From cb2b104e63455b488e068679e833a1a3c3b4581b Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 20:25:38 +0700 Subject: [PATCH] feat(web): read admin lanes from the durable pass log, not a poller snapshot (#145) The Lanes page now projects store.LatestLanePasses and the owner-window outcomes (store.LanePassOutcomes) into per-Site rows instead of reading an in-memory Poller snapshot, so a deploy answers the instant the store is up. Browser configuration is a config fact and reachability is derived from recent browser-Site passes inside latest.RefuseBackoff. This atomically deletes the in-memory path in the same commit that makes the page read the DB: latest/status.go (LaneState, Status, LaneStatus, recordLaneState) and the web.LaneReporter seam plus fakeLanes are gone, and latest.refuseBackoff is renamed latest.RefuseBackoff at every callsite. ownerWindow (#142) is referenced, never declared (contract C2) --- backend/AGENTS.md | 18 +- backend/api_test.go | 10 +- backend/cover_test.go | 2 +- backend/internal/latest/poller.go | 112 ++++---- backend/internal/latest/poller_test.go | 171 +++++------- backend/internal/latest/sites.go | 7 +- backend/internal/latest/status.go | 79 ------ backend/internal/web/admin.go | 117 --------- backend/internal/web/admin_lanes.go | 239 +++++++++++++++++ backend/internal/web/static/admin.css | 44 ++++ backend/internal/web/templates/lanes.html | 66 ++--- backend/internal/web/web.go | 22 +- backend/main.go | 35 ++- backend/reader_credential_test.go | 6 +- backend/web_test.go | 302 ++++++++++++++++------ docs/adr/0012-persisted-lane-state.md | 44 ++++ 16 files changed, 757 insertions(+), 517 deletions(-) delete mode 100644 backend/internal/latest/status.go create mode 100644 backend/internal/web/admin_lanes.go create mode 100644 docs/adr/0012-persisted-lane-state.md diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fc91b34..f8bb0a3 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -252,16 +252,18 @@ keep warning to reinstall on all devices. (`view.Owner = readerID == h.store.OwnerID()`): it gates a link, not an endpoint, so it is a rendering decision a registration-time wrapper cannot express. Do not "unify" it into the gate. -- Lane figures come through the `web.LaneReporter` seam - (`latest.Poller.LaneStatus`), never a table. `main.newRouter` takes the - reporter as an interface and converts a nil `*Poller` to a nil interface — a - typed nil would make the page claim a poller exists. +- The Lanes page reads the pass log, never a running poller: `lanesView()` in + `admin_lanes.go` projects `store.LatestLanePasses()` and + `store.LanePassOutcomes()` (ADR-0012), so a restart answers the instant the + database is up. Browser configuration is a config fact and reachability is + derived from recent browser-Site passes inside `latest.RefuseBackoff` — no + reporter interface exists to fake. - A pass that returns before computing figures (refusal backoff, sidecar down) carries the previous pass's numbers forward rather than recording zeroes. - **`Checked` next to `Due` is what separates a stopped Lane from a quiet one**, so neither may be dropped from the row. - Due-without-Checked is **not** by itself a stall: a browser Lane under both - wake thresholds sets `LaneState.Asleep` and renders "browser asleep", and - never counts toward `Attention`. That is the commonest healthy state for - kagane, comix and novelfull, so spending the stall mark on it would train the - owner to ignore the mark that matters. + wake thresholds records its pass with the `SkipAsleep` skip and renders + "browser asleep", and that never counts toward `Attention`. It is the + commonest healthy state for kagane, comix and novelfull, so spending the + stall mark on it would train the owner to ignore the mark that matters. diff --git a/backend/api_test.go b/backend/api_test.go index beee5a8..1037808 100644 --- a/backend/api_test.go +++ b/backend/api_test.go @@ -48,7 +48,7 @@ func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) } func newTestServer(t *testing.T) http.Handler { t.Helper() - return newRouter(newTestStore(t), testConfig(), nil) + return newRouter(newTestStore(t), testConfig()) } func newTestStore(t *testing.T) *store.Store { @@ -599,7 +599,7 @@ func TestLoadConfigDiscord(t *testing.T) { // cooldown and the poller would re-fetch that series on every single tick. func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) { s := newTestStore(t) - srv := newRouter(s, testConfig(), nil) + srv := newRouter(s, testConfig()) seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777) @@ -627,7 +627,7 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) { // series stops being due the moment the PUT lands. func TestPutRecordsASighting(t *testing.T) { s := newTestStore(t) - srv := newRouter(s, testConfig(), nil) + srv := newRouter(s, testConfig()) now := time.Now().UnixMilli() hour := time.Hour.Milliseconds() @@ -677,7 +677,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/u/"+ownerCredential()+"/manga-bookmark.user.js", nil) - newRouter(s, cfg, nil).ServeHTTP(rr, req) + newRouter(s, cfg).ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } @@ -699,7 +699,7 @@ func TestNovelUserscriptServed(t *testing.T) { cfg := testConfig() cfg.NovelUserscriptPath = novelPath - srv := newRouter(s, cfg, nil) + srv := newRouter(s, cfg) rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, diff --git a/backend/cover_test.go b/backend/cover_test.go index 77ea269..9c3c93f 100644 --- a/backend/cover_test.go +++ b/backend/cover_test.go @@ -98,7 +98,7 @@ func TestPublicCoverNeverEchoesNonImage(t *testing.T) { if _, err := db.Exec(`UPDATE covers SET content_type = 'text/html' WHERE address = $1`, address); err != nil { t.Fatalf("poison row: %v", err) } - rr := getCover(t, newRouter(st, testConfig(), nil), "/covers/"+address, nil) + rr := getCover(t, newRouter(st, testConfig()), "/covers/"+address, nil) if rr.Code == http.StatusOK { t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String()) } diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index dac582b..cf7be40 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -58,15 +58,11 @@ type Poller struct { // refuseUntil gates a Site's Lane after it refused twice in one run: no // Series of that Site is attempted again before this time (issue #100). // browserDownAt is when a browser Lane last lost the sidecar; the other - // browser Lanes skip their passes for the next refuseBackoff, so a + // browser Lanes skip their passes for the next RefuseBackoff, so a // restarting Chrome does not stamp one Series per pass per Lane (story 20). mu sync.Mutex refuseUntil map[string]time.Time browserDownAt time.Time - // laneStates is the owner's page snapshot of each Lane's last pass - // (issue #102), keyed by Site. Guarded by mu; a Site appears only after - // its first pass, so a restart renders "no data yet" rather than zeroes. - laneStates map[string]LaneState // coverWG tracks in-flight cover work. Covers heal in the background so a // slow cover host cannot delay the next Series-page Poll; tests join it // before asserting on cover fetches. @@ -226,14 +222,16 @@ func (p *Poller) runOnce(ctx context.Context) { // empty means the pass reached the loop. The values are wire strings — stored // in poll_passes and read by the Lanes page — so they are stable, not prose. const ( - skipPaused = "paused" // the pause row was read at the top - skipRefusing = "refusing" // refusal backoff - skipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome - skipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback - skipDueQuery = "due-query" // the due query failed - skipAsleep = "asleep" // under both browser wake thresholds - skipEligibleCount = "eligible-count" // the eligible count failed - skipNothingEligible = "nothing-eligible" // nothing eligible; sleeps a full rest + // Exported so the web layer renders a skip's reason without retyping the + // wire string (issue #145); the values are storage and page-stable. + SkipPaused = "paused" // the pause row was read at the top + SkipRefusing = "refusing" // refusal backoff + SkipSidecarDown = "sidecar-down" // a sibling browser Lane lost Chrome + SkipNoFetcher = "no-fetcher" // browser Site, no browser configured, no fallback + SkipDueQuery = "due-query" // the due query failed + SkipAsleep = "asleep" // under both browser wake thresholds + SkipEligibleCount = "eligible-count" // the eligible count failed + SkipNothingEligible = "nothing-eligible" // nothing eligible; sleeps a full rest ) // readOutcome classifies one Series read for the pass row's outcome counts @@ -296,14 +294,12 @@ const lanePassRetention = 14 * 24 * time.Hour // production Lane's rate limit; the deterministic test entry runs back to back. func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration { now := p.Now() - // Snapshot this pass for the owner's page (issue #102). Recorded on every - // return path, with the figures filled in where the pass computes them. - st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)} - defer func() { p.recordLaneState(st) }() - // Durable pass log (issue #141): one row per exit, written from the same - // snapshot so the two recordings cannot disagree. + // Durable pass log (issue #141): one row per exit. The figures are filled + // in as the pass measures them; a pass that returns before measuring + // carries the previous pass's forward inside recordPass. + fig := passFigures{} rec := passRecord{site: name, ranAt: now.UnixMilli()} - defer func() { p.recordPass(rec, st) }() + defer func() { p.recordPass(rec, fig) }() // One Lane row read at the top of a pass, serving two gates (issue #139). // Both stamps outlive our process, so the gates read the durable row @@ -318,24 +314,24 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. if pausedUntil > now.UnixMilli() { // Paused ahead of the refusal check: no Series is touched, so the // queue stays intact for when the pause lifts (issue #141, #147). - rec.skip = skipPaused + rec.skip = SkipPaused log.Printf("latest poll %s: paused until %s, skipping pass", name, time.UnixMilli(pausedUntil).Format(time.RFC3339)) return time.Duration(pausedUntil-now.UnixMilli()) * time.Millisecond } if refuseUntil > now.UnixMilli() { // Cooling down after a refusal: do not attempt this Site at all. - rec.skip = skipRefusing + rec.skip = SkipRefusing return time.Duration(refuseUntil-now.UnixMilli()) * time.Millisecond } if isBrowserSite(name) { - if downFor, down := p.browserDownFor(now); down && downFor < refuseBackoff { + if downFor, down := p.browserDownFor(now); down && downFor < RefuseBackoff { // A sibling browser Lane lost the sidecar within the backoff // window: skip this pass, so a restarting Chrome does not stamp - // this Site's Series one pass at a time. After refuseBackoff the + // this Site's Series one pass at a time. After RefuseBackoff the // flag decays and the Lane probes again (issue #100, story 20). - rec.skip = skipSidecarDown + rec.skip = SkipSidecarDown log.Printf("latest poll %s: browser lane skipping pass (sidecar down %s ago)", name, downFor) - return refuseBackoff - downFor + return RefuseBackoff - downFor } } s := sites[name] @@ -344,27 +340,27 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. // No fetcher at all right now (browser absent, no fallback): every // Series stays unstamped and due, so a browser that appears after a // restart finds its full queue waiting (issue #100). - rec.skip = skipNoFetcher - st.Gap = defaultGap + rec.skip = SkipNoFetcher + fig.Gap = defaultGap return defaultGap } due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(), now.Add(-sightingCeilingRests*s.Rest).UnixMilli()) if err != nil { - rec.skip = skipDueQuery + rec.skip = SkipDueQuery log.Printf("latest poll %s: due query: %v", name, err) - st.Gap = defaultGap + fig.Gap = defaultGap return defaultGap } - st.Due = len(due) + fig.Due = len(due) if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) { // 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. - rec.skip = skipAsleep - st.Gap, st.Asleep = defaultGap, true + rec.skip = SkipAsleep + fig.Gap = defaultGap return defaultGap } if s.Browser != nil { @@ -379,20 +375,20 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. eligible, err := p.countEligible(name) if err != nil { - rec.skip = skipEligibleCount + rec.skip = SkipEligibleCount log.Printf("latest poll %s: eligible count: %v", name, err) - st.Gap = defaultGap + fig.Gap = defaultGap return defaultGap } gap, clamped := effectiveGap(s, eligible) - st.Gap, st.Clamped = gap, clamped + fig.Gap, fig.Clamped = gap, clamped if clamped { log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible) } if eligible == 0 { // Nothing to poll for the foreseeable future; sleep a full rest instead // of re-querying every gap. - rec.skip = skipNothingEligible + rec.skip = SkipNothingEligible return s.Rest } @@ -403,7 +399,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. } if refusals >= 2 { // This Site refused twice in a row: the remaining Series are left - // unstamped and due, and the Lane waits refuseBackoff before + // unstamped and due, and the Lane waits RefuseBackoff before // trying it again. break } @@ -426,7 +422,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. // make it legible but is deliberately not invented here. rec.counts.add(outcome) p.setBrowserDown(now) - log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, refuseBackoff) + log.Printf("latest poll %s: browser unreachable, browser lanes skipping passes for %s", name, RefuseBackoff) return gap } if outcome == outcomeRefused { @@ -435,27 +431,39 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. refusals = 0 } rec.counts.add(outcome) - st.Checked++ + fig.Checked++ } - if st.Checked > 0 { - log.Printf("latest poll %s: due=%d checked=%d", name, len(due), st.Checked) + if fig.Checked > 0 { + log.Printf("latest poll %s: due=%d checked=%d", name, len(due), fig.Checked) } if refusals >= 2 { - p.setRefusalBackoff(name, now.Add(refuseBackoff)) + p.setRefusalBackoff(name, now.Add(RefuseBackoff)) // The refusal outlives the process: the durable stamp gates a restart, // so a Site that just told us to back off is not re-probed. The // in-memory twin is still written because the Lane status block reads // it directly; the gate reads the durable stamp, so a restart does not // forget the refusal. - if err := p.Store.SetLaneRefusal(name, now.Add(refuseBackoff).UnixMilli()); err != nil { + if err := p.Store.SetLaneRefusal(name, now.Add(RefuseBackoff).UnixMilli()); err != nil { log.Printf("latest poll %s: persist refusal: %v", name, err) } - log.Printf("latest poll %s: refused twice this run, waiting %s", name, refuseBackoff) - return refuseBackoff + log.Printf("latest poll %s: refused twice this run, waiting %s", name, RefuseBackoff) + return RefuseBackoff } return gap } +// passFigures are the numbers one pass measured for its durable row (issue +// #141): due and checked as the pass saw them, the pace it chose, and whether +// the gap sat on the floor. A pass that returned before measuring keeps the +// previous pass's figures via carry-forward in recordPass; the in-memory +// snapshot those once mirrored into is gone — the page reads the durable row +// now (issue #145). +type passFigures struct { + Due, Checked int + Gap time.Duration + Clamped bool +} + // recordPass writes the durable row for one pass (issue #141). Called deferred // from runLanePass so every return path records exactly one row. A pass that // never computed its own figures — its gap is zero — carries the previous @@ -463,15 +471,15 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time. // did not measure; the skip column says why it declined, so the zeroes that // remain (due-query, no-fetcher) read as explanations rather than // measurements. -func (p *Poller) recordPass(rec passRecord, st LaneState) { +func (p *Poller) recordPass(rec passRecord, fig passFigures) { row := store.LanePass{ Site: rec.site, RanAt: rec.ranAt, Skip: rec.skip, - Due: st.Due, - Checked: st.Checked, - GapMS: st.Gap.Milliseconds(), - Clamped: st.Clamped, + Due: fig.Due, + Checked: fig.Checked, + GapMS: fig.Gap.Milliseconds(), + Clamped: fig.Clamped, Refused: rec.counts.refused, Unreachable: rec.counts.unreachable, NoChapter: rec.counts.noChapter, @@ -522,7 +530,7 @@ func (p *Poller) setBrowserDown(now time.Time) { // browserDownFor reports how long the sidecar has been down and that it is // down at all — the zero time means never down, which must not read as a -// zero-duration loss. The window decays: once refuseBackoff passes without a +// zero-duration loss. The window decays: once RefuseBackoff passes without a // fresh loss, Lanes probe again. func (p *Poller) browserDownFor(now time.Time) (time.Duration, bool) { p.mu.Lock() diff --git a/backend/internal/latest/poller_test.go b/backend/internal/latest/poller_test.go index 0ae089e..4a83373 100644 --- a/backend/internal/latest/poller_test.go +++ b/backend/internal/latest/poller_test.go @@ -1308,7 +1308,7 @@ func TestRunOnceOrdersBySharednessThenAge(t *testing.T) { } // Two refusals in one pass stop the Lane: the remaining Series stay unstamped -// and due, and the Lane backs off for refuseBackoff before trying the Site +// and due, and the Lane backs off for RefuseBackoff before trying the Site // again. One hostile Site burns only its own Lane's budget (issue #100). func TestRunOnceSiteRefusalSkipsRestOfLaneAndBacksOff(t *testing.T) { s, _ := newTestStore(t) @@ -1391,11 +1391,9 @@ func TestBrowserLaneWakeThresholds(t *testing.T) { if got := browser.callCount(); got != 0 { t.Fatalf("browser fetches with 3 freshly-due series = %d, want 0 (Chrome stays asleep)", got) } - // The owner's page reads this state off the snapshot, and Due-without- - // Checked has to be distinguishable there from a Lane that has stopped. - if lane := laneByName(t, p, "kagane"); !lane.Asleep || lane.Due != 3 || lane.Checked != 0 { - t.Fatalf("asleep kagane lane = %+v, want Asleep with 3 due and 0 checked", lane) - } + // The skipped pass still records its row, carrying the due count it never + // read; the Lanes page (issue #145) reads that row — see + // TestRunLanePassRecordsEveryExit/"asleep". // 5 due crosses the count threshold. for i := 3; i < 5; i++ { seed(i) @@ -1404,9 +1402,6 @@ func TestBrowserLaneWakeThresholds(t *testing.T) { if got := browser.callCount(); got != 5 { t.Fatalf("browser fetches with 5 due series = %d, want 5", got) } - if lane := laneByName(t, p, "kagane"); lane.Asleep { - t.Fatalf("woken kagane lane still reports Asleep: %+v", lane) - } // A single long-neglected series wakes the browser by age alone. seedForCheck(t, s, "kagane:ancient", "https://kagane.to/series/ancient", 0) p.runOnce(context.Background()) @@ -1415,19 +1410,6 @@ func TestBrowserLaneWakeThresholds(t *testing.T) { } } -// laneByName pulls one Lane out of the poller's snapshot, failing rather than -// returning a zero LaneState a caller would assert against by accident. -func laneByName(t *testing.T, p *Poller, site string) LaneState { - t.Helper() - for _, lane := range p.LaneStatus().Lanes { - if lane.Site == site { - return lane - } - } - t.Fatalf("no %q lane in the snapshot", site) - return LaneState{} -} - // When one browser Lane loses the sidecar, the round's remaining browser // Lanes are skipped: every fetch would fail anyway, and their Series must not // burn their stamps on a dead Chrome (issue #100). @@ -1459,7 +1441,7 @@ func TestRunOnceUnreachableBrowserStopsBrowserLanes(t *testing.T) { } } - // The shared flag decays after refuseBackoff: the next round probes + // The shared flag decays after RefuseBackoff: the next round probes // again. comix's Series is resting (stamped last round), so the probe // falls to kagane — the only Lane with something due — and its fresh // loss re-gates the Lanes behind it. @@ -1605,19 +1587,21 @@ func TestRunOnceClampWarningNamesTheSite(t *testing.T) { } } -// The owner's admin page (issue #102) reads Lane state out of the poller. -// Before any pass the snapshot is empty — a restart must render "no data -// yet", not zeroes — and each pass records what it saw: the frozen clock, -// the due count and the pace, with refusal backoff derived at snapshot time. -func TestLaneStatus(t *testing.T) { +// The Lanes page (issue #145) reads the durable pass log, so the poller's +// only duty to it is that every return path writes its row; the snapshot it +// used to mirror into memory is gone. A refusing pass still records why it +// declined, and the latest row per Site is what the page renders — nothing +// else is left to assert against here, because the page's seam moved into the +// web layer (web_test.go, seeded-row tests). +func TestPassLogIsTheLanesPagesOnlyWindow(t *testing.T) { s, _ := newTestStore(t) now := time.UnixMilli(5_000_000) p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now) - if st := p.LaneStatus(); len(st.Lanes) != 0 { - t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes)) - } else if st.BrowserConfigured || st.BrowserReachable { - t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable) + // No pass has run: the log is empty, which the page renders as "none + // observed" rather than confident zeroes. + if _, ok, err := s.LatestLanePass("asura"); err != nil || ok { + t.Fatalf("LatestLanePass before any pass = ok %v err %v, want no row", ok, err) } seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0) @@ -1630,66 +1614,33 @@ func TestLaneStatus(t *testing.T) { } p.runOnce(context.Background()) - st := p.LaneStatus() - var asura, kagane LaneState - for _, lane := range st.Lanes { - switch lane.Site { - case "asura": - asura = lane - case "kagane": - kagane = lane - } + // Both lanes wrote their rows: asura the pass it read; kagane the + // mid-loop double-refusal exit, which records an empty skip by design + // with its two refused counts — the durable row is the whole record, and + // the page reads it rather than a snapshot. + asura := latestPassFor(t, s, "asura") + if asura.Due != 1 || asura.Checked != 1 || asura.GapMS == 0 { + t.Fatalf("asura row = %+v, want due 1 checked 1 with the Lane's pace", asura) } - if st.Lanes[0].Site != "asura" { - t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site) - } - if asura.Site == "" { - t.Fatalf("asura missing from snapshot: %+v", st.Lanes) - } - if !asura.LastRun.Equal(now) { - t.Fatalf("asura LastRun = %s, want the frozen clock %s", asura.LastRun, now) - } - if asura.Due != 1 { - t.Fatalf("asura Due = %d, want 1", asura.Due) - } - if asura.Gap == 0 { - t.Fatal("asura Gap = 0, want the Lane's pace") - } - if asura.Browser || asura.Refusing { - t.Fatalf("asura = %+v, want a TLS Lane that is not refusing", asura) - } - if !kagane.Browser || !kagane.Refusing { - t.Fatalf("kagane = %+v, want a browser Lane in refusal backoff", kagane) - } - if !st.BrowserConfigured || !st.BrowserReachable { - t.Fatalf("browser after round = configured=%v reachable=%v, want true/true (sidecar never lost)", st.BrowserConfigured, st.BrowserReachable) - } - if asura.Checked != 1 { - t.Fatalf("asura Checked = %d, want the one Series it read", asura.Checked) + kagane := latestPassFor(t, s, "kagane") + if kagane.Skip != "" || kagane.Refused != 2 || kagane.Due != 2 || kagane.GapMS == 0 { + t.Fatalf("kagane row = %+v, want an empty-skip double-refusal pass with 2 refused", kagane) } - // A pass that declines to look (kagane is now in backoff) must not restate - // the figures it never gathered as zeroes: the last real pass's due count - // and pace stand until a pass replaces them. + // With the refusal now durable, the next pass declines ahead of the loop: + // it records the refusing skip and carries the previous pass's figures + // forward rather than restating zeroes it never gathered (the carry logic + // itself is driven in TestRunLanePassCarryForwardOnlyWhenGapZero). before := kagane - if before.Due == 0 || before.Gap == 0 { - t.Fatalf("kagane after its refusing pass = %+v, want the figures that pass gathered", before) - } + p.Now = func() time.Time { return now.Add(time.Minute) } p.runOnce(context.Background()) - for _, lane := range p.LaneStatus().Lanes { - if lane.Site != "kagane" { - continue - } - if lane.Due != before.Due || lane.Gap != before.Gap { - t.Fatalf("kagane after a skipped pass = due %d gap %s, want the previous pass's %d / %s", - lane.Due, lane.Gap, before.Due, before.Gap) - } + again := latestPassFor(t, s, "kagane") + if again.Skip != SkipRefusing { + t.Fatalf("kagane refusing pass skip = %q, want %q", again.Skip, SkipRefusing) } - - // A lost sidecar reads as unreachable for the same window the Lanes skip. - p.setBrowserDown(now) - if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable { - t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable) + if again.Due != before.Due || again.GapMS != before.GapMS || again.Checked != before.Checked { + t.Fatalf("kagane after a skipped pass = due %d gap %d, want the previous pass's %d / %d", + again.Due, again.GapMS, before.Due, before.GapMS) } } @@ -1744,8 +1695,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) { if got := readLatestCheckedAt(t, s, "asura:x"); got != 0 { t.Fatalf("stamp while paused = %d, want 0 (Series stay due and unstamped)", got) } - if pass := latestPassFor(t, s, "asura"); pass.Skip != skipPaused { - t.Fatalf("skip = %q, want %q", pass.Skip, skipPaused) + if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipPaused { + t.Fatalf("skip = %q, want %q", pass.Skip, SkipPaused) } if got := countPassRows(t, dbURL, "asura"); got != 1 { t.Fatalf("pass rows = %d, want exactly 1", got) @@ -1767,8 +1718,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) { if browser.callCount() != 0 { t.Fatalf("fetches while refusing = %d, want 0", browser.callCount()) } - if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipRefusing { - t.Fatalf("skip = %q, want %q", pass.Skip, skipRefusing) + if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipRefusing { + t.Fatalf("skip = %q, want %q", pass.Skip, SkipRefusing) } if got := countPassRows(t, dbURL, "kagane"); got != 1 { t.Fatalf("pass rows = %d, want exactly 1", got) @@ -1782,14 +1733,14 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) { p := newTestPoller(t, s, &fakeFetcher{status: 200}, now) p.BrowserFetch = browser p.setBrowserDown(now) // a sibling Lane lost Chrome within the backoff window - if pace := p.runLanePass(context.Background(), "kagane", false); pace != refuseBackoff { - t.Fatalf("sidecar-down pace = %s, want %s", pace, refuseBackoff) + if pace := p.runLanePass(context.Background(), "kagane", false); pace != RefuseBackoff { + t.Fatalf("sidecar-down pace = %s, want %s", pace, RefuseBackoff) } if browser.callCount() != 0 { t.Fatalf("fetches with the sidecar down = %d, want 0", browser.callCount()) } - if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipSidecarDown { - t.Fatalf("skip = %q, want %q", pass.Skip, skipSidecarDown) + if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipSidecarDown { + t.Fatalf("skip = %q, want %q", pass.Skip, SkipSidecarDown) } if got := countPassRows(t, dbURL, "kagane"); got != 1 { t.Fatalf("pass rows = %d, want exactly 1", got) @@ -1804,8 +1755,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) { t.Fatalf("no-fetcher pace = %s, want %s", pace, defaultGap) } pass := latestPassFor(t, s, "comix") - if pass.Skip != skipNoFetcher || pass.GapMS != defaultGap.Milliseconds() { - t.Fatalf("no-fetcher pass = %+v, want skip %q with its own gap %s", pass, skipNoFetcher, defaultGap) + if pass.Skip != SkipNoFetcher || pass.GapMS != defaultGap.Milliseconds() { + t.Fatalf("no-fetcher pass = %+v, want skip %q with its own gap %s", pass, SkipNoFetcher, defaultGap) } if got := countPassRows(t, dbURL, "comix"); got != 1 { t.Fatalf("pass rows = %d, want exactly 1", got) @@ -1829,8 +1780,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) { if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap { t.Fatalf("due-query pace = %s, want %s", pace, defaultGap) } - if pass := latestPassFor(t, s, "asura"); pass.Skip != skipDueQuery { - t.Fatalf("skip = %q, want %q", pass.Skip, skipDueQuery) + if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipDueQuery { + t.Fatalf("skip = %q, want %q", pass.Skip, SkipDueQuery) } if got := countPassRows(t, dbURL, "asura"); got != 1 { t.Fatalf("pass rows = %d, want exactly 1", got) @@ -1853,8 +1804,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) { t.Fatalf("fetches while Chrome is asleep = %d, want 0", browser.callCount()) } pass := latestPassFor(t, s, "kagane") - if pass.Skip != skipAsleep || pass.Due != 3 || pass.GapMS != defaultGap.Milliseconds() { - t.Fatalf("asleep pass = %+v, want skip %q with 3 due and the default gap", pass, skipAsleep) + if pass.Skip != SkipAsleep || pass.Due != 3 || pass.GapMS != defaultGap.Milliseconds() { + t.Fatalf("asleep pass = %+v, want skip %q with 3 due and the default gap", pass, SkipAsleep) } if got := countPassRows(t, dbURL, "kagane"); got != 1 { t.Fatalf("pass rows = %d, want exactly 1", got) @@ -1871,8 +1822,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) { if pace := p.runLanePass(context.Background(), "asura", false); pace != defaultGap { t.Fatalf("eligible-count pace = %s, want %s", pace, defaultGap) } - if pass := latestPassFor(t, s, "asura"); pass.Skip != skipEligibleCount { - t.Fatalf("skip = %q, want %q", pass.Skip, skipEligibleCount) + if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipEligibleCount { + t.Fatalf("skip = %q, want %q", pass.Skip, SkipEligibleCount) } if got := countPassRows(t, dbURL, "asura"); got != 1 { t.Fatalf("pass rows = %d, want exactly 1", got) @@ -1885,8 +1836,8 @@ func TestRunLanePassRecordsEveryExit(t *testing.T) { if pace := p.runLanePass(context.Background(), "asura", false); pace != sites["asura"].Rest { t.Fatalf("nothing-eligible pace = %s, want a full rest %s", pace, sites["asura"].Rest) } - if pass := latestPassFor(t, s, "asura"); pass.Skip != skipNothingEligible { - t.Fatalf("skip = %q, want %q", pass.Skip, skipNothingEligible) + if pass := latestPassFor(t, s, "asura"); pass.Skip != SkipNothingEligible { + t.Fatalf("skip = %q, want %q", pass.Skip, SkipNothingEligible) } if got := countPassRows(t, dbURL, "asura"); got != 1 { t.Fatalf("pass rows = %d, want exactly 1", got) @@ -1959,8 +1910,8 @@ func TestRunLanePassCarryForwardOnlyWhenGapZero(t *testing.T) { p.Now = func() time.Time { return now.Add(time.Minute) } p.runLanePass(context.Background(), "kagane", false) second := latestPassFor(t, s, "kagane") - if second.Skip != skipRefusing { - t.Fatalf("second pass skip = %q, want %q", second.Skip, skipRefusing) + if second.Skip != SkipRefusing { + t.Fatalf("second pass skip = %q, want %q", second.Skip, SkipRefusing) } if second.Due != first.Due || second.Checked != first.Checked || second.GapMS != first.GapMS || second.Clamped != first.Clamped { @@ -1986,8 +1937,8 @@ func TestRunLanePassCarryForwardOnlyWhenGapZero(t *testing.T) { p.Now = func() time.Time { return now.Add(time.Minute) } p.runLanePass(context.Background(), "comix", false) second := latestPassFor(t, s, "comix") - if second.Skip != skipNoFetcher { - t.Fatalf("second pass skip = %q, want %q", second.Skip, skipNoFetcher) + if second.Skip != SkipNoFetcher { + t.Fatalf("second pass skip = %q, want %q", second.Skip, SkipNoFetcher) } if second.GapMS != defaultGap.Milliseconds() { t.Fatalf("second pass gap = %d, want its own %d (not carried)", second.GapMS, defaultGap.Milliseconds()) @@ -2089,8 +2040,8 @@ func TestDurableRefusalSurvivesFreshPoller(t *testing.T) { if got := fresh.BrowserFetch.(*fakeFetcher).callCount(); got != 0 { t.Fatalf("fetches by a fresh poller inside the backoff = %d, want 0", got) } - if pass := latestPassFor(t, s, "kagane"); pass.Skip != skipRefusing { - t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, skipRefusing) + if pass := latestPassFor(t, s, "kagane"); pass.Skip != SkipRefusing { + t.Fatalf("fresh poller's pass skip = %q, want %q", pass.Skip, SkipRefusing) } // Past the backoff the fresh poller probes again — the two Series the diff --git a/backend/internal/latest/sites.go b/backend/internal/latest/sites.go index 64580b4..16e04e2 100644 --- a/backend/internal/latest/sites.go +++ b/backend/internal/latest/sites.go @@ -401,9 +401,10 @@ const ( // express (docs/research/cloudflare-bot-scoring-and-poll-cadence.md); // below it the Lane is outrunning its own plan and says so loudly. minGap = time.Second - // refuseBackoff is how long a Lane waits after its Site refused twice in - // one run before attempting it again. - refuseBackoff = 15 * time.Minute + // RefuseBackoff is how long a Lane waits after its Site refused twice in + // one run before attempting it again. Exported so the web layer can derive + // browser reachability from the pass log over the same window (issue #145). + RefuseBackoff = 15 * time.Minute // browserWakeCount and browserWakeAge gate a browser Lane's run: five or // more due Series, or any one of them waiting this long, or Chrome stays // asleep (ADR-0005 on-demand browser). diff --git a/backend/internal/latest/status.go b/backend/internal/latest/status.go deleted file mode 100644 index 49328a8..0000000 --- a/backend/internal/latest/status.go +++ /dev/null @@ -1,79 +0,0 @@ -package latest - -import "time" - -// LaneState is the administrative page's view of one Poll Lane (issue #102): -// what the Lane's last pass saw. Due, Gap and Checked are filled in as the -// pass computes them; a pass that returned before reaching a figure (refusal -// backoff, sidecar down) carries the previous pass's figures forward rather -// than overwriting them with zeroes the page would state as fact. -type LaneState struct { - Site string - Due int - LastRun time.Time - Gap time.Duration - // Checked is how many Series this pass actually read. A Lane with Series - // due and nothing checked has stopped working; one with nothing due is - // merely quiet, and the page must not draw the two the same (story 13). - Checked int - Clamped bool - Refusing bool - Browser bool - // Asleep marks a browser Lane whose last pass declined to wake Chrome - // because it was under both wake thresholds (ADR-0005). Due without - // Checked then means "waiting for the group to gather", not "stopped", and - // the page must not draw it as a stall. - Asleep bool -} - -// Status is the owner's page snapshot of the whole poller (issue #102). -type Status struct { - Lanes []LaneState - BrowserConfigured bool - BrowserReachable bool -} - -// LaneStatus returns a copy of the poller's Lane state for the owner's page. -// Only Sites that have completed a pass appear — a restart therefore renders -// "no data yet" instead of confident zeroes — in the same order Run iterates. -// Refusing is derived at snapshot time from the refusal backoff, not stored, -// so a Lane that cooled down between passes reports false without a new pass. -// BrowserReachable mirrors the Lanes' own gate: the sidecar is down only -// within the refuseBackoff window since its last loss. -func (p *Poller) LaneStatus() Status { - p.mu.Lock() - defer p.mu.Unlock() - lanes := make([]LaneState, 0, len(p.laneStates)) - now := p.Now() - for _, name := range laneNames() { - st, ok := p.laneStates[name] - if !ok { - continue - } - st.Refusing = now.Before(p.refuseUntil[name]) - lanes = append(lanes, st) - } - configured := p.BrowserFetch != nil - reachable := configured - if reachable && !p.browserDownAt.IsZero() && now.Sub(p.browserDownAt) < refuseBackoff { - reachable = false - } - return Status{Lanes: lanes, BrowserConfigured: configured, BrowserReachable: reachable} -} - -// recordLaneState stores one Lane's last pass for LaneStatus. Called deferred -// from runLanePass so every return path records, even a pass that refused. -// A pass that never reached the pace (Gap zero) keeps the last pass's figures: -// the Lane's due count and gap did not become zero because this pass declined -// to look, and the row's own marks say why it declined. -func (p *Poller) recordLaneState(st LaneState) { - p.mu.Lock() - defer p.mu.Unlock() - if p.laneStates == nil { - p.laneStates = make(map[string]LaneState) - } - if prev, ok := p.laneStates[st.Site]; ok && st.Gap == 0 { - st.Due, st.Gap, st.Clamped, st.Checked = prev.Due, prev.Gap, prev.Clamped, prev.Checked - } - p.laneStates[st.Site] = st -} diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index 3afcaa1..81f07dc 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -6,18 +6,9 @@ import ( "strconv" "time" - "bookmarkmanager/backend/internal/latest" "bookmarkmanager/backend/internal/store" ) -// LaneReporter is the administrative page's whole window onto the running -// poller: one snapshot of Poll Lane state, copied out of memory on request. -// The Poller satisfies it in production and a fake with fixed values satisfies -// it in tests, so the page's tests need neither a poller nor a Site. -type LaneReporter interface { - LaneStatus() latest.Status -} - // adminView is the shared shell data for an administrative page and the roster // fragment returned after a Reader action. type adminView struct { @@ -29,50 +20,6 @@ type adminView struct { Lanes lanesView } -// lanesView is the Lane status block: one row per Site that has run, plus the -// browser fact, which is shared by the three browser Sites rather than held -// once per Site. -type lanesView struct { - Rows []laneRow - // PollerOff means no poller is running at all (disabled by config, or its - // client could not be built). The browser line must not answer "not - // configured" then: the sidecar is not the reason nothing is polled. - PollerOff bool - BrowserConfigured bool - BrowserReachable bool -} - -// laneRow is one Lane formatted for reading rather than for arithmetic: the -// template renders strings and flags, and every judgement about what they mean -// is made here. -type laneRow struct { - Site string - Due int - Ran string - // Checked is how many Series the last pass read. Due without Checked is a - // Lane that has stopped working; the two figures side by side are what - // separate that from a Lane with nothing to do. - Checked int - // Gap is empty when no pass has reached the pace yet, so the row omits the - // figure instead of stating a zero. - Gap string - Clamped bool - Refusing bool - // BrowserLost marks a Lane whose pages can only be read through the - // sidecar while the sidecar is unreachable — including the case where none - // is configured, which stops those Series just as completely. - BrowserLost bool - // Stalled marks a Lane with Series waiting that its last pass did not read - // — the difference between a stopped Lane and a quiet one (story 13). A - // browser Lane holding Chrome asleep under the wake thresholds is neither, - // so it carries Asleep instead and never Stalled. - Stalled bool - Asleep bool - // Attention is the one flag the template colours on, so an unhealthy Lane - // is found at a glance rather than read for. - Attention bool -} - // adminRoute pairs a route pattern with its handler so the route list and the // gate cannot drift apart. type adminRoute struct { @@ -126,11 +73,6 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) { h.renderAdmin(w, adminView{Page: "overview"}) } -// adminLanes renders the page that hosts the live Lane fragment. -func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) { - h.renderAdmin(w, adminView{Page: "lanes", Lanes: h.lanesView()}) -} - // adminReaders renders the Reader roster on its own bookmarkable page. func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) { readers, err := h.store.Readers() @@ -152,65 +94,6 @@ func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) { h.render(w, http.StatusOK, "admin", view) } -// uiLanes answers the status block's own refresh. Only the block refreshes on a -// timer; the roster re-renders after an action, as it always has. -func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) { - h.render(w, http.StatusOK, "lanes", h.lanesView()) -} - -// lanesView copies the poller's snapshot into display form. A nil reporter (no -// poller running) and a poller no Lane has reported to yet are the same thing -// to the page: no data, which it must say rather than draw as confident zeroes -// — an empty page a few seconds after a restart must not read as a stopped one. -func (h *Handler) lanesView() lanesView { - if h.lanes == nil { - return lanesView{PollerOff: true} - } - snap := h.lanes.LaneStatus() - v := lanesView{ - Rows: make([]laneRow, 0, len(snap.Lanes)), - BrowserConfigured: snap.BrowserConfigured, - BrowserReachable: snap.BrowserReachable, - } - now := time.Now() - for _, l := range snap.Lanes { - lost := l.Browser && !snap.BrowserReachable - // Series waiting and none read is the shape of a Lane that has stopped - // working, as distinct from one that is quiet for want of work — or one - // deliberately leaving Chrome asleep until its group gathers. - stalled := l.Due > 0 && l.Checked == 0 && !l.Asleep - gap := "" - if l.Gap > 0 { - gap = l.Gap.Truncate(time.Second).String() - } - v.Rows = append(v.Rows, laneRow{ - Site: l.Site, - Due: l.Due, - Ran: since(now, l.LastRun), - Checked: l.Checked, - Gap: gap, - Clamped: l.Clamped, - Refusing: l.Refusing, - BrowserLost: lost, - Stalled: stalled, - Asleep: l.Asleep, - Attention: l.Clamped || l.Refusing || lost || stalled, - }) - } - return v -} - -// since formats how long ago a Lane last ran, at second resolution: the block -// refreshes every thirty seconds, so anything finer is noise the owner would -// have to ignore. -func since(now, then time.Time) string { - d := now.Sub(then).Truncate(time.Second) - if d < time.Second { - return "just now" - } - return d.String() + " ago" -} - // revokeReaderSessions logs one Reader out of every browser they are signed in // on. The owner gate is the route's, not this handler's. func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/web/admin_lanes.go b/backend/internal/web/admin_lanes.go new file mode 100644 index 0000000..36b940d --- /dev/null +++ b/backend/internal/web/admin_lanes.go @@ -0,0 +1,239 @@ +package web + +import ( + "fmt" + "log" + "net/http" + "time" + + "bookmarkmanager/backend/internal/latest" + "bookmarkmanager/backend/internal/store" +) + +// lanesView is the Lane status block: one row per Site's latest durable pass, +// plus the browser fact derived from that same log. No poller is consulted — +// the page answers from the database, so it is complete thirty seconds after +// a deploy (issue #145). +type lanesView struct { + Rows []laneRow + // PollerOff means latest-chapter polling is switched off in this + // deployment (LATEST_CHAPTER_POLL_ENABLED). It is a config fact, not a + // poller answering "absent": the browser line must not blame the sidecar + // when nothing polls. + PollerOff bool + BrowserConfigured bool + BrowserReachable bool +} + +// laneRow is one Lane formatted for reading rather than for arithmetic: the +// template renders strings and flags, and every judgement about what they +// mean is made here. +type laneRow struct { + Site string + Due int + Checked int + // Gap is the last pass's pace, or "—" when no pass has reached one yet — + // a refused Lane still reports the pace its last real pass chose, so a + // zero here would be a figure the row never measured. + Gap string + Ran string + // Chips are the named outcome counts over the owner's window, in the + // taxonomy's fixed order. Empty writes "none observed". + Chips []chip + HasChips bool + // StatePhrase is the reason this Lane declined to work: a skipped pass's + // own sentence, or the one true stall. Empty means the pass reached its + // loop and read normally. StateGood marks a healthy way to do nothing + // (paused, browser asleep, nothing eligible) rather than a fault. + StatePhrase string + StateGood bool + // Attention is the one flag the template colours on, so a Lane that + // needs the owner is found at a glance rather than read for. + Attention bool +} + +// chip is one named outcome count over the owner's window. +type chip struct { + Name string + Count int +} + +// adminLanes renders the page that hosts the live Lane fragment. +func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) { + h.renderAdmin(w, adminView{Page: "lanes", Lanes: h.lanesView()}) +} + +// uiLanes answers the status block's own refresh. Only the block refreshes on +// a timer; the roster re-renders after an action, as it always has. +func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) { + h.render(w, http.StatusOK, "lanes", h.lanesView()) +} + +// lanesView builds the Lane status block from the durable pass log. Both +// reads are the store's latest-per-Site projection, so the page's seam is a +// seeded row rather than a fake poller; errors degrade to the empty state and +// are logged, never shown to the owner in detail. +func (h *Handler) lanesView() lanesView { + v := lanesView{ + PollerOff: !h.pollerEnabled, + BrowserConfigured: h.browserConfigured, + } + passes, err := h.store.LatestLanePasses() + if err != nil { + log.Printf("admin lanes: latest passes: %v", err) + return v + } + now := time.Now() + outcomes, err := h.store.LanePassOutcomes(time.Now().Add(-ownerWindow).UnixMilli()) + if err != nil { + log.Printf("admin lanes: outcomes: %v", err) + return lanesView{ + PollerOff: v.PollerOff, + BrowserConfigured: v.BrowserConfigured, + } + } + bySite := make(map[string]store.SiteOutcomes, len(outcomes)) + for _, o := range outcomes { + bySite[o.Site] = o + } + v.Rows = make([]laneRow, 0, len(passes)) + v.BrowserReachable = browserReachable(passes, now) + for _, p := range passes { + v.Rows = append(v.Rows, buildLaneRow(p, bySite[p.Site], now)) + } + return v +} + +// browserReachable derives the sidecar's reachability from the pass log: a +// browser Site is down when its latest pass inside the refusal backoff is a +// sidecar loss, a missing fetcher, or an interrupted read. Only browser Sites +// ever produce those signals, so no Site registry leaks into the web layer. +// A configured browser with no such evidence reads as reachable; an unset +// BROWSER_WS_URL degrades identically to a browser that is down. +func browserReachable(passes []store.LanePass, now time.Time) bool { + backoff := latest.RefuseBackoff + for _, p := range passes { + ran := time.UnixMilli(p.RanAt) + if now.Sub(ran) >= backoff || ran.After(now) { + continue + } + if p.Skip == latest.SkipSidecarDown || p.Skip == latest.SkipNoFetcher || p.Unreachable > 0 { + return false + } + } + return true +} + +// buildLaneRow turns one Site's latest pass and window outcome sums into the +// row the template prints. The skip column is the authority on why a pass did +// nothing; the outcomes render named and unlinked, because the pass row holds +// counts and never identities. +func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow { + row := laneRow{ + Site: p.Site, + Due: p.Due, + Checked: p.Checked, + Gap: "—", + Ran: since(now, time.UnixMilli(p.RanAt)), + } + if p.GapMS > 0 { + row.Gap = (time.Duration(p.GapMS) * time.Millisecond).Truncate(time.Second).String() + } + row.Chips = outcomeChips(o) + row.HasChips = len(row.Chips) > 0 + row.StatePhrase, row.StateGood, row.Attention = laneState(p, now) + return row +} + +// outcomeChips lists a Site's nonzero window sums in the taxonomy's fixed +// order, so the chips never reorder as the window changes. None observed is +// written by the template, not drawn as a confident zero count. +func outcomeChips(o store.SiteOutcomes) []chip { + fixed := []struct { + name string + count int + }{ + {"refused", o.Refused}, + {"unreachable", o.Unreachable}, + {"no chapter", o.NoChapter}, + {"unfetchable", o.Unfetchable}, + {"errors", o.Errors}, + } + var out []chip + for _, f := range fixed { + if f.count > 0 { + out = append(out, chip{Name: f.name, Count: f.count}) + } + } + return out +} + +// laneState renders the reason a Lane's last pass did nothing, in one sentence +// per skip value with the one true stall kept apart from every Lane that +// declined and said why. Good states — a pause, a sleeping browser, nothing +// eligible — carry no Attention: the mark must stay spendable on the faults +// that actually need the owner. +func laneState(p store.LanePass, now time.Time) (phrase string, good, attention bool) { + switch p.Skip { + case latest.SkipPaused: + phrase = "paused · resumes in " + humanDuration(time.UnixMilli(p.PausedUntil).Sub(now)) + good = true + case latest.SkipRefusing: + phrase = "refusing" + if until := time.UnixMilli(p.RefuseUntil); until.After(now) { + phrase += " · backs off until " + until.Format("15:04") + } + attention = true + case latest.SkipSidecarDown, latest.SkipNoFetcher: + // Known false positive shipped per spec: a sibling Lane's Chrome loss + // stamps this Site too, and the enum deliberately has no tenth value + // to separate it (issue #141). Render it as written. + phrase = "no browser" + attention = true + case latest.SkipAsleep: + phrase = "browser asleep" + good = true + case latest.SkipDueQuery, latest.SkipEligibleCount: + phrase = "check failed" + attention = true + case latest.SkipNothingEligible: + phrase = "nothing eligible" + good = true + } + if phrase == "" && p.Due > 0 && p.Checked == 0 { + // The one true stall: the pass reached its loop, Series were waiting, + // and none were read. Every skip above is a Lane that said why. + phrase = "not checking" + attention = true + } + return phrase, good, attention +} + +// humanDuration renders a positive duration compactly for a "resumes in" clue +// at the pause and refusal scales — minutes under an hour, then h and h+m. +func humanDuration(d time.Duration) string { + d = d.Round(time.Minute) + if d <= 0 { + return "soon" + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d/time.Minute)) + } + h := int(d / time.Hour) + if m := int(d%time.Hour) / int(time.Minute); m == 0 { + return fmt.Sprintf("%dh", h) + } else { + return fmt.Sprintf("%dh%dm", h, m) + } +} + +// since formats how long ago a Lane last ran, at second resolution: the block +// refreshes every thirty seconds, so anything finer is noise the owner would +// have to ignore. +func since(now, then time.Time) string { + d := now.Sub(then).Truncate(time.Second) + if d < time.Second { + return "just now" + } + return d.String() + " ago" +} diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index 3c0d311..fb6edb3 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -191,6 +191,29 @@ } /* A single grid keeps row rules continuous; cell padding supplies gutters. */ +.admin-sheet .sechead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; +} + +.admin-sheet .sechead .statusline { + padding: 0 0 8px; + font-size: 11px; + letter-spacing: .14em; + text-transform: uppercase; +} + +.admin-sheet .statusline { + margin: 0; + padding: 0 0 10px; + font: 500 12px/1 var(--font-mono); + letter-spacing: .04em; + color: var(--mute-2); +} + .admin-sheet .tbl { display: grid; grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px; @@ -309,6 +332,23 @@ color: var(--mute-2); } +.admin-sheet .mark.mark-strong { + font-size: 13px; + letter-spacing: .14em; + color: var(--patina); +} + +.admin-sheet .mark.mark-strong::before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--patina); + margin-right: 8px; + vertical-align: .08em; +} + .admin-sheet .mark.bad { color: var(--danger); } @@ -335,6 +375,10 @@ color: var(--danger); } +.admin-sheet .tbl.lanes .c-skip .ok { + color: var(--patina); +} + .admin-sheet .tbl.sites .thead > *:nth-child(n+2):nth-child(-n+5), .admin-sheet .tbl.sites .trow > *:nth-child(n+2):nth-child(-n+5) { padding-right: 0; diff --git a/backend/internal/web/templates/lanes.html b/backend/internal/web/templates/lanes.html index 32a3976..a978861 100644 --- a/backend/internal/web/templates/lanes.html +++ b/backend/internal/web/templates/lanes.html @@ -1,42 +1,44 @@ -{{/* Poll Lane status: one row per Site, refreshing itself so a run can be - watched rather than sampled by reloading. The refresh is one attribute on - the fragment root and the endpoint answers with this same fragment, so the - swap replaces the element that asked for it. +{{/* Poll Lane status: one row per Site's latest durable pass, refreshing + itself so a run can be watched rather than sampled by reloading. The + refresh is one attribute on the fragment root and the endpoint answers + with this same fragment, so the swap replaces the element that asked. - Every figure here is read out of the running poller, never out of a table: - a Site absent from Rows has not completed a pass since the last restart, - which the empty state must say — zeroes would read as a stopped Lane. */}} + Every figure is read from poll_passes, never from a running poller: a + restart answers from the database the moment it is up (issue #145). The + browser fact is a deployment-config fact plus a reachability derived from + the pass log; the cause chips and the state phrase are decided in Go, + this template only prints them. */}} {{define "lanes"}}
-

Poll Lanes

+
+

Poll Lanes

+

+ {{if .PollerOff}}Polling is switched off in this deployment: no Lane + runs, and Latest Chapter comes from the userscripts alone. + {{else}}Browser: {{if not .BrowserConfigured}}not configured{{else if .BrowserReachable}}reachable{{else}}unreachable{{end}}{{end}} +

+
{{if .Rows}} - + {{else}} -

No data yet — no Lane has completed a pass since the - backend started.

+

No data yet — no Lane has recorded a pass.

{{end}} -

- {{if .PollerOff}}Polling is switched off in this deployment: no Lane runs, - and Latest Chapter comes from the userscripts alone. - {{else}}Browser sidecar: - {{if not .BrowserConfigured}}not configured — comix, kagane and novelfull - pages are not fetched through it{{else if .BrowserReachable}}reachable - {{else}}unreachable{{end}}.{{end}}

-{{end}} +{{end}} \ No newline at end of file diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go index e1f3a69..367df34 100644 --- a/backend/internal/web/web.go +++ b/backend/internal/web/web.go @@ -50,9 +50,14 @@ type Handler struct { // httpClient is the plain stdlib client that talks to Discord. It is not // an injected interface: tests point APIBase at a stub server instead. httpClient *http.Client - // lanes is the Poll Lane snapshot source the administrative page reads. - // Nil is a running deployment with no poller, not a bug. - lanes LaneReporter + // pollerEnabled reports whether latest-chapter polling is switched on in + // this deployment (LATEST_CHAPTER_POLL_ENABLED) and browserConfigured + // whether a browser sidecar is configured (BROWSER_WS_URL set). Both are + // deployment facts resolved by the composition root; the Lanes page (issue + // #145) reports them from config and derives reachability from the pass + // log rather than from whether a poller goroutine happened to start. + pollerEnabled bool + browserConfigured bool } // listView is what every list-rendering template receives. @@ -110,9 +115,11 @@ type loginView struct { // New parses every template up front so a broken one kills the process at // startup rather than the first request that touches it. // -// lanes is the administrative page's window onto the running Poller; nil means -// nothing is polling, which the page reports rather than hides. -func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, lanes LaneReporter) (*Handler, error) { +// pollerEnabled and browserConfigured are deployment facts the composition +// root resolves from LATEST_CHAPTER_POLL_ENABLED and BROWSER_WS_URL: the Lanes +// page (issue #145) reports them and derives browser reachability from the +// pass log, so no running poller is wired through here at all. +func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, pollerEnabled, browserConfigured bool) (*Handler, error) { tmpl, err := template.ParseFS(templateFS, "templates/*.html") if err != nil { return nil, err @@ -127,7 +134,8 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove states: newOAuthStates(), limiter: session.NewLoginLimiter(), httpClient: &http.Client{Timeout: discordTimeout}, - lanes: lanes, + pollerEnabled: pollerEnabled, + browserConfigured: browserConfigured, }, nil } diff --git a/backend/main.go b/backend/main.go index bebff8d..ca2bd90 100644 --- a/backend/main.go +++ b/backend/main.go @@ -54,6 +54,11 @@ type Config struct { // /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the // two libraries are separate installs. NovelUserscriptPath string + // BrowserWSURL is the CDP websocket the poller's browser Sites read + // through. Set means a browser sidecar is configured in this deployment — + // the Lanes page reports the fact and derives reachability from the pass + // log rather than asking the poller (issue #145). + BrowserWSURL string // LatestPoll configures the background latest-chapter fetcher. LatestPoll LatestPoll } @@ -108,6 +113,7 @@ func loadConfig() Config { OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"), UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"), NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"), + BrowserWSURL: os.Getenv("BROWSER_WS_URL"), LatestPoll: loadLatestPoll(), } c.Discord = web.DiscordConfig{ @@ -130,9 +136,10 @@ func loadConfig() Config { // preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected, // /healthz is public. // -// lanes may be nil — polling disabled, or its client could not be built. The -// admin page reports that rather than pretending Lanes exist. -func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler { +// The web layer learns the deployment's poller and browser config from cfg — +// nothing of the running poller is wired through here; the Lanes page reads +// the database (issue #145). +func newRouter(s *store.Store, cfg Config) http.Handler { mux := http.NewServeMux() h := &api.Handler{Store: s} mux.HandleFunc("GET /healthz", api.Healthz) @@ -161,9 +168,12 @@ func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler mux.Handle("/bookmarks/", auth) // The browser UI is always registered; signing in is Discord OAuth, so - // there is no password to forget and no gate to leave unset. + // there is no password to forget and no gate to leave unset. The poller + // and browser facts are config, not the poller's: the Lanes page reads + // the pass log and reports the deployment as configured. wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey), - cfg.UserscriptPath, cfg.NovelUserscriptPath, lanes) + cfg.UserscriptPath, cfg.NovelUserscriptPath, + cfg.LatestPoll.Enabled, strings.TrimSpace(cfg.BrowserWSURL) != "") if err != nil { log.Fatalf("web handler: %v", err) } @@ -241,7 +251,7 @@ func main() { var browser latest.Fetcher pollCtx, stopPoll := context.WithCancel(context.Background()) defer stopPoll() - if ws := strings.TrimSpace(os.Getenv("BROWSER_WS_URL")); ws != "" { + if ws := strings.TrimSpace(cfg.BrowserWSURL); ws != "" { bf, err := latest.NewBrowserFetcher(ws) if err != nil { log.Printf("browser fetcher disabled: %v", err) @@ -278,17 +288,14 @@ func main() { } s.OnSeriesCreated = acq.Acquire } - // A nil *Poller must not become a non-nil interface holding a nil pointer: - // the admin page tests the reporter for nil to decide whether anything is - // polling at all. - var lanes web.LaneReporter - if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil { - lanes = poller - } + // The poller's only connection to the web layer is the database now: it is + // started for its own sake, and the Lanes page reads the pass rows it + // records (issue #145). + startLatestPoller(pollCtx, s, cfg.LatestPoll, browser) srv := &http.Server{ Addr: ":" + cfg.Port, - Handler: newRouter(s, cfg, lanes), + Handler: newRouter(s, cfg), ReadHeaderTimeout: 10 * time.Second, } diff --git a/backend/reader_credential_test.go b/backend/reader_credential_test.go index 03d2a85..e66970c 100644 --- a/backend/reader_credential_test.go +++ b/backend/reader_credential_test.go @@ -49,7 +49,7 @@ func withBody(req *http.Request, body string) *http.Request { // A refused credential is refused however plausible it looks: only a hash the // readers table holds authenticates anything. func TestUnknownCredentialRejected(t *testing.T) { - srv := newRouter(newTestStore(t), testConfig(), nil) + srv := newRouter(newTestStore(t), testConfig()) rr := httptest.NewRecorder() srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("never-registered"))) @@ -69,7 +69,7 @@ func TestUnknownCredentialRejected(t *testing.T) { func TestPerReaderIsolation(t *testing.T) { s := newTestStore(t) registerReader(t, s, "other-reader") - srv := newRouter(s, testConfig(), nil) + srv := newRouter(s, testConfig()) ownerKey := "asura:solo" putBookmark(t, srv, ownerKey, store.Bookmark{ @@ -267,7 +267,7 @@ func TestRotateCredentialViaWebUI(t *testing.T) { } cfg := testConfig() cfg.UserscriptPath = path - srv := newRouter(s, cfg, nil) + srv := newRouter(s, cfg) oldCred := ownerCredential() rr := httptest.NewRecorder() diff --git a/backend/web_test.go b/backend/web_test.go index dcfd21d..54f4f42 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -28,17 +28,13 @@ import ( const testOwnerID = "owner-snowflake" // newWebTestServer returns the full router plus the store behind it, so tests -// can seed rows and assert on what the handlers wrote back. An optional lane -// reporter stands in for the running poller; omitted means none is running, -// which is what every test that is not about the admin page wants. -func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) { +// can seed rows and assert on what the handlers wrote back. The Lanes page +// reads the pass log (issue #145), so a test seeds store rows rather than +// standing in for a poller. +func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) { t.Helper() st := newTestStore(t) - var reporter web.LaneReporter - if len(lanes) > 0 { - reporter = lanes[0] - } - return newRouter(st, cfg, reporter), st + return newRouter(st, cfg), st } // sessionCookie mints a live session row for the owner and returns the cookie @@ -149,12 +145,12 @@ func discordConfig(stubURL string) web.DiscordConfig { // oauthWebTestServer returns the full router, its store, and a Discord stub // wired as the configured API — the starting point for sign-in tests. -func oauthWebTestServer(t *testing.T, lanes ...web.LaneReporter) (http.Handler, *store.Store, *discordStub) { +func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) { t.Helper() stub, srv := newDiscordStub(t) cfg := testConfig() cfg.Discord = discordConfig(srv.URL) - router, st := newWebTestServer(t, cfg, lanes...) + router, st := newWebTestServer(t, cfg) return router, st, stub } @@ -418,7 +414,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) { cfg.Discord = discordConfig(srv.URL) cfg.Discord.RequiredRole = tc.require st := newTestStore(t) - router := newRouter(st, cfg, nil) + router := newRouter(st, cfg) rr := completeSignIn(t, router, startSignIn(t, router)) if rr.Code != http.StatusForbidden { @@ -634,11 +630,37 @@ func TestOwnerRevokesAnotherReadersSessions(t *testing.T) { } } -// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the -// page's tests need neither a poller nor a Site. -type fakeLanes struct{ status latest.Status } +// seedPass writes one durable pass row — the Lanes page's whole seam. +// The page renders from the database with no poller running at all (issue +// #145), so a test seeds rows instead of constructing a fake reporter. +func seedPass(t *testing.T, st *store.Store, p store.LanePass) { + t.Helper() + if err := st.RecordLanePass(p, -1); err != nil { + t.Fatalf("seed pass %s: %v", p.Site, err) + } +} -func (f fakeLanes) LaneStatus() latest.Status { return f.status } +// lanesConfig returns a config with latest-chapter polling switched on, so +// the Lanes page's statusline speaks about the browser rather than about +// polling being off. +func lanesConfig() Config { + cfg := testConfig() + cfg.LatestPoll.Enabled = true + return cfg +} + +// lanesBody fetches the Lanes fragment as the owner and returns the body. +func lanesBody(t *testing.T, router http.Handler, st *store.Store) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code) + } + return rr.Body.String() +} // Every admin address carries the same navigation, while the roster only lives // on its own page and the other pages keep their shells independent. @@ -769,32 +791,22 @@ func TestAdminRoutesAreOwnerOnly(t *testing.T) { } } -// The Lane block reports what the poller says, and marks the Lanes that need -// attention — a clamped gap, a refusal, a Site whose pages can only be read -// through a sidecar that is not there, and a Lane with Series waiting that its -// last pass did not read. +// The Lane block reports what the pass log says, and marks the Lanes that +// need attention: a Site whose pages can only be read through a sidecar that +// is not there, and a Lane with Series waiting that its last pass did not +// read. Rows come from seeded database rows — no poller runs anywhere. func TestAdminPageShowsLaneStatus(t *testing.T) { - lanes := fakeLanes{latest.Status{ - Lanes: []latest.LaneState{ - {Site: "asura", Due: 12, Checked: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second}, - {Site: "kagane", Due: 3, Checked: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true}, - {Site: "demonic", Due: 400, Checked: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true}, - {Site: "comix", Due: 7, LastRun: time.Now(), Gap: time.Minute, Browser: true}, - }, - BrowserConfigured: true, - BrowserReachable: true, - }} - router, st, _ := oauthWebTestServer(t, lanes) + cfg := lanesConfig() + cfg.BrowserWSURL = "ws://browser:9222" + router, st := newWebTestServer(t, cfg) + now := time.Now() + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-90 * time.Second).UnixMilli(), Due: 12, Checked: 12, GapMS: 40_000}) + seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 3, Checked: 3, GapMS: 60_000}) + seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Due: 400, Checked: 400, GapMS: 8_000}) + seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli(), Due: 7, Checked: 0, GapMS: 60_000}) - req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil) - req.AddCookie(sessionCookie(t, st)) - rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code) - } - body := rr.Body.String() - for _, want := range []string{"asura", "kagane", "12 due", "12 checked", "gap 40s", "ran 1m30s ago", "gap at floor", "not checking", "reachable"} { + body := lanesBody(t, router, st) + for _, want := range []string{"asura", "kagane", "demonic", "comix", "40s", "ran 1m30s ago", "not checking", "none observed", "reachable"} { if !strings.Contains(body, want) { t.Errorf("lane status lacks %q:\n%s", want, body) } @@ -810,78 +822,75 @@ func TestAdminPageShowsLaneStatus(t *testing.T) { // A browser Lane under both wake thresholds holds Chrome asleep (ADR-0005), so // Series due with none checked is the design working, not a stopped Lane. The -// two must not render the same mark: "not checking" is the owner's cue to go -// looking, and spending it on the commonest healthy browser-Lane state trains -// them to ignore it. +// pass row records it with the asleep skip, and the page must render its +// sleeping sentence with no stall mark and no attention. func TestAsleepBrowserLaneIsNotMarkedStalled(t *testing.T) { - lanes := fakeLanes{latest.Status{ - Lanes: []latest.LaneState{ - {Site: "kagane", Due: 1, LastRun: time.Now(), Gap: 10 * time.Second, Browser: true, Asleep: true}, - }, - BrowserConfigured: true, - BrowserReachable: true, - }} - router, st, _ := oauthWebTestServer(t, lanes) + cfg := lanesConfig() + cfg.BrowserWSURL = "ws://browser:9222" + router, st := newWebTestServer(t, cfg) + seedPass(t, st, store.LanePass{ + Site: "kagane", RanAt: time.Now().UnixMilli(), + Skip: latest.SkipAsleep, Due: 1, Checked: 0, GapMS: 10_000, + }) - req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil) - req.AddCookie(sessionCookie(t, st)) - rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) - body := rr.Body.String() + body := lanesBody(t, router, st) if strings.Contains(body, "not checking") { t.Errorf("an asleep browser Lane is marked as stalled:\n%s", body) } if !strings.Contains(body, "browser asleep") { t.Errorf("an asleep browser Lane says nothing about why it read nothing:\n%s", body) } - if strings.Contains(body, `class="attention"`) { + if strings.Contains(body, `class="trow attention"`) { t.Errorf("an asleep browser Lane is coloured as unhealthy:\n%s", body) } } // A Lane whose pass never reached a figure must not have that figure drawn as -// a zero: a refusing Lane still reports the due count and gap its last real -// pass saw, and a Lane that has never reached one omits it entirely. +// a zero: a refusing Lane that has never gathered figures draws "—" for the +// gap rather than stating a zero it did not measure. func TestLaneStatusOmitsUnknownGap(t *testing.T) { - lanes := fakeLanes{latest.Status{ - Lanes: []latest.LaneState{{Site: "comix", LastRun: time.Now(), Refusing: true, Browser: true}}, - BrowserConfigured: true, - BrowserReachable: true, - }} - router, st, _ := oauthWebTestServer(t, lanes) + cfg := lanesConfig() + cfg.BrowserWSURL = "ws://browser:9222" + st, dsn := newTestStoreURL(t) + router := newRouter(st, cfg) + now := time.Now() + oldNow := now.Add(-time.Minute).UnixMilli() + seedPass(t, st, store.LanePass{Site: "comix", RanAt: oldNow, Skip: latest.SkipRefusing}) + // Refusal expiry lives on the Lane (poll_lanes), not the pass row, so it + // must be seeded there for the backs-off-until clause to render. + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open %s: %v", dsn, err) + } + defer db.Close() + if _, err := db.Exec(`INSERT INTO poll_lanes (site, refuse_until) VALUES ($1, $2)`, "comix", now.Add(10*time.Minute).UnixMilli()); err != nil { + t.Fatalf("seed lane refusal: %v", err) + } - req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil) - req.AddCookie(sessionCookie(t, st)) - rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) - body := rr.Body.String() - if strings.Contains(body, "gap 0s") { + body := lanesBody(t, router, st) + if strings.Contains(body, `>0s<`) { t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body) } - if !strings.Contains(body, "refusing") { - t.Errorf("a refusing Lane is not marked as such:\n%s", body) + if !strings.Contains(body, "refusing · backs off until") { + t.Errorf("a refusing Lane is not marked with its backoff time:\n%s", body) } } -// No poller and a poller that has not finished a pass both render "no data -// yet" rather than zeroes that read as a stopped backend — but they are not -// the same fact, so the page must not blame the sidecar when nothing polls. +// Polling switched off and a browser not configured are different facts, and +// the page must not blame the sidecar when nothing polls. Neither is a poller +// running — the Lanes page answers entirely from config and the pass log. func TestAdminPageWithoutAPollerSaysSo(t *testing.T) { for _, tc := range []struct { name string - lanes []web.LaneReporter + cfg Config want, unwant string }{ - {"no poller", nil, "Polling is switched off", "not configured"}, - {"poller, no pass yet", []web.LaneReporter{fakeLanes{}}, "not configured", "Polling is switched off"}, + {"polling switched off", testConfig(), "Polling is switched off", "not configured"}, + {"browser not configured", lanesConfig(), "not configured", "Polling is switched off"}, } { t.Run(tc.name, func(t *testing.T) { - router, st, _ := oauthWebTestServer(t, tc.lanes...) - req := httptest.NewRequest(http.MethodGet, "/admin/lanes", nil) - req.AddCookie(sessionCookie(t, st)) - rr := httptest.NewRecorder() - router.ServeHTTP(rr, req) - body := rr.Body.String() + router, st := newWebTestServer(t, tc.cfg) + body := lanesBody(t, router, st) if !strings.Contains(body, "No data yet") { t.Errorf("admin page with no Lane data does not say so:\n%s", body) } @@ -895,12 +904,133 @@ func TestAdminPageWithoutAPollerSaysSo(t *testing.T) { } } +// Restart survival is the point of the durable lane state: rows seeded into +// poll_passes render with no poller running anywhere, complete thirty seconds +// after a deploy. This is the test that proves the in-memory path is gone. +func TestLanesRenderFromSeededRowsAfterRestart(t *testing.T) { + router, st := newWebTestServer(t, lanesConfig()) + now := time.Now() + // A pass a minute ago, another three hours ago: the latest per Site wins. + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-3 * time.Hour).UnixMilli(), Due: 1, Checked: 1, GapMS: 10_000}) + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 5, Checked: 5, GapMS: 20_000}) + + body := lanesBody(t, router, st) + if !strings.Contains(body, `class="c-site">asura`) { + t.Fatalf("asura row missing from a restart-read database:\n%s", body) + } + // The fragment carries its own single timer and answers the swap it asked + // for, so the page keeps refreshing against the database. + if !strings.Contains(body, `hx-get="/ui/admin/lanes"`) || !strings.Contains(body, `hx-trigger="every 30s"`) { + t.Errorf("Lanes fragment lost its self-refresh:\n%s", body) + } +} + +// A skip reason is the whole difference between a Lane resting and a Lane +// stuck: a skipped pass says why it declined, and the one true stall — empty +// skip with Series due and none read — is the only thing that draws the fault. +func TestSkipReasonIsNotAStall(t *testing.T) { + router, st := newWebTestServer(t, lanesConfig()) + now := time.Now() + // Asleep: due but none checked, with a skip that says why — no stall, no + // attention. + seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 2, Checked: 0, GapMS: 10_000}) + // The true stall: reached the loop, Series waiting, none read. + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, GapMS: 20_000}) + + body := lanesBody(t, router, st) + if !strings.Contains(body, "browser asleep") { + t.Errorf("the asleep row does not say why it declined:\n%s", body) + } + if !strings.Contains(body, "not checking") { + t.Errorf("the true stall is not rendered as a fault:\n%s", body) + } + // Exactly the stall row wears attention; the asleep row never does. + if strings.Count(body, `class="trow attention"`) != 1 { + t.Errorf("attention is on %d rows, want exactly the stall:\n%s", strings.Count(body, `class="trow attention"`), body) + } +} + +// The five outcome counts render named — the page never prints the word +// "failures" — and a Site with none observed says so rather than drawing a +// blank cell. +func TestNamedOutcomeChips(t *testing.T) { + router, st := newWebTestServer(t, lanesConfig()) + now := time.Now() + seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Refused: 5, NoChapter: 2, Errors: 1}) + seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Unreachable: 3, Unfetchable: 4}) + seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli()}) + + body := lanesBody(t, router, st) + for _, want := range []string{"refused 5", "no chapter 2", "errors 1", "unreachable 3", "unfetchable 4"} { + if !strings.Contains(body, want) { + t.Errorf("lane chips lack %q:\n%s", want, body) + } + } + if strings.Contains(body, "failures") { + t.Errorf("the page prints the forbidden word \"failures\":\n%s", body) + } + // comix has no outcomes in the window: it must say none observed, and the + // phrase must not be blank. + if !strings.Contains(body, "none observed") { + t.Errorf("a Site with no outcomes does not say none observed:\n%s", body) + } +} + +// Browser configuration is a deployment fact and reachability is derived from +// the latest browser-Site passes inside the refusal backoff — no poller in any +// of the three. +func TestBrowserConfigAndReachabilityDerived(t *testing.T) { + now := time.Now() + + t.Run("not configured", func(t *testing.T) { + router, st := newWebTestServer(t, lanesConfig()) + seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipNoFetcher}) + body := lanesBody(t, router, st) + if !strings.Contains(body, "not configured") || strings.Contains(body, "unreachable") { + t.Errorf("unset BROWSER_WS_URL must read as not configured:\n%s", body) + } + }) + + t.Run("unreachable from recent sidecar-down", func(t *testing.T) { + cfg := lanesConfig() + cfg.BrowserWSURL = "ws://browser:9222" + router, st := newWebTestServer(t, cfg) + seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown}) + body := lanesBody(t, router, st) + if !strings.Contains(body, "unreachable") { + t.Errorf("recent sidecar-down passes must read as unreachable:\n%s", body) + } + }) + + t.Run("reachable from clean passes", func(t *testing.T) { + cfg := lanesConfig() + cfg.BrowserWSURL = "ws://browser:9222" + router, st := newWebTestServer(t, cfg) + seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-time.Minute).UnixMilli(), Checked: 3}) + body := lanesBody(t, router, st) + if !strings.Contains(body, "reachable") { + t.Errorf("clean browser passes must read as reachable:\n%s", body) + } + }) + + t.Run("sidecar-down outside the backoff is reachable again", func(t *testing.T) { + cfg := lanesConfig() + cfg.BrowserWSURL = "ws://browser:9222" + router, st := newWebTestServer(t, cfg) + seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.Add(-30 * time.Minute).UnixMilli(), Skip: latest.SkipSidecarDown}) + body := lanesBody(t, router, st) + if !strings.Contains(body, "reachable") { + t.Errorf("a sidecar-down older than the backoff must read as reachable:\n%s", body) + } + }) +} + // A Reader past the disagreement threshold is rendered as blocked, and // clearing their marks both zeroes the counters and lifts the block in the // roster the response carries back. func TestOwnerClearsReaderMarks(t *testing.T) { st, dsn := newTestStoreURL(t) - router := newRouter(st, testConfig(), nil) + router := newRouter(st, testConfig()) cookie := sessionCookie(t, st) // The counters are filled by issue #103; until it lands the only way to // stand a marked Reader up is to write the columns directly. diff --git a/docs/adr/0012-persisted-lane-state.md b/docs/adr/0012-persisted-lane-state.md new file mode 100644 index 0000000..f740522 --- /dev/null +++ b/docs/adr/0012-persisted-lane-state.md @@ -0,0 +1,44 @@ +# ADR-0012: Persisted lane state + +Date: 2026-08-21 +Status: accepted + +Supersedes the in-memory lane snapshot carried by `latest`'s `LaneState`/`Status` +and the `web.LaneReporter` seam (ADR-0010 wrote the durable rows this page now +reads). + +## Decision + +The admin Lanes page stops reading the poller's in-memory Lane state and +becomes a read of `poll_passes`/`poll_lanes` in Postgres. There is no +`LaneReporter` interface: `web/admin_lanes.go` walks `store.LatestLanePasses()` +into one row per Site and adds the window's outcome sums from +`store.LanePassOutcomes()`. The `latest` package's `LaneState`/`Status` snapshot +and its `web.LaneReporter` seam are deleted. + +The browser is a deployment configuration fact plus a reachability derived +from the pass log: `BROWSER_WS_URL` set means "configured", and the browser is +"reachable" unless a recent browser-Site pass inside `latest.RefuseBackoff` is +a sidecar loss, a missing fetcher, or an interrupted read. A skip reason is +the whole difference between a Lane resting and a Lane stuck: a skipped pass +prints its sentence, and only an empty skip with Series due and none read +draws the true-stall fault. Sleep skips never count toward `Attention`. + +## Why + +The old page lived on a poller snapshot. Because that state was in memory, a +deploy erased it: the page read zeroes until a fresh pass ran, and browser +reachability came through a reporter interface only a live poller could +serve. Making the page answer from the database means a restart is complete +the instant the store is up, the browser fact survives a poller restart, and +a Lane that has not yet gathered figures shows a placeholder rather than a +confident zero. + +## Constraints + +The poller still owns the writes: each pass exit records one row (ADR-0010), +and a pass that returns before gathering figures carries the previous pass's +numbers forward instead of recording zeroes. A skip is a stable wire string; +`asleep` never counts toward `Attention`. When polling is switched off the +page must say so, and the browser statusline appears only when a poller +actually answers. \ No newline at end of file