From fe2cd12049d2c0f8683373aeb98b6470464254ed Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 22 Aug 2026 00:53:51 +0700 Subject: [PATCH] =?UTF-8?q?feat(web,store,latest):=20forced=20poll=20?= =?UTF-8?q?=E2=80=94=20Check=20now=20stamps=20a=20Series=20for=20the=20nex?= =?UTF-8?q?t=20Lane=20pass=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control writes series.force_poll_at (column landed in migration 0014) and never commands the poller: pending is derived as force_poll_at > latest_checked_at and self-clears because the check stamp is written before the fetch. The due query's forced flag overrides the rest cutoff, the Sighting-deferral and finished-only clauses, jumps the queue, and wakes a sleeping browser Lane; it never overrides an empty series_url, the Bookmarks join, the refusal backoff, the sidecar-down skip or the Lane gap. ADRs: 0013-commands-through-the-database. --- backend/internal/latest/poller.go | 21 +- backend/internal/latest/poller_test.go | 138 +++++++++++++ backend/internal/store/admin.go | 13 +- backend/internal/store/admin_test.go | 46 +++++ backend/internal/store/store.go | 57 +++++- backend/internal/store/store_test.go | 153 +++++++++++++- backend/internal/web/admin.go | 1 + backend/internal/web/admin_series.go | 74 +++++++ backend/internal/web/admin_series_detail.go | 47 ++++- backend/internal/web/static/admin.css | 10 + .../internal/web/templates/series-detail.html | 22 +- .../internal/web/templates/series-list.html | 29 ++- backend/web_test.go | 191 +++++++++++++++++- .../adr/0013-commands-through-the-database.md | 60 ++++++ 14 files changed, 818 insertions(+), 44 deletions(-) create mode 100644 docs/adr/0013-commands-through-the-database.md diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index 90834e8..035ff84 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -347,11 +347,13 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. return defaultGap } fig.Due = len(due) - if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) { + if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) && !anyForced(due) { // Below both thresholds Chrome stays asleep (ADR-0005 on-demand // browser): waking it for a single Poll would cost a challenge solve - // per request. The Lane still paces at the default gap, which is what - // the owner's page must show rather than a zero. + // per request. A forced Series is the one exception — a human asking + // is not the machine waking itself (issue #146). The Lane still paces + // at the default gap, which is what the owner's page must show rather + // than a zero. rec.skip = SkipAsleep fig.Gap = defaultGap return defaultGap @@ -539,6 +541,19 @@ func browserWakeDue(due []store.Series, now time.Time, rest time.Duration) bool return maxSeriesWait(due, now, rest) >= browserWakeAge } +// anyForced reports whether the due list holds a forced Series: one whose +// owner check-now request (issue #146) has not been answered yet. A human +// asking wakes a sleeping Chrome even below the wake thresholds; the request +// itself still ages visibly if the home machine is off. +func anyForced(due []store.Series) bool { + for _, sr := range due { + if sr.Forced { + return true + } + } + return false +} + // maxSeriesWait returns how long the most-overdue of the due Series has been // waiting past its due moment (0 when due is empty). func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.Duration { diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index 4a83373..70da921 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -2098,3 +2098,141 @@ func TestPassRetentionCutoffIsFourteenDays(t *testing.T) { t.Fatalf("passes = %d, want 2 (this pass plus the kept row)", n) } } + +// stampRecordingFetcher is a fakeFetcher that records the Series' check stamp +// at call time — the seam that proves the check stamp is written before the +// fetch (issue #146). If the order were swapped, the recorded stamp would be +// the pre-pass value and the ordering assertion would fail. +type stampRecordingFetcher struct { + fakeFetcher + store *store.Store + site string + seriesID string + stampAtCall int64 +} + +func (f *stampRecordingFetcher) Get(ctx context.Context, url string) (string, int, error) { + ts, err := f.store.LatestCheckedAt(f.site, f.seriesID) + if err != nil { + return "", 0, err + } + f.stampAtCall = ts + return f.fakeFetcher.Get(ctx, url) +} + +// The check stamp is written before the fetch is attempted: the fake fetcher +// records the stamp it sees at call time, and it must already be the pass's +// own stamp. The order is load-bearing — a forced request is pending while +// force_poll_at > latest_checked_at, so stamping after the fetch would make a +// failed forced request sticky — and the assertion fails if it is swapped. +func TestCheckStampIsWrittenBeforeFetch(t *testing.T) { + s, _ := newTestStore(t) + 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) + now := time.UnixMilli(7_000_000) + rec := &stampRecordingFetcher{ + fakeFetcher: fakeFetcher{body: asuraSeriesFixture, status: 200}, + store: s, site: "asura", seriesID: "chronicles-of-the-demon-faction-f886a8af", + } + newTestPoller(t, s, rec, now).runOnce(context.Background()) + if rec.stampAtCall != now.UnixMilli() { + t.Fatalf("check stamp at fetch time = %d, want %d (the stamp must be written before the fetch)", rec.stampAtCall, now.UnixMilli()) + } +} + +// A forced Series whose attempt fails is no longer pending: the check stamp +// is written before the fetch, so the first attempt ends the pending state +// whatever it returns. A naive implementation (stamp only on success, or +// after the fetch) leaves the request sticky and the row due next pass. +func TestForcedSeriesSelfClearsOnFailedAttempt(t *testing.T) { + s, _ := newTestStore(t) + now := time.UnixMilli(7_000_000) + const url = "https://asurascans.com/comics/x" + // Freshly checked, so only the force flag makes it due. + seedForCheck(t, s, "asura:x", url, now.Add(-30*time.Minute).UnixMilli()) + if err := s.ForceSeriesPoll("asura", "x", now.UnixMilli()); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + + due, err := s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + if len(due) != 1 || due[0].Key() != "asura:x" || !due[0].Forced { + t.Fatalf("forced row not due and flagged before the attempt: %+v", due) + } + + // The attempt fails, but the attempt still happened: the row is stamped + // and no longer pending. + f := &fakeFetcher{err: errors.New("dial tcp: refused")} + newTestPoller(t, s, f, now).runOnce(context.Background()) + if got := readLatestCheckedAt(t, s, "asura:x"); got != now.UnixMilli() { + t.Fatalf("latest_checked_at = %d, want %d", got, now.UnixMilli()) + } + due, err = s.DueForLatestCheck("asura", now.Add(-time.Hour).UnixMilli(), -1) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + if len(due) != 0 { + t.Fatalf("failed attempt left the forced row due: %v", due) + } +} + +// The refusal backoff is a Lane gate, not a Series gate: a forced Series does +// not override a Site that is actively refusing, because hand-forcing a +// request into a refusal only makes it worse (issue #146). +func TestForcedSeriesDoesNotOverrideRefusalBackoff(t *testing.T) { + s, _ := newTestStore(t) + now := time.UnixMilli(5_000_000) + seedForCheck(t, s, "kagane:w", "https://kagane.to/series/w", 0) + if err := s.ForceSeriesPoll("kagane", "w", now.UnixMilli()); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + if err := s.SetLaneRefusal("kagane", now.Add(RefuseBackoff).UnixMilli()); err != nil { + t.Fatalf("SetLaneRefusal: %v", err) + } + + browser := &fakeFetcher{body: kaganeAPIFixture, status: 200} + p := &Poller{ + Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser, + Now: func() time.Time { return now }, + } + p.runOnce(context.Background()) + if got := browser.callCount(); got != 0 { + t.Fatalf("browser fetches through a refusal backoff = %d, want 0", got) + } +} + +// A forced Series wakes a sleeping browser Lane: the wake thresholds exist to +// stop the machine waking itself for one unattended check, and a human asking +// is not that (issue #146). Below both thresholds the Lane still sleeps when +// nothing is forced. +func TestForcedSeriesWakesSleepingBrowser(t *testing.T) { + s, _ := newTestStore(t) + now := time.UnixMilli(5_000_000) + // Freshly due: checked two minutes before the rest elapses, so the wait + // is far below browserWakeAge and the count is under browserWakeCount. + seedForCheck(t, s, "kagane:w1", "https://kagane.to/series/w1", now.Add(-62*time.Minute).UnixMilli()) + seedForCheck(t, s, "kagane:w2", "https://kagane.to/series/w2", now.Add(-62*time.Minute).UnixMilli()) + + browser := &fakeFetcher{body: kaganeAPIFixture, status: 200} + p := &Poller{ + Store: s, Fetch: &fakeFetcher{status: 200}, BrowserFetch: browser, + Now: func() time.Time { return now }, + } + p.runOnce(context.Background()) + if got := browser.callCount(); got != 0 { + t.Fatalf("browser fetches without a forced series = %d, want 0 (Chrome stays asleep)", got) + } + + if err := s.ForceSeriesPoll("kagane", "w1", now.UnixMilli()); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + p.runOnce(context.Background()) + if got := browser.callCount(); got != 2 { + t.Fatalf("browser fetches with a forced series = %d, want 2 (the lane wakes)", got) + } +} diff --git a/backend/internal/store/admin.go b/backend/internal/store/admin.go index 24e05fd..7e78924 100644 --- a/backend/internal/store/admin.go +++ b/backend/internal/store/admin.go @@ -41,7 +41,7 @@ type SeriesFilter struct { // must never leave the store package — so the projection does not select it, // 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.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at` // raisedByReaderAnswer answers "did a Reader's report set this number" without // naming which Reader. Kept apart from adminSeriesColumns so the column list — @@ -68,8 +68,12 @@ type AdminSeries struct { LatestChapter string LatestChapterNum *float64 // nil until first captured LatestCheckedAt int64 - ReaderCount int - RaisedByReader bool // a Reader's report set LatestChapterNum + // ForcePollAt is the owner's "check now" request stamp (issue #146), zero + // meaning never asked. Pending is derived, never stored: a request is + // pending while ForcePollAt is newer than LatestCheckedAt. + ForcePollAt int64 + ReaderCount int + RaisedByReader bool // a Reader's report set LatestChapterNum } // SeriesPage is one page of the owner's filtered Series list plus the count @@ -185,7 +189,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) { `+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.latest_raised_by + s.force_poll_at, s.latest_raised_by `+having+` ORDER BY s.latest_checked_at, s.site, s.series_id LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...) @@ -260,6 +264,7 @@ 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.RaisedByReader, &a.ReaderCount, &total, ); err != nil { return AdminSeries{}, 0, err diff --git a/backend/internal/store/admin_test.go b/backend/internal/store/admin_test.go index 8a62d55..f5f7a73 100644 --- a/backend/internal/store/admin_test.go +++ b/backend/internal/store/admin_test.go @@ -348,3 +348,49 @@ func TestAdminFilterUnknownNameRejected(t *testing.T) { } } } + +// ForceSeriesPoll is the idempotent stamp write: a second press overwrites +// the request time, and touching a missing series is not an error. +func TestForceSeriesPollStampsIdempotently(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1}) + + if err := s.ForceSeriesPoll("asura", "x", 42); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + if err := s.ForceSeriesPoll("asura", "x", 99); err != nil { + t.Fatalf("ForceSeriesPoll re-stamp: %v", err) + } + // Touching a missing series is not an error: the row may have been + // orphaned, and the caller's read decides what exists. + if err := s.ForceSeriesPoll("asura", "ghost", 99); err != nil { + t.Fatalf("ForceSeriesPoll missing: %v", err) + } + + var got int64 + if err := s.db.QueryRow( + `SELECT force_poll_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil { + t.Fatalf("read force_poll_at: %v", err) + } + if got != 99 { + t.Fatalf("force_poll_at = %d, want 99 (the later press wins)", got) + } +} + +// The admin projection carries the force stamp so the web layer can derive +// the pending flag without a second read. +func TestAdminSeriesCarriesForcePollAt(t *testing.T) { + s := newTestStore(t) + seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1}) + if err := s.ForceSeriesPoll("asura", "x", 5000); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + + page, err := s.SeriesPage(SeriesFilter{}) + if err != nil { + t.Fatalf("SeriesPage: %v", err) + } + if len(page.Rows) != 1 || page.Rows[0].ForcePollAt != 5000 { + t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows) + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index e7ee4b7..da4dd0d 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -88,6 +88,11 @@ type Series struct { // readerCount is the number of bookmarks referencing this series, filled // only by the due-queue query that orders on it. readerCount int + // Forced is whether the owner asked for a check now (issue #146): the + // request stamp is newer than the check stamp. Derived in the due query, + // never stored, and the flag that jumps the queue and opens the browser + // wake gate. + Forced bool } // LanePass is one Poll Lane's durable pass snapshot. Pause and refusal stamps @@ -621,8 +626,9 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) { } // scanSeries reads one row in seriesColumns order, plus the due query's -// reader_count column. latest_chapter_num and latest_raised_by are both -// nullable, same as latest_chapter_num on the bookmark read path. +// forced flag and reader_count columns. latest_chapter_num and +// latest_raised_by are both nullable, same as latest_chapter_num on the +// bookmark read path. func scanSeries(scan func(...any) error) (Series, error) { var ( sr Series @@ -632,7 +638,7 @@ func scanSeries(scan func(...any) error) (Series, error) { if err := scan( &sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress, &sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy, - &sr.readerCount, + &sr.Forced, &sr.readerCount, ); err != nil { return Series{}, err } @@ -1164,6 +1170,13 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro // limit — the Lane's own gap paces the fetches, and the batch size that used // to cap this query is gone with the shared pace. // +// A forced Series (force_poll_at newer than latest_checked_at, issue #146) +// overrides exactly three gates: the rest cutoff, the Sighting-deferral +// clause and the finished-only bucket. It never overrides an empty +// series_url or the Bookmarks join — nothing to fetch, and no consumer for +// the result — so those stay unconditional. Forced rows sort to the front of +// the queue; the reader-count-then-age ordering among the rest is ADR-0003. +// // The reader_count ordering is the point of the split (ADR-0003): a series // shared by several readers is fetched once per due cycle, and the popular // ones stay freshest while the long tail absorbs any shortfall. Within one @@ -1192,19 +1205,26 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro // allowed to defer at all was settled when the Sighting was recorded — see // RecordSighting. func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Series, error) { - rows, err := s.db.Query(`SELECT `+seriesColumns+`, COUNT(*) AS reader_count + rows, err := s.db.Query(`SELECT `+seriesColumns+`, + (s.force_poll_at > s.latest_checked_at) AS forced, + COUNT(*) AS reader_count FROM series s JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id WHERE s.site = $1 AND s.series_url <> '' - AND s.latest_checked_at <= $2::bigint + AND (s.latest_checked_at <= $2::bigint + OR s.force_poll_at > s.latest_checked_at) GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover, - s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at - HAVING COUNT(*) FILTER (WHERE b.status <> 'finished') > 0 + s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, + s.force_poll_at + HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0 + OR s.force_poll_at > s.latest_checked_at) AND (COUNT(*) > 1 OR s.latest_sighted_at <= $2::bigint - OR s.latest_checked_at <= $3::bigint) - ORDER BY reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs) + OR s.latest_checked_at <= $3::bigint + OR s.force_poll_at > s.latest_checked_at) + ORDER BY (s.force_poll_at > s.latest_checked_at) DESC, + reader_count DESC, s.latest_checked_at ASC`, site, cutoffMs, ceilingMs) if err != nil { return nil, fmt.Errorf("query due series: %w", err) } @@ -1264,6 +1284,25 @@ func (s *Store) MarkLatestChecked(site, seriesID string, ts int64) error { return nil } +// ForceSeriesPoll stamps a Series with the owner's "check now" request +// (issue #146): a fact about the Series the Lane's next pass reads through +// DueForLatestCheck, never a command to the poller — so the request survives +// a restart. Writing again overwrites the request time; the write is +// idempotent. Touching a missing series is not an error: the row may have +// been orphaned, and the caller's read decides what exists. The stamp never +// expires by itself — an unanswered request keeps ageing — and pending is +// derived as force_poll_at > latest_checked_at, which is why the poller's +// check stamp is written before the fetch: the first attempt ends the +// pending state whatever it returns. +func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error { + if _, err := s.db.Exec( + `UPDATE series SET force_poll_at = $1 WHERE site = $2 AND series_id = $3`, + at, site, seriesID); err != nil { + return fmt.Errorf("force poll %s:%s: %w", site, seriesID, err) + } + return nil +} + // LatestCheckedAt reads the column MarkLatestChecked writes. It exists for // tests outside this package (the poller's own tests assert on rest // bookkeeping) — see MarkLatestChecked for why the field stays off the diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index a663def..4aa3b9d 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -758,7 +758,7 @@ func TestMigration0008DropsLegacyCoverRows(t *testing.T) { func readSeries(t *testing.T, s *Store, site, seriesID string) Series { t.Helper() sr, err := scanSeries(s.db.QueryRow( - `SELECT `+seriesColumns+`, 0 AS reader_count FROM series s + `SELECT `+seriesColumns+`, false AS forced, 0 AS reader_count FROM series s WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan) if err != nil { t.Fatalf("read series %s:%s: %v", site, seriesID, err) @@ -1748,3 +1748,154 @@ func TestLaneStatePauseResumeAndRefusal(t *testing.T) { t.Fatalf("lane state rows after resume = %d, want 1", rows) } } + +// A forced Series is due ahead of the rest cutoff: the request overrides the +// rest gate so the Lane's next pass picks it up however recently it was +// checked. An unforced series under the rest stays out. +func TestDueForLatestCheckForcedOverridesRestCutoff(t *testing.T) { + s := newTestStore(t) + const now = int64(10 * 3600_000) + seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", now-30*60_000) + seedForCheck(t, s, "asura:fresh", "https://asurascans.com/comics/fresh", now-30*60_000) + if err := s.ForceSeriesPoll("asura", "forced", now); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + + due, err := s.DueForLatestCheck("asura", now-3600_000, noCeiling) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + if len(due) != 1 || due[0].Key() != "asura:forced" { + t.Fatalf("due = %v, want only the forced series", due) + } +} + +// The Sighting-deferral clause holds a solitary series back after a recent +// Reader report; a forced request overrides it and puts the series back on +// the Lane's list. +func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) { + s := newTestStore(t) + const now = int64(10 * 3600_000) + // One bookmark (so deferral can apply), sighted and checked 10m ago: + // inside the deferral window and under the ceiling. + seedForCheck(t, s, "asura:deferred", "https://asurascans.com/comics/deferred", now-10*60_000) + if _, err := s.db.Exec( + `UPDATE series SET latest_sighted_at = $1 WHERE site = 'asura' AND series_id = 'deferred'`, + now-10*60_000); err != nil { + t.Fatalf("seed sighting: %v", err) + } + + // Unforced: deferred, and under the rest anyway. + due, err := s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + if len(due) != 0 { + t.Fatalf("unforced deferred series is due: %v", due) + } + + // Forced: the request overrides the deferral. + if err := s.ForceSeriesPoll("asura", "deferred", now); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + due, err = s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + if len(due) != 1 || due[0].Key() != "asura:deferred" { + t.Fatalf("forced deferred series not due: %v", due) + } +} + +// The finished-only bucket excludes a series whose only bookmarks are +// finished; a forced request overrides it — the owner asked, so the Lane +// looks. +func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) { + s := newTestStore(t) + seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0) + if _, err := s.Upsert(s.OwnerID(), Bookmark{ + Key: "asura:finished", Site: "asura", SeriesID: "finished", + SeriesURL: "https://asurascans.com/comics/finished", + Status: StatusFinished, UpdatedAt: 1000, + }); err != nil { + t.Fatalf("seed finished: %v", err) + } + + due, err := s.DueForLatestCheck("asura", 1000, noCeiling) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + for _, sr := range due { + if sr.Key() == "asura:finished" { + t.Fatalf("unforced finished series is due: %v", due) + } + } + + if err := s.ForceSeriesPoll("asura", "finished", 5000); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + due, err = s.DueForLatestCheck("asura", 1000, noCeiling) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + got := map[string]bool{} + for _, sr := range due { + got[sr.Key()] = true + } + if !got["asura:reading"] || !got["asura:finished"] { + t.Fatalf("forced finished series not due: %v", due) + } +} + +// A forced Series jumps the queue: it sorts ahead of a more-read series that +// is due by rest, without disturbing the reader-count-then-age tie-break +// among the unforced rows (ADR-0003). +func TestDueForLatestCheckForcedSortsFirst(t *testing.T) { + s := newTestStore(t) + // "popular" has two readers and is long overdue; "forced" has one reader + // and a fresh check stamp. The forced row must come first. + seedForCheck(t, s, "asura:popular", "https://asurascans.com/comics/popular", 100) + seedSecondReader(t, s, "asura:popular:2", "asura", "popular", 1001) + seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", 900) + if err := s.ForceSeriesPoll("asura", "forced", 5000); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + + due, err := s.DueForLatestCheck("asura", 1000, noCeiling) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + if len(due) != 2 { + t.Fatalf("due = %d rows, want 2", len(due)) + } + if due[0].Key() != "asura:forced" || due[1].Key() != "asura:popular" { + t.Fatalf("due order = %q, %q; want forced first, then popular", due[0].Key(), due[1].Key()) + } +} + +// A forced Series with no series URL is still not fetched — nothing to fetch — +// and one with no Bookmarks is still excluded by the join. The force flag +// opens the three gates it is allowed to, not the whole query. +func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) { + s := newTestStore(t) + seedForCheck(t, s, "asura:nourl", "", 0) + if err := s.ForceSeriesPoll("asura", "nourl", 5000); err != nil { + t.Fatalf("ForceSeriesPoll: %v", err) + } + if _, err := s.db.Exec(` + INSERT INTO series (site, series_id, title, series_url, cover, kind, + latest_chapter, latest_chapter_num, latest_checked_at, + force_poll_at) + VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan', + '', 'manga', '', NULL, 0, 5000)`); err != nil { + t.Fatalf("seed orphan: %v", err) + } + + due, err := s.DueForLatestCheck("asura", 1000, noCeiling) + if err != nil { + t.Fatalf("DueForLatestCheck: %v", err) + } + if len(due) != 0 { + t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due) + } +} diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index b83bd19..436e202 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -44,6 +44,7 @@ func (h *Handler) adminRoutes() []adminRoute { {"GET /admin/readers", h.adminReaders}, {"GET /admin/series", h.adminSeries}, {"GET /admin/series/{key}", h.adminSeriesDetail}, + {"POST /admin/series/{key}/poll", h.adminSeriesPoll}, {"GET /ui/admin/lanes", h.uiLanes}, {"POST /readers/{id}/revoke", h.revokeReaderSessions}, {"POST /readers/{id}/clear-marks", h.clearReaderMarks}, diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go index 6b98d35..4730863 100644 --- a/backend/internal/web/admin_series.go +++ b/backend/internal/web/admin_series.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" "bookmarkmanager/backend/internal/latest" @@ -80,6 +81,11 @@ type seriesFilterOption struct { // the alternating row tint by class rather than nth-of-type, so the confirm // rows later tickets add are row siblings without breaking the alternation. // Attention tints the title patina: a row with any hygiene chip needs one. +// +// CanPoll is the Check now control's visibility: absent on a Series with no +// page to fetch and on an orphan, so the owner is never offered a button that +// can never do anything. Pending is derived — the request stamp is newer than +// the check stamp — and Requested is its ageing label. type seriesRowView struct { Key string Title string @@ -91,6 +97,9 @@ type seriesRowView struct { More int // chips past the cap, rendered as a +N tail Band bool Attention bool + CanPoll bool + Pending bool + Requested string // "requested 3m ago", rendered only while pending } // adminSeries renders the filterable, bookmarkable Series list: filter, Site, @@ -106,6 +115,58 @@ func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) { h.renderAdmin(w, adminView{Page: "series", SeriesList: view}) } +// adminSeriesPoll is the Check now action: it stamps the Series' force_poll_at +// and answers with the freshly rendered row, so the figures describe the +// state after the press. The control never commands the poller — the request +// is a fact about the Series, and the Lane's next pass reads it through +// DueForLatestCheck (ADR-0013). The owner gate is the route's, not this +// handler's; the body is capped like the API path caps its bodies; the key is +// validated here — a malformed key is a 400 and an unknown one a 404. +func (h *Handler) adminSeriesPoll(w http.ResponseWriter, r *http.Request) { + site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") + if !ok || site == "" || seriesID == "" { + http.Error(w, "bad series key", http.StatusBadRequest) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil { + log.Printf("series poll %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } else if !found { + http.NotFound(w, r) + return + } + if err := h.store.ForceSeriesPoll(site, seriesID, time.Now().UnixMilli()); err != nil { + log.Printf("series poll %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + // Re-read after the stamp: the answer must describe the state after the + // press. The detail page's control swaps its meta in place and the list + // row's swaps the row; htmx names an id target in HX-Target, so the + // response matches the surface it came from. + a, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series poll %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + if r.Header.Get("HX-Target") == "detail-meta" { + h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a)) + return + } + h.render(w, http.StatusOK, "series-row", seriesRow(a, 0, time.Now())) +} + // seriesListView assembles one Series list view from the request's query // string. An unknown filter value is the absent All case, never an error: the // select's options are not the only way this URL can be reached. @@ -213,6 +274,11 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { Site: a.Site, Readers: a.ReaderCount, Band: i%2 == 1, + CanPoll: a.SeriesURL != "" && a.ReaderCount > 0, + } + if a.ForcePollAt > a.LatestCheckedAt { + row.Pending = true + row.Requested = requestedAge(now, a.ForcePollAt) } if a.LatestChapterNum != nil { row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) @@ -273,6 +339,14 @@ func checkedAge(now time.Time, ts int64) string { } } +// requestedAge is the pending marker's text: how long ago the owner asked, +// and nothing about when the request will run — the page does not know when a +// sleeping browser will wake (issue #146). An unanswered request ages forever; +// there is no expiry. +func requestedAge(now time.Time, ts int64) string { + return "requested " + checkedAge(now, ts) +} + // pagerRange is the pager's "1–50 of 120" line. The template renders the // pager only over rows (the empty state replaces it), so it is never asked // to describe an empty list. diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go index 085565b..c856926 100644 --- a/backend/internal/web/admin_series_detail.go +++ b/backend/internal/web/admin_series_detail.go @@ -31,6 +31,15 @@ type seriesDetailView struct { NoCover bool Orphan bool // no Reader holds the Series SightingRaised bool // a Reader's Sighting set the Latest Chapter + + // Poll is the Check now control and the pending marker (issue #146): the + // same derivation and visibility as the list row. CanPoll is false on a + // Series with no page to fetch and on an orphan; Pending is derived — + // the request stamp is newer than the check stamp — and Requested is its + // ageing label. + CanPoll bool + Pending bool + Requested string } // adminSeriesDetail renders one Series' page, keyed by the composite @@ -38,35 +47,50 @@ type seriesDetailView struct { // the list's own SeriesPage read narrowed to the key's Site: the admin // projection is the privacy boundary, and a dedicated single-row read would // be a second definition of it. -// ponytail: a page scan per detail view, one query per page of the Site's -// rows up to the window total; a keyed read alongside SeriesPage when the -// library outgrows the page size. func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) { site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") if !ok || site == "" || seriesID == "" { http.NotFound(w, r) return } + a, found, err := h.adminSeriesByKey(site, seriesID) + if err != nil { + log.Printf("series detail %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !found { + http.NotFound(w, r) + return + } + h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(a)}) +} + +// adminSeriesByKey reads one Series through the list's own SeriesPage read +// narrowed to the key's Site: the admin projection is the privacy boundary, +// and a dedicated single-row read would be a second definition of it. Absence +// is reported with found=false, never an error. +// ponytail: a page scan per keyed read, one query per page of the Site's rows +// up to the window total; a keyed read alongside SeriesPage when the library +// outgrows the page size. +func (h *Handler) adminSeriesByKey(site, seriesID string) (store.AdminSeries, bool, error) { seen := 0 for page := 1; ; page++ { p, err := h.store.SeriesPage(store.SeriesFilter{Site: site, Page: page}) if err != nil { - log.Printf("series detail %s: %v", site+":"+seriesID, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return + return store.AdminSeries{}, false, err } seen += len(p.Rows) for i := range p.Rows { if p.Rows[i].SeriesID == seriesID { - h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(p.Rows[i])}) - return + return p.Rows[i], true, nil } } if seen >= p.Total { break } } - http.NotFound(w, r) + return store.AdminSeries{}, false, nil } // seriesDetailView shapes one AdminSeries row for display: every judgement in @@ -83,6 +107,11 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { NoCover: a.CoverAddress == "", Orphan: a.ReaderCount == 0, SightingRaised: a.RaisedByReader, + CanPoll: a.SeriesURL != "" && a.ReaderCount > 0, + } + if a.ForcePollAt > a.LatestCheckedAt { + v.Pending = true + v.Requested = requestedAge(time.Now(), a.ForcePollAt) } if a.LatestChapterNum == nil { v.Chapter = "—" diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index c867be6..627b3f7 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -281,6 +281,10 @@ text-align: right; } +.admin-sheet .tbl .c-act .ghost + .ghost { + margin-left: 12px; +} + .admin-sheet .tbl .trow > .confirm-row { grid-column: 1 / -1; padding: 10px 12px; @@ -685,6 +689,12 @@ text-transform: uppercase; } +.admin-sheet .dform .field { + display: flex; + gap: 10px; + margin-top: 8px; +} + .admin-sheet .pausebar { display: flex; align-items: center; diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html index 8cbbc80..3866e16 100644 --- a/backend/internal/web/templates/series-detail.html +++ b/backend/internal/web/templates/series-detail.html @@ -1,21 +1,35 @@ {{/* Per-Series page: one address per Series, keyed ":" so the list row is one hop from it. Everything here is a Series-level fact plus - the anonymous Reader count; the intervention forms a later ticket adds - land inside the empty .detail-grid, so this page edits nothing. */}} + the anonymous Reader count. The Check now control lands in its own .dform + below the (empty) .detail-grid; the pending marker rides the meta line + with the other marks. */}} {{define "series-detail"}} ← Series

