Record poll failures as rows: the row is the failure state (#165)
This commit is contained in:
@@ -287,6 +287,24 @@ const (
|
||||
outcomeError
|
||||
outcomeNotFound
|
||||
)
|
||||
// word returns the wire spelling this outcome stores in poll_failures — the
|
||||
// same strings the pass log's columns use (C1, issue #164). Success, refusal
|
||||
// and browser loss return "" so recordFailure's "no statement" case is one
|
||||
// return: a challenge or a lost sidecar is no evidence about any particular
|
||||
// Series (ADR-0016).
|
||||
func (o readOutcome) word() string {
|
||||
switch o {
|
||||
case outcomeNotFound:
|
||||
return "not_found"
|
||||
case outcomeNoChapter:
|
||||
return "no_chapter"
|
||||
case outcomeUnfetchable:
|
||||
return "unfetchable"
|
||||
case outcomeError:
|
||||
return "errors"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// outcomeCounts are the six named outcome counts of one pass. A success
|
||||
// count is derived, never stored: checked minus the five named failures,
|
||||
@@ -456,6 +474,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
}
|
||||
}
|
||||
outcome := p.checkOne(ctx, sr)
|
||||
p.recordFailure(sr, outcome)
|
||||
if outcome == outcomeUnreachable {
|
||||
// The mid-loop browser loss writes an empty skip on purpose: the
|
||||
// pass returns before the checked counter increments, so its row
|
||||
@@ -613,6 +632,28 @@ func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.D
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
// recordFailure keeps one Series' failure row in step with its read
|
||||
// (ADR-0016): a failure word is upserted, a successful read deletes the row,
|
||||
// and refused or unreachable issue no statement at all. Called for every
|
||||
// outcome from the pass loop, so the four failure words and the success path
|
||||
// share one write point, and a forced Poll that reads the page clears through
|
||||
// the ordinary success path — no branch of its own. Log a store failure and
|
||||
// carry on: this is best-effort, and no single bad Series may stall a Lane.
|
||||
func (p *Poller) recordFailure(sr store.Series, outcome readOutcome) {
|
||||
if outcome == outcomeSuccess {
|
||||
if err := p.Store.ClearSeriesFailure(sr.Site, sr.SeriesID); err != nil {
|
||||
log.Printf("latest poll %q: clear failure: %v", sr.Key(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
word := outcome.word()
|
||||
if word == "" {
|
||||
return
|
||||
}
|
||||
if err := p.Store.RecordSeriesFailure(sr.Site, sr.SeriesID, word, p.Now().UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %q: record failure: %v", sr.Key(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkOne re-checks one series. Every failure path here is "log and move on":
|
||||
// the poller is a best-effort enhancement, and no single bad series may stall a
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- One row per Series that is failing right now (ADR-0016): the row's
|
||||
-- existence is the failure state, failing_since ages the run of failures,
|
||||
-- and a correct read deletes the row. Keyed by the same (site, series_id)
|
||||
-- composite the rest of the system uses, with the cascade so deleting a
|
||||
-- Series takes its failure row and orphan removal stays a single statement.
|
||||
CREATE TABLE poll_failures (
|
||||
site text NOT NULL,
|
||||
series_id text NOT NULL,
|
||||
outcome text NOT NULL,
|
||||
failing_since bigint NOT NULL,
|
||||
PRIMARY KEY (site, series_id),
|
||||
FOREIGN KEY (site, series_id) REFERENCES series (site, series_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1297,6 +1297,31 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
}
|
||||
return pausedUntil, refuseUntil, nil
|
||||
}
|
||||
// RecordSeriesFailure upserts one Series' failure row: the outcome word is
|
||||
// updated when it changes, failing_since is never overwritten, and an
|
||||
// unchanged word writes nothing. One statement, so the identical-word no-op
|
||||
// and the keep-the-stamp rule are the same guarantee: the SET arm fires only
|
||||
// when the word differs, and failing_since is simply absent from it
|
||||
// (ADR-0016).
|
||||
func (s *Store) RecordSeriesFailure(site, seriesID, outcome string, now int64) error {
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO poll_failures (site, series_id, outcome, failing_since) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (site, series_id) DO UPDATE SET outcome = EXCLUDED.outcome
|
||||
WHERE poll_failures.outcome <> EXCLUDED.outcome`, site, seriesID, outcome, now); err != nil {
|
||||
return fmt.Errorf("record series failure %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSeriesFailure deletes one Series' failure row. A row that is not there
|
||||
// is not an error.
|
||||
func (s *Store) ClearSeriesFailure(site, seriesID string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM poll_failures WHERE site = $1 AND series_id = $2`, site, seriesID); err != nil {
|
||||
return fmt.Errorf("clear series failure %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DueForLatestCheck returns one Site's series whose server-side
|
||||
// latest-chapter check has aged past cutoffMs, ordered by how many bookmarks
|
||||
|
||||
@@ -2611,3 +2611,92 @@ func TestRemoveSeriesMissingKeyIsCleanNoOp(t *testing.T) {
|
||||
t.Fatalf("RemoveSeries on a missing key = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
// The failure row's existence is the failure state (ADR-0016):
|
||||
// RecordSeriesFailure upserts the outcome word, keeps the original
|
||||
// failing_since through a word change, and an unchanged word writes nothing
|
||||
// at all — a broken Series costs the same as a healthy one every hour.
|
||||
func TestRecordSeriesFailureUpsertKeepsFailingSince(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
word, since, found := failureRow(t, s, "asura", "solo")
|
||||
if !found || word != "not_found" || since != 1000 {
|
||||
t.Fatalf("row after create = (%q, %d, %v), want (not_found, 1000, true)", word, since, found)
|
||||
}
|
||||
|
||||
// A changed word updates the outcome and never touches failing_since.
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "errors", 2000); err != nil {
|
||||
t.Fatalf("record changed word: %v", err)
|
||||
}
|
||||
word, since, found = failureRow(t, s, "asura", "solo")
|
||||
if !found || word != "errors" || since != 1000 {
|
||||
t.Fatalf("row after word change = (%q, %d, %v), want (errors, 1000, true)", word, since, found)
|
||||
}
|
||||
|
||||
// An identical word writes nothing: the stamp survives a later now.
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "errors", 3000); err != nil {
|
||||
t.Fatalf("record identical word: %v", err)
|
||||
}
|
||||
word, since, found = failureRow(t, s, "asura", "solo")
|
||||
if !found || word != "errors" || since != 1000 {
|
||||
t.Fatalf("row after identical word = (%q, %d, %v), want the earlier (errors, 1000, true)", word, since, found)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearSeriesFailure deletes the row, and a row that is not there is not an
|
||||
// error — the poller clears on every successful read, so most clears find
|
||||
// nothing.
|
||||
func TestClearSeriesFailure(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
|
||||
if err := s.ClearSeriesFailure("asura", "solo"); err != nil {
|
||||
t.Fatalf("clear a missing row: %v, want nil", err)
|
||||
}
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
if err := s.ClearSeriesFailure("asura", "solo"); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if _, _, found := failureRow(t, s, "asura", "solo"); found {
|
||||
t.Fatal("row after clear: still present")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a Series takes its failure row with it (the composite FK's
|
||||
// cascade), so orphan removal stays a single statement.
|
||||
func TestRemoveSeriesCascadesToFailureRow(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
if err := s.Delete(s.OwnerID(), "asura:solo"); err != nil {
|
||||
t.Fatalf("delete bookmark: %v", err)
|
||||
}
|
||||
if err := s.RemoveSeries("asura", "solo"); err != nil {
|
||||
t.Fatalf("RemoveSeries: %v", err)
|
||||
}
|
||||
if _, _, found := failureRow(t, s, "asura", "solo"); found {
|
||||
t.Fatal("failure row after series delete: still present (no cascade)")
|
||||
}
|
||||
}
|
||||
|
||||
// failureRow reads one Series' failure row as stored.
|
||||
func failureRow(t *testing.T, s *Store, site, seriesID string) (word string, since int64, found bool) {
|
||||
t.Helper()
|
||||
err := s.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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user