Add finished Series filter; clock-driven hygiene predicates exclude it (#159)

This commit is contained in:
2026-08-22 17:25:10 +07:00
parent f85641da61
commit 0029cff27c
5 changed files with 218 additions and 23 deletions
+24 -10
View File
@@ -7,11 +7,13 @@ import (
"strings"
)
// Series filter names (issue #140), ordered permanent-then-fixable — the
// repairs nothing will ever undo first, the ones a Poll can make right after.
// 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.
// Series filter names (issue #140): the eight hygiene names 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.
const (
SeriesFilterAll = "all"
SeriesFilterNoURL = "no_series_url"
@@ -21,6 +23,7 @@ const (
SeriesFilterStale = "stale"
SeriesFilterNoCover = "no_cover"
SeriesFilterReaderReport = "reader_report"
SeriesFilterFinished = "finished"
)
// SeriesFilter is one named hygiene predicate over the whole library. Site
@@ -115,10 +118,19 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
// The WHERE set is: no URL (an empty URL only — the host-failing-the-fetch-
// 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, and Reader-report.
// (non-zero versus zero check stamp), stale, no cover, finished (the
// retirement stamp, read directly), and Reader-report.
// 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 —
// 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.
//
// 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
// checked Series is already counted on its own "waiting"/never-checked
@@ -135,16 +147,18 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
case SeriesFilterNoURL:
clauses = append(clauses, `s.series_url = ''`)
case SeriesFilterNoChapter:
clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL`)
clauses = append(clauses, `s.latest_checked_at <> 0 AND s.latest_chapter_num IS NULL AND s.finished_at = 0`)
case SeriesFilterNeverChecked:
clauses = append(clauses, `s.latest_checked_at = 0`)
clauses = append(clauses, `s.latest_checked_at = 0 AND s.finished_at = 0`)
case SeriesFilterStale:
clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1))
clauses = append(clauses, `s.latest_checked_at > 0 AND s.latest_checked_at < $`+strconv.Itoa(len(args)+1)+` AND s.finished_at = 0`)
args = append(args, f.Cutoff)
case SeriesFilterNoCover:
clauses = append(clauses, `s.cover_address = ''`)
clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
case SeriesFilterReaderReport:
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
case SeriesFilterFinished:
clauses = append(clauses, `s.finished_at > 0`)
case SeriesFilterNoReaders:
having = `HAVING COUNT(b.reader_id) = 0`
default:
+79
View File
@@ -128,6 +128,85 @@ func TestAdminSeriesFilters(t *testing.T) {
}
}
// A finished Series is the owner's deliberate state, not a problem a Poll
// will fix: the four clock-driven hygiene predicates exclude it (their
// stamps stop advancing at the last Poll, so without the guard a retired row
// is reported forever), the three outcome-driven ones still include it, and
// the finished filter returns exactly the retired rows.
func TestAdminFinishedSeriesFilters(t *testing.T) {
s := newTestStore(t)
// Each fin-* row is shaped to trip exactly one predicate if its guard
// fails: checked-but-old for stale, a zero stamp for never-checked, a
// stamp with no chapter for no-chapter, an empty cover for no-cover, and
// the unguarded three shaped to trip their own. A healthy, unfinished
// neighbour keeps the exclusion checks honest: a filter that regressed to
// matching nothing would pass a bare "no finished rows" assertion.
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-stale", url: "u", cover: "c", checkedAt: 2000, latestNum: new(4.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-neverchecked", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nocover", url: "u", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-nourl", url: "", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-orphan", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 0})
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-report", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, raisedBy: true})
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
finished := []string{
"asura:fin-stale", "asura:fin-neverchecked", "asura:fin-nochapter",
"asura:fin-nocover", "asura:fin-nourl", "asura:fin-orphan", "asura:fin-report",
}
for _, key := range finished {
site, id, _ := strings.Cut(key, ":")
if err := s.SetSeriesFinished(site, id, 1000); err != nil {
t.Fatalf("finish %s: %v", key, err)
}
}
cases := []struct {
name string
f SeriesFilter
want []string
}{
{"stale excludes finished", SeriesFilter{Name: SeriesFilterStale, Cutoff: 5000}, nil},
{"never checked excludes finished", SeriesFilter{Name: SeriesFilterNeverChecked}, nil},
{"no chapter excludes finished", SeriesFilter{Name: SeriesFilterNoChapter}, nil},
{"no cover excludes finished", SeriesFilter{Name: SeriesFilterNoCover}, nil},
{"no url includes finished", SeriesFilter{Name: SeriesFilterNoURL}, []string{"asura:fin-nourl"}},
{"no readers includes finished", SeriesFilter{Name: SeriesFilterNoReaders}, []string{"asura:fin-orphan"}},
{"reader report includes finished", SeriesFilter{Name: SeriesFilterReaderReport}, []string{"asura:fin-report"}},
{"finished returns the retired rows", SeriesFilter{Name: SeriesFilterFinished}, finished},
}
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)
}
}
})
}
// The aggregate's finished total counts every retired row — the same
// predicate the Overview's finished figure is summed from.
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterFinished})
if err != nil {
t.Fatalf("SeriesShapes(finished): %v", err)
}
sum := 0
for _, sh := range shapes {
sum += sh.Total
}
if sum != len(finished) {
t.Fatalf("finished aggregate = %d, want %d", sum, len(finished))
}
}
// "Never read a chapter" and "never checked" are disjoint by construction:
// the first requires a non-zero check stamp, the second a zero one. Over a
// mix that should satisfy both, no row may be counted twice.