{{.Title}}

{{.Key}} · {{.Site}} · {{.Kind}}

{{if .Cover}}
{{else}}
{{end}} -
+{{template "series-detail-meta" .}} +
+{{if .CanPoll}} + +{{end}} +{{end}} + +{{/* series-detail-meta is the meta line, and the answer a Check now press on + the detail page swaps into its place: the same marks, re-rendered after + the stamp so the pending marker shows. */}} +{{define "series-detail-meta"}} +
ch {{.Chapter}} checked {{.Checked}} {{.Readers}} readers + {{if .Pending}}{{.Requested}}{{end}} {{if .Unpollable}}unpollable{{end}} {{if .NoCover}}no cover{{end}} {{if .Orphan}}orphan{{end}} {{if .SightingRaised}}sighting-raised{{end}}
-
{{end}} diff --git a/backend/internal/web/templates/series-list.html b/backend/internal/web/templates/series-list.html index 682c75c..48eeb94 100644 --- a/backend/internal/web/templates/series-list.html +++ b/backend/internal/web/templates/series-list.html @@ -23,17 +23,7 @@ {{if .Rows}}
SiteChCheckedReadersNotes
- {{range .Rows}} -
- {{.Title}} - {{.Site}} - {{.Ch}} - {{.Age}} - {{.Readers}} - {{range .Notes}}{{.}}{{end}}{{if .More}}+{{.More}}{{end}} - -
- {{end}} + {{range .Rows}}{{template "series-row" .}}{{end}}
{{if .PrevHref}}‹ prev{{else}}‹ prev{{end}} @@ -44,3 +34,20 @@
No series

