Spec #137: per-Series poll failure state, completion hint, and outbound owner notification (#174)

Implements spec #137 (spec 4 of 4 from wayfinder map #114).

Closes #137.

Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch.

## Summary
- #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate.
- #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion.
- #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition.
- #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing.
- #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier.
- #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree.

`cd backend && go test ./...` green on the merged branch (8 packages).

Reviewed-on: #174
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #174.
This commit is contained in:
2026-08-23 11:31:10 +07:00
committed by sulthan
parent 8e4fa6448e
commit e93e79c1bb
31 changed files with 3745 additions and 176 deletions
+218 -11
View File
@@ -12,14 +12,15 @@ import (
// Upsert path could not produce together: an orphan has no bookmark, and a
// Reader-raised Latest Chapter needs a Sighting the store does not create.
type seriesSeed struct {
key string
kind string
url string
cover string // cover_address
checkedAt int64
latestNum *float64
bookmarks int // readers that hold it; 0 = orphan
raisedBy bool // a Reader's report is attributed as the raiser
key string
kind string
url string
cover string // cover_address
checkedAt int64
latestNum *float64
bookmarks int // readers that hold it; 0 = orphan
raisedBy bool // a Reader's report is attributed as the raiser
siteCompletedAt int64 // the Site's own marker (issue #168), 0 = not set
}
// seedAdminSeries inserts one series row and its bookmarks (owner first, then
@@ -39,10 +40,10 @@ func seedAdminSeries(t *testing.T, s *Store, seed seriesSeed) {
}
if _, err := s.db.Exec(`
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
latest_checked_at, latest_chapter, latest_chapter_num)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
seed.checkedAt, latestChapter, seed.latestNum, seed.siteCompletedAt); err != nil {
t.Fatalf("seed series %q: %v", seed.key, err)
}
for i := range seed.bookmarks {
@@ -527,3 +528,209 @@ func TestAdminSeriesCarriesFinishedAt(t *testing.T) {
t.Fatalf("row = %+v, want FinishedAt 5000", page.Rows)
}
}
// The failing pair reads the failure row's age, not the Series row (issue
// #165): failing requires the joined row, a Latest Chapter, and failing_since
// past the cutoff — the same constant stale reads; unverified is the
// Reader-attributed subset of the same test. A failure inside the window is
// in neither — one bad fetch is not a fault to correct — and a Series that
// never captured a chapter is never failing, the exact complement of
// never-read-a-chapter's IS NULL half, so the two filters are disjoint by
// construction. A finished failing Series still appears: this pair reads
// stored outcomes, which simply stop arriving, and carries no finished guard.
func TestAdminFailingAndUnverifiedFilters(t *testing.T) {
s := newTestStore(t)
const cutoff = 5000
seedAdminSeries(t, s, seriesSeed{key: "asura:failing", url: "u", cover: "c", checkedAt: 9000, latestNum: new(10.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:recent", url: "u", cover: "c", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:reader", url: "u", cover: "c", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1, raisedBy: true})
seedAdminSeries(t, s, seriesSeed{key: "asura:polled", url: "u", cover: "c", checkedAt: 9000, latestNum: new(7.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-failing", url: "u", cover: "c", checkedAt: 9000, latestNum: new(6.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(5.0), bookmarks: 1})
// Failure rows seed the run's start stamp; the cutoff decides the age.
for _, f := range []struct {
key, word string
since int64
}{
{"asura:failing", "not_found", 2000},
{"asura:recent", "not_found", 9000},
{"asura:nochapter", "not_found", 2000},
{"asura:reader", "not_found", 2000},
{"asura:polled", "errors", 2000},
{"asura:fin-failing", "not_found", 2000},
} {
site, id, _ := strings.Cut(f.key, ":")
if err := s.RecordSeriesFailure(site, id, f.word, f.since); err != nil {
t.Fatalf("seed failure %s: %v", f.key, err)
}
}
if err := s.SetSeriesFinished("asura", "fin-failing", 1000); err != nil {
t.Fatalf("finish asura:fin-failing: %v", err)
}
cases := []struct {
name string
f SeriesFilter
want []string
}{
{"failing", SeriesFilter{Name: SeriesFilterFailing, Cutoff: cutoff}, []string{"asura:failing", "asura:reader", "asura:polled", "asura:fin-failing"}},
{"unverified", SeriesFilter{Name: SeriesFilterUnverified, Cutoff: cutoff}, []string{"asura:reader"}},
{"never read a chapter", SeriesFilter{Name: SeriesFilterNoChapter}, []string{"asura:nochapter"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := pageKeys(t, s, tc.f)
want := map[string]bool{}
for _, k := range tc.want {
want[k] = true
}
if len(got) != len(want) {
t.Fatalf("%+v returned %v, want exactly %v", tc.f, got, want)
}
for k := range want {
if !got[k] {
t.Fatalf("%+v dropped %q (got %v)", tc.f, k, got)
}
}
})
}
// Disjointness: the failing pair and never-read-a-chapter are disjoint by
// the chapter column — IS NOT NULL here, IS NULL there — so no row may be
// counted under both.
failing := pageKeys(t, s, SeriesFilter{Name: SeriesFilterFailing, Cutoff: cutoff})
noChapter := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoChapter})
for k := range failing {
if noChapter[k] {
t.Fatalf("row %q matches both failing and never-read-a-chapter", k)
}
}
if failing["asura:nochapter"] {
t.Fatal("a Series with no chapter ever captured appears in failing")
}
if !noChapter["asura:nochapter"] {
t.Fatal("the no-chapter Series vanished from never-read-a-chapter")
}
// The aggregates count the same rows the lists do: the landing page's
// figures and the select's options come from these two passes.
for _, name := range []string{SeriesFilterFailing, SeriesFilterUnverified} {
shapes, err := s.SeriesShapes(SeriesFilter{Name: name, Cutoff: cutoff})
if err != nil {
t.Fatalf("SeriesShapes(%s): %v", name, err)
}
sum := 0
for _, sh := range shapes {
sum += sh.Total
}
want := len(pageKeys(t, s, SeriesFilter{Name: name, Cutoff: cutoff}))
if sum != want {
t.Fatalf("%s aggregate sum = %d, want %d (aggregate disagrees with row query)", name, sum, want)
}
}
}
// The projection carries the failure facts from the LEFT JOIN, not the
// Series row: a failing Series reads back its outcome word and the stamp its
// run began at; a Series with no failure row reads empty and zero. Nothing
// new is projected from the Series row itself.
func TestAdminFailureProjection(t *testing.T) {
s := newTestStore(t)
seedAdminSeries(t, s, seriesSeed{key: "asura:broken", url: "u", cover: "c", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1})
if err := s.RecordSeriesFailure("asura", "broken", "not_found", 2000); err != nil {
t.Fatalf("seed failure row: %v", err)
}
page, err := s.SeriesPage(SeriesFilter{})
if err != nil {
t.Fatalf("SeriesPage: %v", err)
}
byKey := map[string]AdminSeries{}
for _, a := range page.Rows {
byKey[a.Key()] = a
}
if byKey["asura:broken"].FailureOutcome != "not_found" || byKey["asura:broken"].FailingSince != 2000 {
t.Fatalf("broken row = %+v, want FailureOutcome not_found, FailingSince 2000", byKey["asura:broken"])
}
if byKey["asura:fine"].FailureOutcome != "" || byKey["asura:fine"].FailingSince != 0 {
t.Fatalf("fine row = %+v, want empty outcome and zero stamp", byKey["asura:fine"])
}
}
// The site-completed filter (issue #170) lists the Site's own marker as work
// to work through, carrying the same finished guard the clock-driven
// predicates gained: the Site's stamp keeps standing after the owner retires
// the row, so a finished Series would be reported as work nobody is going to
// do. A zero stamp never appears. Un-finishing puts the Series back in the
// Poll query — the finished_at gate is #157's own, asserted through
// DueForLatestCheck rather than re-derived here.
func TestAdminSiteCompletedFilter(t *testing.T) {
s := newTestStore(t)
seedAdminSeries(t, s, seriesSeed{key: "asura:done", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
seedAdminSeries(t, s, seriesSeed{key: "asura:never", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
if err := s.SetSeriesFinished("asura", "retired", 1000); err != nil {
t.Fatalf("finish asura:retired: %v", err)
}
got := pageKeys(t, s, SeriesFilter{Name: SeriesFilterSiteCompleted})
if len(got) != 1 || !got["asura:done"] {
t.Fatalf("site-completed returned %v, want only asura:done", got)
}
// The projection carries the stamp so the detail page can age it.
page, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterSiteCompleted})
if err != nil {
t.Fatalf("SeriesPage(site_completed): %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].SiteCompletedAt != 5000 {
t.Fatalf("row = %+v, want SiteCompletedAt 5000", page.Rows)
}
// The aggregate counts the same row: the landing figure and the select's
// option come from this pass, so they cannot disagree with the list.
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterSiteCompleted})
if err != nil {
t.Fatalf("SeriesShapes(site_completed): %v", err)
}
sum := 0
for _, sh := range shapes {
sum += sh.Total
}
if sum != 1 {
t.Fatalf("site-completed aggregate = %d, want 1", sum)
}
// Un-finishing puts the Series back in the Poll query: the due read's
// finished_at gate (issue #157) admits it again — asserted through the
// Lane's own read, not re-derived here.
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck: %v", err)
}
for _, sr := range due {
if sr.Key() == "asura:retired" {
t.Fatalf("a finished Series is still due for a Poll:\n%+v", due)
}
}
if err := s.SetSeriesFinished("asura", "retired", 0); err != nil {
t.Fatalf("un-finish asura:retired: %v", err)
}
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
if err != nil {
t.Fatalf("DueForLatestCheck after un-finish: %v", err)
}
found := false
for _, sr := range due {
if sr.Key() == "asura:retired" {
found = true
}
}
if !found {
t.Fatalf("un-finished Series is not due for a Poll:\n%+v", due)
}
}