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
+60 -52
View File
@@ -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()