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
+75 -32
View File
@@ -9,32 +9,38 @@ import (
// Series filter names (issue #140): the seven repair filters are ordered
// permanent-then-fixable — the repairs nothing will ever undo first, the
// ones a Poll can make right after. SeriesFilterFinished is not part of
// that ordering: a finished Series is a deliberate state, not a repair, so
// it sits last, informational. A name is the repair a row needs, not the
// SQL that finds it; the values are the wire form the Series list URL
// carries (#142). "all" is the absent and unknown case: every Series.
// ones a Poll can make right after. SeriesFilterFinished and
// SeriesFilterSiteCompleted are not part of that ordering: a finished
// Series is a deliberate state and a Site-completed one is the Site's own
// marker, not repairs, so the pair rides the tail, informational. A name is
// the repair a row needs, not the SQL that finds it; the values are the
// wire form the Series list URL carries (#142). "all" is the absent and
// unknown case: every Series.
const (
SeriesFilterAll = "all"
SeriesFilterNoURL = "no_series_url"
SeriesFilterNoChapter = "never_read_a_chapter"
SeriesFilterNoReaders = "no_readers"
SeriesFilterNeverChecked = "never_checked"
SeriesFilterStale = "stale"
SeriesFilterNoCover = "no_cover"
SeriesFilterReaderReport = "reader_report"
SeriesFilterFinished = "finished"
SeriesFilterAll = "all"
SeriesFilterNoURL = "no_series_url"
SeriesFilterNoChapter = "never_read_a_chapter"
SeriesFilterNoReaders = "no_readers"
SeriesFilterNeverChecked = "never_checked"
SeriesFilterStale = "stale"
SeriesFilterNoCover = "no_cover"
SeriesFilterReaderReport = "reader_report"
SeriesFilterFinished = "finished"
SeriesFilterSiteCompleted = "site_completed"
SeriesFilterFailing = "failing"
SeriesFilterUnverified = "unverified"
)
// SeriesFilter is one named filter predicate over the whole library. Site
// and Kind narrow the row read; Name picks the predicate; Cutoff is the
// staleness boundary the "stale" filter compares against, supplied by the
// caller's clock — the store has no clock; Page is 1-based.
// staleness boundary the age-based filters — "stale" and the failing pair —
// compare against, supplied by the caller's clock — the store has no clock;
// Page is 1-based.
type SeriesFilter struct {
Site string // "" = every Site
Kind string // "" = both library buckets' series
Name string // one of the SeriesFilter* constants; "" = SeriesFilterAll
Cutoff int64 // unix ms; "stale" reads it, the store never does
Cutoff int64 // unix ms; the age-based filters read it, the store never does
Page int // 1-based page of the row read; default 1
}
@@ -45,7 +51,7 @@ type SeriesFilter struct {
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
s.latest_corrected_at, s.finished_at`
s.latest_corrected_at, s.finished_at, s.site_completed_at`
// raisedByReaderAnswer answers "did a Reader's report set this number" without
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
@@ -54,6 +60,12 @@ const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_
// Reader behind them.
const raisedByReaderAnswer = `(s.latest_raised_by IS NOT NULL) AS raised_by_reader`
// failureAnswer is the poll-failures row's answer (issue #165), kept apart
// from adminSeriesColumns like raisedByReaderAnswer: outcome and failing_since
// are not Series columns, and the COALESCE keeps the row scannable when the
// LEFT JOIN finds no failure row.
const failureAnswer = `COALESCE(f.outcome, ''), COALESCE(f.failing_since, 0)`
// seriesPageSize is the row read's page length. The tie-break in the query's
// ORDER BY is what makes this a stable page boundary — see SeriesPage.
const seriesPageSize = 50
@@ -83,9 +95,19 @@ type AdminSeries struct {
LatestCorrectedAt int64
ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum
// FailureOutcome is the outcome word of the Series' standing failure, ""
// when no failure row stands. FailingSince is when the run of failures
// began, zero with no row. Both come from the LEFT JOIN, not the Series
// row (issue #165: the row's existence is the state).
FailureOutcome string
FailingSince int64
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
// not finished — the same shape as the Correction stamp, and its own undo.
FinishedAt int64
// SiteCompletedAt is when the last successful Poll read saw the Site's own
// completed value, zero meaning it did not (issue #168). The Site's marker,
// never a Lifecycle decision: the owner's Finish is the only retirement.
SiteCompletedAt int64
}
// SeriesPage is one page of the owner's filtered Series list plus the count
@@ -119,17 +141,26 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
// gate case is invisible to SQL, needs the Site registry in Go, and belongs to
// a later repair), never-read-a-chapter and never-checked as disjoint halves
// (non-zero versus zero check stamp), stale, no cover, finished (the
// retirement stamp, read directly), and Reader-report.
// retirement stamp, read directly), site completed (the Site's marker, read
// directly), Reader-report, and the failing pair — failing (the failure row
// exists, a chapter exists, and failing_since is past the cutoff) and
// unverified (the Reader-attributed subset of failing).
// failing's chapter IS NOT NULL is the exact complement of never-read-a-
// chapter's IS NULL half, so the two are disjoint by construction.
// no_readers is the one HAVING predicate: it is the orphan test, an aggregate
// over the LEFT JOIN, where a bare WHERE has no row to test.
//
// The clock-versus-outcome split decides which predicates exclude finished
// Series (`s.finished_at = 0` in each of the four): the clock-driven one —
// Series (`s.finished_at = 0` in each of the five): the clock-driven ones —
// never-checked, stale, no-chapter, no-cover — keep ticking after the last
// Poll, so they would report a retired row as a problem no Poll is coming to
// fix; the three outcome-driven ones — no-URL, no-readers, Reader-report —
// read stored facts that simply stop arriving, so a finished Series needing a
// genuine repair still shows up under them.
// fix; site-completed's stamp is the Site's, and it keeps standing after the
// owner retires the row, so a finished Series would be reported as work
// nobody is going to do. The outcome-driven ones — no-URL, no-readers,
// Reader-report, failing, unverified — read stored facts that simply stop
// arriving, so a finished Series needing a genuine repair still shows up
// under them. The failing pair carries no finished guard for exactly that
// contrast: a failure row is a stored outcome, not a ticking clock.
//
// stale is the checked-but-old half of the stamp partition — because the
// verdict line wants "not checked in twelve hours" as one figure, and a never
@@ -157,8 +188,16 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
case SeriesFilterReaderReport:
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
case SeriesFilterFailing:
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1))
args = append(args, f.Cutoff)
case SeriesFilterUnverified:
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1)+` AND s.latest_raised_by IS NOT NULL`)
args = append(args, f.Cutoff)
case SeriesFilterFinished:
clauses = append(clauses, `s.finished_at > 0`)
case SeriesFilterSiteCompleted:
clauses = append(clauses, `s.site_completed_at > 0 AND s.finished_at = 0`)
case SeriesFilterNoReaders:
having = `HAVING COUNT(b.reader_id) = 0`
default:
@@ -204,15 +243,18 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
base := len(args)
args = append(args, seriesPageSize, seriesPageSize*(f.Page-1))
rows, err := s.db.Query(`
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`,
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`, `+failureAnswer+`,
COUNT(b.reader_id) AS reader_count,
COUNT(*) OVER () AS filtered_total
FROM series s
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
`+where+`
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.site_completed_at,
s.latest_raised_by,
f.outcome, f.failing_since
`+having+`
ORDER BY s.latest_checked_at, s.site, s.series_id
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
@@ -251,6 +293,7 @@ func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
SELECT s.site, s.kind
FROM series s
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
`+where+`
GROUP BY s.site, s.series_id, s.kind
`+having+`
@@ -273,11 +316,11 @@ func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
return out, rows.Err()
}
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer
// order, plus the query's reader_count and filtered_total columns, and returns
// the window total alongside the row. latest_chapter_num is NULL until first
// captured — the "never read a chapter" state. The Sighting-raiser column is
// never among the scanned columns.
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer +
// failureAnswer order, plus the query's reader_count and filtered_total
// columns, and returns the window total alongside the row. latest_chapter_num
// is NULL until first captured — the "never read a chapter" state. The
// Sighting-raiser column is never among the scanned columns.
func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
var (
a AdminSeries
@@ -287,8 +330,8 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
if err := scan(
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt,
&a.RaisedByReader, &a.ReaderCount, &total,
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.SiteCompletedAt,
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
); err != nil {
return AdminSeries{}, 0, err
}