Record poll failures as rows: the row is the failure state (#165)

This commit is contained in:
2026-08-22 23:22:57 +07:00
parent 4708703044
commit 417f809d75
6 changed files with 530 additions and 0 deletions
+215
View File
@@ -2725,3 +2725,218 @@ func TestRunOnceForcedPassReclaimFailureDoesNotFailPoll(t *testing.T) {
t.Fatalf("Cover = %q, want the replacement %q", got.Cover, want)
}
}
// failureRow reads one Series' failure row as stored, for the poller tests
// that must see the table the store API writes (ADR-0016).
func failureRow(t *testing.T, dbURL, site, seriesID string) (word string, since int64, found bool) {
t.Helper()
db, err := sql.Open("pgx", dbURL)
if err != nil {
t.Fatalf("open %s: %v", dbURL, err)
}
defer db.Close()
err = db.QueryRow(
`SELECT outcome, failing_since FROM poll_failures WHERE site = $1 AND series_id = $2`,
site, seriesID).Scan(&word, &since)
if errors.Is(err, sql.ErrNoRows) {
return "", 0, false
}
if err != nil {
t.Fatalf("read failure row %s:%s: %v", site, seriesID, err)
}
return word, since, true
}
// The failure row follows the read (ADR-0016): a 404 writes a row with the
// outcome word and the pass's time; a second 404 an hour later leaves
// failing_since alone — the age is the age of the run of failures, not of
// the current word; a good read deletes the row.
func TestRunLanePassFailureRowFollowsTheRead(t *testing.T) {
s, dbURL := newTestStore(t)
now := time.UnixMilli(5_000_000)
const (
key = "asura:chronicles-of-the-demon-faction-f886a8af"
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
)
seedForCheck(t, s, key, seriesURL, 0)
f := &fakeFetcher{perURL: map[string]fakeResponse{seriesURL: {status: 404}}}
p := newTestPoller(t, s, f, now)
p.runLanePass(context.Background(), "asura", false)
word, since, found := failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af")
if !found || word != "not_found" || since != now.UnixMilli() {
t.Fatalf("row after 404 = (%q, %d, %v), want (not_found, %d, true)", word, since, found, now.UnixMilli())
}
// A repeated failure an hour later: the word is unchanged and the stamp
// is untouched, so the age keeps meaning the run of failures.
p.Now = func() time.Time { return now.Add(2 * time.Hour) }
p.runLanePass(context.Background(), "asura", false)
word, since, found = failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af")
if !found || word != "not_found" || since != now.UnixMilli() {
t.Fatalf("row after repeated 404 = (%q, %d, %v), want (not_found, %d, true)", word, since, found, now.UnixMilli())
}
// A good read ends the failure: the row is gone.
f.perURL[seriesURL] = fakeResponse{body: asuraSeriesFixture, status: 200}
p.Now = func() time.Time { return now.Add(4 * time.Hour) }
p.runLanePass(context.Background(), "asura", false)
if _, _, found := failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af"); found {
t.Fatal("failure row after a good read: still present")
}
}
// Every failure word the pass counts lands in the table under the wire
// spelling the worklist left-joins on (C1): no_chapter, unfetchable, errors,
// not_found — and a success writes no row.
func TestRunLanePassStoresEachFailureWord(t *testing.T) {
s, dbURL := newTestStore(t)
now := time.UnixMilli(5_000_000)
seeds := map[string]string{
"asura:no-chapter": "https://asurascans.com/comics/no-chapter",
"asura:unfetchable": "https://evil.example/x",
"asura:transport": "https://asurascans.com/series/transport",
"asura:gone": "https://asurascans.com/series/gone",
"asura:healthy": "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af",
}
for key, url := range seeds {
seedForCheck(t, s, key, url, 0)
}
f := &fakeFetcher{perURL: map[string]fakeResponse{
seeds["asura:no-chapter"]: {body: "<html></html>", status: 200},
seeds["asura:transport"]: {err: errors.New("dial tcp: refused")},
seeds["asura:gone"]: {status: 404},
seeds["asura:healthy"]: {body: asuraSeriesFixture, status: 200},
}}
newTestPoller(t, s, f, now).runLanePass(context.Background(), "asura", false)
want := map[string]string{
"no-chapter": "no_chapter",
"unfetchable": "unfetchable",
"transport": "errors",
"gone": "not_found",
}
for seriesID, word := range want {
got, _, found := failureRow(t, dbURL, "asura", seriesID)
if !found || got != word {
t.Fatalf("failure word for %s = (%q, %v), want %q", seriesID, got, found, word)
}
}
if _, _, found := failureRow(t, dbURL, "asura", "healthy"); found {
t.Fatal("success Series gained a failure row")
}
}
// refused and unreachable issue no statement at all (ADR-0016): a challenge
// or a lost sidecar is no evidence about any particular Series, so a
// pre-seeded row survives both untouched and a Series with no row gains none.
func TestRunLanePassRefusalAndUnreachableWriteNothing(t *testing.T) {
s, dbURL := newTestStore(t)
now := time.UnixMilli(5_000_000)
t.Run("refused", func(t *testing.T) {
const (
seeded = "asura:held"
neverFail = "asura:other"
)
seedForCheck(t, s, seeded, "https://asurascans.com/series/held", 0)
seedForCheck(t, s, neverFail, "https://asurascans.com/series/other", 0)
if err := s.RecordSeriesFailure("asura", "held", "not_found", 7_000); err != nil {
t.Fatalf("seed failure row: %v", err)
}
f := &fakeFetcher{perURL: map[string]fakeResponse{
"https://asurascans.com/series/held": {status: 403},
"https://asurascans.com/series/other": {status: 403},
}}
newTestPoller(t, s, f, now).runLanePass(context.Background(), "asura", false)
word, since, found := failureRow(t, dbURL, "asura", "held")
if !found || word != "not_found" || since != 7_000 {
t.Fatalf("pre-seeded row after refusal = (%q, %d, %v), want (not_found, 7000, true)", word, since, found)
}
if _, _, found := failureRow(t, dbURL, "asura", "other"); found {
t.Fatal("row appeared for a refused Series with none: refusal must write nothing")
}
})
t.Run("unreachable mid-loop", func(t *testing.T) {
const (
seeded = "comix:c"
neverFail = "comix:d"
)
seedForCheck(t, s, seeded, "https://comix.to/title/c", 0)
seedForCheck(t, s, neverFail, "https://comix.to/title/d", 0)
if err := s.RecordSeriesFailure("comix", "c", "errors", 7_000); err != nil {
t.Fatalf("seed failure row: %v", err)
}
interrupted := fmt.Errorf("%w: %w", errBrowserInterrupted, errors.New("restart"))
browser := &fakeFetcher{status: 200, err: interrupted}
p := newTestPoller(t, s, &fakeFetcher{status: 200}, now)
p.BrowserFetch = browser
p.runLanePass(context.Background(), "comix", false)
word, since, found := failureRow(t, dbURL, "comix", "c")
if !found || word != "errors" || since != 7_000 {
t.Fatalf("pre-seeded row after unreachable = (%q, %d, %v), want (errors, 7000, true)", word, since, found)
}
if _, _, found := failureRow(t, dbURL, "comix", "d"); found {
t.Fatal("row appeared for an unreachable Series with none: unreachable must write nothing")
}
})
}
// A Forced Poll request clears nothing — it only stamps the request — and a
// forced Poll that then reads the page clears the row through the ordinary
// success path, with no forced branch in the code.
func TestRunLanePassForcedPollClearsThroughSuccess(t *testing.T) {
s, dbURL := newTestStore(t)
now := time.UnixMilli(5_000_000)
const (
key = "asura:chronicles-of-the-demon-faction-f886a8af"
seriesURL = "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af"
)
seedForCheck(t, s, key, seriesURL, 0)
if err := s.RecordSeriesFailure("asura", "chronicles-of-the-demon-faction-f886a8af", "not_found", 7_000); err != nil {
t.Fatalf("seed failure row: %v", err)
}
if err := s.ForceSeriesPoll("asura", "chronicles-of-the-demon-faction-f886a8af", now.Add(time.Hour).UnixMilli()); err != nil {
t.Fatalf("ForceSeriesPoll: %v", err)
}
// The request alone cleared nothing.
if _, _, found := failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af"); !found {
t.Fatal("failure row gone after only a Forced Poll request")
}
newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now).
runLanePass(context.Background(), "asura", false)
if _, _, found := failureRow(t, dbURL, "asura", "chronicles-of-the-demon-faction-f886a8af"); found {
t.Fatal("failure row after a forced Poll that read the page: still present")
}
}
// The due query does not join the failure table (AC7): a failing Series is
// polled at the same pace as any other, so its failure age keeps meaning what
// the worklist reads it as.
func TestPollFailureDoesNotChangePacing(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(5_000_000)
seedForCheck(t, s, "asura:failing", "https://asurascans.com/series/failing", 0)
seedForCheck(t, s, "asura:healthy", "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af", 0)
f := &fakeFetcher{perURL: map[string]fakeResponse{
"https://asurascans.com/series/failing": {status: 404},
"https://asurascans.com/series/healthy": {body: asuraSeriesFixture, status: 200},
}}
p := newTestPoller(t, s, f, now)
p.runLanePass(context.Background(), "asura", false)
if got := f.callCount(); got != 2 {
t.Fatalf("first pass fetched %d series, want both (the failure row must not exclude the failing one)", got)
}
// After the rest both are due again: the row the first pass wrote changed
// nothing about when the failing Series is polled.
p.Now = func() time.Time { return now.Add(2 * time.Hour) }
p.runLanePass(context.Background(), "asura", false)
if got := f.callCount(); got != 4 {
t.Fatalf("second pass fetched %d series, want both again", got)
}
}