Poll history: persist it, or stay a live-now view #117
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #114
Question
Should the backend persist poll history, and if so in what shape?
Nothing is persisted today.
Poller.laneStatesholds the single most recent pass per Lane inmemory and loses it on restart — which is why the page reads "No data yet" seconds after a
deploy. The only per-poll DB writes are
MarkLatestChecked,SetLatestChapter,RecordSighting*,SetSeriesCover, and there is no per-Series failure counter column anywhere.Expected shape going in: live-now plus a bounded last-N-runs, not a timeseries — a swapless
1974 MiB VPS running Postgres argues hard against volume.
To decide:
failing for a week" needs; per-Lane is far cheaper and answers "what has the poller been doing".
window). Note
sessionsdeliberately has no background sweep — expiry is lazy at lookup.adapter reading a wrong number are three different failures, and only the first two are visible
to the Lane today.
Added by #115
Hard requirement from the landing page: per-Site failure counts over the last 24h, wanted in
the landing page's per-Site table. Impossible today, so until this ticket lands that column is
absent rather than zero.
Two consequences for the shape:
the dashboard — it does not by itself require per-Series history.
unreachable). An adapter reading a wrong number (#79) is not, so a rendered "0 failures" would
lie in exactly the case that motivates the dashboard. Whatever this ticket persists must let
the page distinguish "no failures seen" from "this kind of failure is not observable".
Resolution
One append-only Lane Pass log, a generalised per-Site Lane row, and the deletion of the
poller's in-memory page state. Display window 12h; retention 14 days — two different
windows, deliberately.
Migration 0014
(site, ran_at)is the whole index budget. One goroutine per Lane writes sequentially, so thepair is unique without a surrogate id, and it serves both reads: latest row per Site, and a
per-Site window sum. Retention's
WHERE ran_at < cutoffscans, which at ~1.7k live rows ischeaper than a second index. Volume: 5 Sites x ~24 passes/day = ~120 rows/day.
Not persisted, because each is derivable and a second copy is a second thing to drift:
refusing(frompoll_lanes.refuse_until—LaneStatusalready overwrites the in-memoryfield at snapshot time, status.go:53),
browser(registry:isBrowserSite), and anyokcount (see below).
The skip enum — one value per return path
runLanePasshas nine exits, and today a page cannot tell them apart: all it sees isDue > 0, Checked == 0, which reads as a stall in eight cases that are not one. One text column fixesthat and replaces the
Asleepboolean:skip''pausedrefusingsidecar-downno-fetcherBROWSER_WS_URL, no fallback (poller.go:245)due-queryDueForLatestCheckfailed (poller.go:255)asleepeligible-countEligibleSeriesCountfailed (poller.go:280)nothing-eligibleeligible == 0, sleeps a full rest (poller.go:290)The stall rule follows from it:
due > 0 AND checked = 0 AND skip = ''is the only truestall. Everything else is a Lane that declined to work and said why.
Outcome counts — the classification already exists
readSeriesPageclassifies six outcomes andcheckOnethrows all of them atlog.Printf.The pass row counts them:
refusederrChallengeHeld— 403cf-mitigatedor an interstitial body (read.go:55-69)unreachableerrBrowserInterruptedno_chapter!facts.HasLatest— "no chapter links in N bytes" (poller.go:458)unfetchableerrNotFetchable(host pin) orerrNoFetchererrorsMarkLatestCheckedfailureno_chapteris its own count and never folded intorefused: it is what a broken adapterlooks like when it breaks loudly, and #115's honesty requirement is precisely that the page not
present adapter breakage as a quiet zero.
A success count is derived, never stored:
ok = checked - (refused + no_chapter + unfetchable + errors).unreachableis excluded from that arithmetic because the sidecar-losspath returns before
st.Checked++(poller.go:321-324), so the Series that lost Chrome was nevercounted as checked. A stored
okcolumn would be a fifth way to get that wrong.The page never renders the word "failures" bare. Named counts, and a zero renders as
none observed — the wording is the honesty, because the one failure kind that matters most
(#79: an adapter parsing a wrong number successfully) is not in this taxonomy and never can be.
Carry-forward stays on the write
recordLaneState(status.go:75-77) carries the previous pass'sdue/gap/clamped/checkedforward when the pass returned before computing them, so the page never states a zero it
did not measure. That rule moves verbatim — carry forward exactly when the pass's own gap is
0 (today: the
refusingandsidecar-downexits) — so every row is self-describing and theread is one
DISTINCT ON. Not preserved-by-accident: withskiprecorded, a genuine zero nextto
skip = 'due-query'now reads correctly instead of as a measurement, which is why the ruleis kept rather than widened.
Rejected: NULL columns plus a read-side "last non-NULL per Site" (two-part read,
COALESCEgymnastics for a value the writer already holds), and writing no row for a skipped pass (a Lane
refusing for a day would look like a Lane that stopped existing).
Store surface
RecordLanePassinserts and prunes in the same call — delete-on-insert, so the Lanegoroutine is the pruner and no
time.Tickerenters a backend that has none (sessionsexpires lazily at lookup for the same reason).
retainBeforeis caller-supplied, keeping thestore clockless like
DueForLatestCheck.LatestLanePassesisDISTINCT ON (site) ... ORDER BY site, ran_at DESC, LEFT JOINed topoll_lanes— one query for the Lanes page, and the same rows the landing verdict sums.LatestLanePass(site)is the carry-forward read: 5 reads an hour, and the recorder needs oneSite, not five.
LanePassOutcomes(since)isGROUP BY sitewithSUMper outcome column over the window.What gets deleted
Poller.laneStates,recordLaneState,LaneState,Status,LaneStatus()— status.go goesaway; the pass row is the record.
web.LaneReporter. This overrides #114's Notes and #119's answer, both of which kept itas one read-only method so the admin page stayed testable with no poller running. That goal is
better served by no interface at all: with the store as the source, an admin test inserts a
pass row and asserts the render, with no fake reporter to construct. The Note's letter goes,
its reason survives.
Poller.refuseUntil(the map). Refusal becomespoll_lanes.refuse_until, read at the top ofa pass alongside #119's pause read — one row, one query, two gates.
Poller.browserDownAtstays in memory, and the distinction is the point: a refusal is theSite's mood and outlives our process, while the sidecar being unreachable is a fact about
our own reach — a restart re-probing Chrome is correct behaviour, not lost state. Note the
consequence:
refuse_untilbecoming durable changes today's behaviour, where a restart forgetsa refusing Site and immediately re-probes it. That is the same argument #119 made for
paused_until.Status.BrowserConfiguredcame fromp.BrowserFetch != nil, a Poller field. It becomesBROWSER_WS_URL != ""in the web layer's config — strictly more accurate, since it describesthe deployment rather than whether one goroutine happened to construct a fetcher. Browser
reachability is derived: any browser Site whose latest pass carries
unreachable > 0insiderefuseBackoffof now. That needs the constant exported aslatest.RefuseBackoff(15m,sites.go:406) — one exported constant instead of one interface.
One window constant, 12h
ownerWindow = 12 * time.Hourin the web package, feeding both the staleness cutoff and theoutcome window. #116 superseded #115's 24h figure for staleness only, so the failure window was
still 24h on paper; the rationale it gave (the owner looks once by day and once by night, and
each look should cover the interval since the last) governs both figures identically, and two
different windows on one page is a comprehension bug. This retires #116's
staleAftername —a rename on paper, since nothing is built.
Retention is not this constant: 14 days, and the two must not be collapsed. The window is
what the owner is shown; retention is how far back a question can reach.
What the pages read
#115's split rule — "landing from the database, Lanes page from memory" — is void, because
its premise was the memory this ticket deletes. Replacement, same guarantee on a new axis:
landing shows aggregates over Sites, the Lanes page shows per-Lane detail; no figure appears
on both.
<verdict> · N series waiting · M unchecked over 12h, withN = SUM(due)over the latest pass per Site. The per-Site tablegains the five outcome sums over 12h from
LanePassOutcomes. The "no Lane has reported yet"state does not disappear — it narrows from after every deploy to a virgin database.
due / checked / gap / clamped / skip / paused_until / refuse_until, plus #119's Pause and Resume controls. Keeps the only timer on the surface(#115), still 30s — now a 5-row primary-key read instead of a map copy, which is what
overrides #115's "the timer is free because it touches no database" argument: Postgres holds
that page in cache, and one source of truth is worth more than the read.
Amendments to closed tickets
poll_laneswas specified as "a row exists only while paused; resume deletes it".It becomes the per-Site Lane state row:
paused_untilandrefuse_untilboth default 0,ResumeLanezeroespaused_untilinstead of deleting the row, andPausedLanes()filters
paused_until > now. A refusing-but-unpaused Lane needs a row, so the delete-on-resumeinvariant cannot survive.
skip = 'paused'is this ticket's record of #119's pause exit.staleAfterbecomesownerWindow; see the new ticket below for the hole its filterset leaves.
LaneReporter stays one read-only methodbullet is rewritten to say theadmin page reads Lane state from the database.
Pushed onto other tickets
doing" per Site, and nothing durable about one Series.
latest_checked_atis stampedbefore the fetch (poller.go:428), so it means attempted, never succeeded — a Series
broken for a month is
unchecked-clean,stale-clean andunpollable-clean in every filter#116 defined, while its per-Site count rolls off in 12h. That is a hole in the filter set, not
a nice-to-have.
the outcome counts should link somewhere, and after the new ticket lands that somewhere is a
failingfilter.skipexplanation and five named outcome countswith a none observed empty state, not a single "errors" number.
Rejected
poll_lanes(5 rows forever, no retention, no pruning). Failsonly the 12h window — which is exactly the question "does that window earn a table", answered
yes: without it the dashboard can say a Lane is broken now but not that it was broken at 04:00.
defaultRest = 1h), and still insufficient: a pass that checked nothing writes nothing, so "the Lane ran,everything was rested, all healthy" — the primary health signal — is invisible. The per-Series
question it would answer is answered better by three columns on
series.it cannot be aggregated.
poll_commandsfor the same reason,hanging it on this ticket). Not subsumed: different provenance (machine observation vs human
action), different retention instinct, and a Forced Poll is already self-evidencing —
force_poll_at > latest_checked_atis the record, ageing visibly while unserved. A 14-daypass log would silently expire the one thing worth looking back on.
Glossary:
CONTEXT.mdgains Lane Pass. An ADR is worth writing when this is implemented(persisted Lane state plus the deletion of the in-memory twin), not now — the map produces the spec.
sulthan referenced this issue2026-08-18 06:59:54 +07:00
Amendments from #127 (closed):
poll_passesgains a sixth outcome count,not_found— a 4xx status other than 403. Your five-word taxonomy is otherwise reused verbatim. The reason for the split is thaterrorstoday covers a 404 (the page is gone, the owner must correct the address), a 5xx (wait) and a failedMarkLatestChecked(our database is unwell) — three different owner actions behind the one word a page prints. One branch in read.go, free on paper since nothing here is built.failingfilter"; #118 wrote it as all five pointing at?site=…&filter=failing. #127 keeps them unlinked permanently. Two reasons:refusedandunreachabledeliberately write no per-Series state at all, so those two counts would open an empty list beside a large number; and the remaining four count attempts in a 12h window while the filter lists Series failing now for over 12h — a Series that broke at 04:00 and healed at 05:00 is counted and not listed, one broken three months on a paused Lane is listed and counted zero. Neither set contains the other. Each Site row on the Lanes page instead carries one separate navigation link to/admin/series?site=<site>&filter=failing.poll_lanes.refuse_untilremains the whole record of a refusing Site andbrowserDownAtthe whole record of a lost sidecar, so arefusedorunreachablePoll issues no statement against the newpoll_failurestable.ownerWindow(12h) is reused for thefailingthreshold — no new constant. Retention is not involved:poll_failuresholds current state and is deleted on the next correct read.