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)
This commit is contained in:
2026-08-21 20:25:38 +07:00
parent 503fb49d0a
commit cb2b104e63
16 changed files with 757 additions and 517 deletions
+216 -86
View File
@@ -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</span>`) {
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.