Nothing matches {{.FilterLabel}}.

{{end}} {{end}} + +{{/* series-row is one Series list row, and the answer a Check now press swaps + into the row's place (hx-target="closest .trow"): it must render the + pending marker the press created. The control is absent on a Series with + no page to fetch and on an orphan, so the owner is never offered a button + that can never do anything. */}} +{{define "series-row"}} +
+ {{.Title}}{{if .Pending}}{{.Requested}}{{end}} + {{.Site}} + {{.Ch}} + {{.Age}} + {{.Readers}} + {{range .Notes}}{{.}}{{end}}{{if .More}}+{{.More}}{{end}} + {{if .CanPoll}}Check now{{end}} +
+{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index ad494c8..a515d19 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -2354,10 +2354,15 @@ func TestSeriesListRowShape(t *testing.T) { if !strings.Contains(body, `class="c-site site-asura"`) { t.Errorf("the site cell lacks its site class:\n%s", body) } - // The action cell is present and empty for the sibling ticket, and no - // Remove control or confirm row renders in this batch. + // The action cell carries the Check now control on pollable rows and is + // empty on the orphan (no URL, no Readers — a button that can never do + // anything is not offered), and no Remove control or confirm row renders + // in this batch. if !strings.Contains(body, ``) { - t.Errorf("the action cell is not present and empty:\n%s", body) + t.Errorf("the orphan's action cell is not present and empty:\n%s", body) + } + if got := strings.Count(body, "Check now"); got != 2 { + t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body) } if strings.Contains(body, "Remove") || strings.Contains(body, "confirm-row") { t.Errorf("a Remove control or confirm row renders in this batch:\n%s", body) @@ -2599,3 +2604,183 @@ func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) { t.Errorf("title rendered raw:\n%s", body) } } + +// seriesDetailPage drives one Series detail request as the owner and returns +// the rendered body. +func seriesDetailPage(t *testing.T, srv http.Handler, st *store.Store, key string) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/admin/series/"+key, nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET /admin/series/%s status = %d, want 200", key, rr.Code) + } + return rr.Body.String() +} + +// Pressing Check now answers with freshly rendered markup showing the pending +// marker: the list row (or the detail meta) re-rendered after the stamp, so +// the figures describe the state after the press, not before (issue #146). +func TestSeriesPollRoundTrip(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + seed(t, st, store.Bookmark{ + Key: "asura:x", Site: "asura", SeriesID: "x", + Title: "Title of asura:x", SeriesURL: "https://asurascans.com/comics/x", + }) + + // List surface: the row anchor swaps its own row. + req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST poll status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + if !strings.Contains(body, `class="trow`) { + t.Errorf("poll response is not the freshly rendered row:\n%s", body) + } + if !strings.Contains(body, `requested `) { + t.Errorf("poll response lacks the pending marker:\n%s", body) + } + + // Detail surface: the meta fragment carries the same marker. + req = httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil) + req.AddCookie(sessionCookie(t, st)) + req.Header.Set("HX-Target", "detail-meta") + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST poll (detail) status = %d, want 200", rr.Code) + } + body = rr.Body.String() + if !strings.Contains(body, `id="detail-meta"`) || !strings.Contains(body, "requested ") { + t.Errorf("detail response lacks the meta fragment with the pending marker:\n%s", body) + } + + // The request is durable: the list shows the pending marker, which is the + // same row the press's answer rendered. + body = adminSeriesPage(t, router, st, "") + if !strings.Contains(body, "requested ") { + t.Errorf("the list does not show the pending marker after the press:\n%s", body) + } +} + +// The Check now control is hidden on a Series with no page to fetch and on +// one no Reader holds — the owner is never offered a button that can never do +// anything — and present otherwise, on both the list row and the detail page. +func TestSeriesPollControlVisibility(t *testing.T) { + st, dsn := newTestStoreURL(t) + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open %s: %v", dsn, err) + } + defer db.Close() + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:ok", url: "u", checkedAt: 9000, bookmarks: 1}) + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:nourl", checkedAt: 9000, bookmarks: 1}) + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:orphan", url: "u", checkedAt: 9000, bookmarks: 0}) + router := newRouter(st, testConfig()) + + // List: exactly the pollable row offers the control. + body := adminSeriesPage(t, router, st, "") + if got := strings.Count(body, "Check now"); got != 1 { + t.Errorf("list offers Check now %d times, want 1 (only the pollable row):\n%s", got, body) + } + // Detail pages: the pollable row offers it, the other two do not. + for _, tc := range []struct { + key string + want bool + }{ + {"asura:ok", true}, + {"asura:nourl", false}, + {"asura:orphan", false}, + } { + body := seriesDetailPage(t, router, st, tc.key) + if got := strings.Contains(body, "Check now"); got != tc.want { + t.Errorf("%s detail offers Check now = %v, want %v", tc.key, got, tc.want) + } + } +} + +// The pending marker ages and never expires: an old unanswered request still +// renders its marker with an old age, and a second press re-stamps the +// request time. +func TestSeriesPollMarkerAgesAndNeverExpires(t *testing.T) { + st, dsn := newTestStoreURL(t) + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open %s: %v", dsn, err) + } + defer db.Close() + seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:x", url: "u", checkedAt: 0, bookmarks: 1}) + // A five-day-old request, never checked: pending, with no expiry. + if _, err := db.Exec( + `UPDATE series SET force_poll_at = $1 WHERE site = 'asura' AND series_id = 'x'`, + time.Now().Add(-5*24*time.Hour).UnixMilli()); err != nil { + t.Fatalf("seed force stamp: %v", err) + } + router := newRouter(st, testConfig()) + body := adminSeriesPage(t, router, st, "") + if !strings.Contains(body, "requested 5d ago") { + t.Errorf("old request does not render its aged marker:\n%s", body) + } + + // A second press re-stamps: the marker reads fresh again. + req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST poll status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "requested 1m ago") { + t.Errorf("re-stamp does not re-age the marker:\n%s", rr.Body.String()) + } +} + +// A malformed key is a 400 and an unknown key a 404 — neither is a 500, and +// neither reaches the store as an unvalidated write. +func TestSeriesPollRejectsBadKeys(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + for _, tc := range []struct { + path string + want int + }{ + {"/admin/series/nocolon/poll", http.StatusBadRequest}, + {"/admin/series/:x/poll", http.StatusBadRequest}, + {"/admin/series/asura:/poll", http.StatusBadRequest}, + {"/admin/series/asura:ghost/poll", http.StatusNotFound}, + } { + req := httptest.NewRequest(http.MethodPost, tc.path, nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != tc.want { + t.Errorf("POST %s status = %d, want %d", tc.path, rr.Code, tc.want) + } + } +} + +// Form bodies on the action route are capped the way the API path caps them: +// an oversized body is a 400, not a memory grant. +func TestSeriesPollCapsBody(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + seed(t, st, store.Bookmark{ + Key: "asura:x", Site: "asura", SeriesID: "x", + Title: "Title of asura:x", SeriesURL: "u", + }) + big := strings.Repeat("a", 1<<17) // 128 KiB, over the 64 KiB cap + req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:x/poll", strings.NewReader(big)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("oversized body status = %d, want 400", rr.Code) + } + // Nothing was stamped: the list still shows no pending marker. + if body := adminSeriesPage(t, router, st, ""); strings.Contains(body, "requested ") { + t.Errorf("an oversized body still stamped the request:\n%s", body) + } +} diff --git a/docs/adr/0013-commands-through-the-database.md b/docs/adr/0013-commands-through-the-database.md new file mode 100644 index 0000000..ad93761 --- /dev/null +++ b/docs/adr/0013-commands-through-the-database.md @@ -0,0 +1,60 @@ +# ADR-0013: Commands through the database + +Date: 2026-08-22 +Status: accepted + +## Decision + +Owner interventions are **facts about rows, never commands to the poller**. +*Check now* (`POST /admin/series/{key}/poll`) writes one stamp — +`series.force_poll_at`, unix ms, zero meaning never asked (the column landed +in migration 0014) — and the poller's next pass reads it through +`Store.DueForLatestCheck`. The control never signals the running process, so +a request survives a restart, and the whole surface is testable with no +poller running at all. + +**Pending is derived, never stored**: a Series is pending while +`force_poll_at > latest_checked_at`. It self-clears with no second write and +no sweeper because the check stamp is written *before* the fetch (the same +"attempted" discipline as ADR-0010) — the first attempt ends the pending +state whatever the attempt returns. There is no expiry: a request the Lane +never reaches keeps ageing in the UI, and an old pending marker is itself the +evidence that a Lane is stuck. Writing again re-stamps the request time; the +write is idempotent. + +**Queue-jump rules.** A forced Series overrides exactly three gates in the +due query: the rest cutoff, the Sighting-deferral clause and the finished-only +bucket, and it sorts to the front of the queue +(`ORDER BY forced DESC, reader_count DESC, latest_checked_at ASC`). It never +overrides an empty `series_url` (nothing to fetch), the Bookmarks join (a +Series no Reader holds has no consumer for the result), the Lane's refusal +backoff, the sidecar-down skip, or the Lane's gap — the last three are +poller-side gates the query cannot see and must not. The one pass-level gate +a forced Series does open is the browser wake threshold: a human asking wakes +a sleeping Chrome, where the thresholds exist to stop the machine waking +itself for one unattended check. If the home machine is off, nothing happens +and the request ages visibly, which is correct. + +Rejected: zeroing the check stamp as the force signal. It would corrupt the +never-checked and stale counts the landing page exists to show, and make a +pending marker impossible. + +## Why + +A stuck-looking Series previously waited for its turn in the Lane's hour, and +there was no way to ask for one check sooner. A direct poller command would +have been lost on every restart and untestable without a running poller; a +row the poller already reads is neither. Deriving pending from the two stamps +keeps the flag honest across restarts and makes the mechanism two column +writes and three query clauses instead of a state machine. + +## Constraints + +- The finished-status clause the force flag overrides is today's Lifecycle + test; a later spec in this series deletes it wholesale rather than amending + it, so the clause stays as it stands. +- The control is unconfirmed (it takes nothing away) and renders no + `.confirm-row`; it is hidden on a Series with no `series_url` and on an + orphan — the same pair the due query refuses to override. +- The answer to a press is the freshly rendered row, so the figures describe + the state after the press.