Poller command seam: force-poll and pause-lane through the database #119

Closed
opened 2026-08-17 15:22:44 +07:00 by sulthan · 3 comments
Owner

Part of #114
Blocked by: #115

Question

How does an admin action reach the poller without the admin page commanding it?

Settled: through the database. LaneReporter stays one read-only method (LaneStatus()), so the
admin page keeps being testable with a fake and no poller. A forced action is a flag the Lane
notices on its next pass.

To decide:

  • Where the flag lives. A column on series for force-poll is the obvious answer; a Lane-level
    pause has no table today, and the map's fog already asks whether a pause survives restart.
  • How force-poll interacts with DueForLatestCheck's existing predicates — the cutoff, the
    ceiling, and the Sighting-deferral guard. A forced Series must jump the queue without breaking
    the pace guarantees ADR-0010 protects.
  • What the UI promises. The action is asynchronous by construction, so the wording and any
    feedback (pending marker on the row? nothing?) must not imply an immediate refetch.
  • Whether pause-lane is per-Site only, or whether pausing everything is a distinct control.
  • How a stuck flag clears itself if the Lane never runs (a refusing Site, an unreachable
    browser).
Part of #114 Blocked by: #115 ## Question How does an admin action reach the poller without the admin page commanding it? Settled: through the database. `LaneReporter` stays one read-only method (`LaneStatus()`), so the admin page keeps being testable with a fake and no poller. A forced action is a flag the Lane notices on its next pass. To decide: - Where the flag lives. A column on `series` for force-poll is the obvious answer; a Lane-level pause has no table today, and the map's fog already asks whether a pause survives restart. - How force-poll interacts with `DueForLatestCheck`'s existing predicates — the cutoff, the ceiling, and the Sighting-deferral guard. A forced Series must jump the queue without breaking the pace guarantees ADR-0010 protects. - What the UI promises. The action is asynchronous by construction, so the wording and any feedback (pending marker on the row? nothing?) must not imply an immediate refetch. - Whether pause-lane is per-Site only, or whether pausing everything is a distinct control. - How a stuck flag clears itself if the Lane never runs (a refusing Site, an unreachable browser).
sulthan added the wayfinder:grilling label 2026-08-17 15:22:44 +07:00
sulthan self-assigned this 2026-08-17 17:57:29 +07:00
Author
Owner

Answer

Two pieces of database state, written by the admin page and read by the Lane. Nothing calls the
poller: LaneReporter stays the single read-only LaneStatus(), so the admin page keeps being
testable with a fake and no poller running.

Forced Poll — one column on series

Migration 0013 (0012 is #116's index): ALTER TABLE series ADD COLUMN force_poll_at bigint NOT NULL DEFAULT 0 — unix ms, 0 means never asked.

  • Written by ForceSeriesPoll(site, seriesID): UPDATE series SET force_poll_at = $3 WHERE site = $1 AND series_id = $2. Idempotent; pressing again re-stamps the request time.
  • Pending is derived, never stored: force_poll_at > latest_checked_at. It clears itself with no
    second write and no sweeper, because checkOne stamps MarkLatestChecked before the fetch
    (poller.go:428) — the first attempt ends the pending state whatever the attempt returns. That
    stamp-before-fetch order is load-bearing here; a change to it silently makes forced requests
    sticky.
  • No expiry. A request the Lane never reaches keeps ageing in the UI rather than vanishing: an
    old pending marker is evidence a Lane is stuck, which is the symptom this dashboard exists to
    surface.

DueForLatestCheck (store.go:972) gains the flag in three places, and s.force_poll_at joins the
GROUP BY list alongside the other series columns so the plain predicate is usable throughout:

WHERE  s.site = $1
  AND  s.series_url <> ''
  AND  (s.latest_checked_at <= $2::bigint OR s.force_poll_at > s.latest_checked_at)
HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0
        OR s.force_poll_at > s.latest_checked_at)
   AND (COUNT(*) > 1
        OR s.latest_sighted_at <= $2::bigint
        OR s.latest_checked_at <= $3::bigint
        OR s.force_poll_at > s.latest_checked_at)
ORDER BY (s.force_poll_at > s.latest_checked_at) DESC, reader_count DESC, s.latest_checked_at ASC

So a Forced Poll overrides the waiting rules: the one-hour rest cutoff, the Sighting-deferral
clause (issue #103, ADR-0011), and the finished-only bucket — the owner asking is direct evidence
someone cares about a Series every Reader shelved. It never overrides: series_url <> ''
(nothing to fetch — that row's repair is #121), the bookmarks join (an Orphan Series is #125), the
Lane's refusal backoff (ADR-0010's containment: hand-forcing a request at a Site that is actively
refusing is the one move that makes it worse), the sidecar-down skip, or the Lane's gap. A forced
Series behind a refusing Site simply goes on the first pass after the 15 minutes.

One pass-level gate it does open: browserWakeDue. A forced Series wakes a sleeping Chrome —
the wake thresholds exist to stop the machine waking itself for a single unattended check, and a
human asking is not that. Cost: seconds of the single shared tab per press, and if the home machine
is off nothing happens at all and the request ages visibly.

Paused Lane — one small table

Same migration: CREATE TABLE poll_lanes (site text PRIMARY KEY, paused_until bigint NOT NULL). A
row exists only while paused; resume deletes it. Store surface: PauseLane(site, until) (upsert,
rejecting an until not in the future), ResumeLane(site), PausedLanes() for the page,
LanePausedUntil(site) for the pass.

  • runLanePass reads its pause row first, ahead of the refusal check: when paused it records the
    Lane state and sleeps until the expiry. Its Series stay due and unstamped — the identical state a
    missing browser leaves them in, so nothing new has to handle it, and the queue is waiting intact
    when the pause lifts.
  • Expiry is mandatory, offered as 1h / 6h / 24h. An indefinite pause is a silent outage on the
    one surface whose job is proving the poller is alive.
  • Survives a restart, because it is a fact about the Site rather than about the process.
  • No global runtime pause. LATEST_CHAPTER_POLL_ENABLED (main.go:98) stays the only
    whole-poller stop and still needs a redeploy; the case for a runtime one is the case where you
    already have a shell.
  • Acquisition is unaffected. Acquirer.Acquire (acquire.go:67) is its own path — a Reader's
    first bookmark of a Series on a paused Site still reads the page. Pause governs the Lane only.

Routes and UI, extending #115's map

  • POST /admin/series/{key}/poll → ForceSeriesPoll, answering with the swapped Series-row
    fragment. Both surfaces carry the button, from one handler and one fragment: the Series list
    (the real workflow is arriving from a hygiene count into a filtered list) and the detail page,
    which embeds the same fragment.
  • POST /admin/lanes/{site}/pause (duration from the form) and POST /admin/lanes/{site}/resume,
    both answering with the swapped lanes block.
  • Label "Check now", not confirm-gated — nothing is destroyed and no row leaves the list, so
    Cinder's confirm rule does not apply. Pending renders as check requested <age> ago, with no
    ETA
    : the page would have to guess the Lane's next wake, and that guess is wrong on a browser
    Site whenever the home machine is off. The button is not offered at all for a Series with no
    series_url or with zero Readers, rather than offered and quietly ineffective.
  • Pause: a duration picker plus Pause per Lane row, Resume on a paused row, neither
    confirm-gated — picking a duration is already deliberate, and the state is visible, bounded and
    instantly reversible.
  • The Lanes page reads the pause rows straight from the database, not through LaneState. Memory
    is empty after a restart while a pause survives one, so a memory-only pause would render a
    genuinely paused Site as "no data yet". This does not break #115's split rule — that rule forbids
    showing the same figure from two sources, and pause has no in-memory twin.
  • Accent: the existing .attention class. Never --ember (New Chapter only), never --danger
    (destruction).

Pushed onto other tickets

  • #116: SeriesRow projects force_poll_at and exposes the derived pending bool. The
    unchecked and stale filters are untouched, because force never writes latest_checked_at.
  • #122: the prototype now covers a Series row with Check now plus an ageing pending marker, and
    a Lane row with a duration picker, Pause and Resume.
  • #121: a Forced Poll re-judges any outstanding Sighting through judgeSighting for free, so
    "check now" is also the manual remedy for a suspicious Latest Chapter and can mark the Reader who
    raised it. Whether the correction UI advertises it that way is #121's call.
  • Fog cleared: whether a paused Lane survives a backend restart, and where that state lives — yes,
    a poll_lanes row.
  • An ADR is worth writing when this is implemented (the through-the-database seam plus the
    queue-jump rules), not now: this map produces the spec.

Glossary: CONTEXT.md gains Forced Poll and Paused Lane.

Rejected

  • latest_checked_at = 0 as the force signal — zero migration, but it corrupts the never-checked
    and stale counts the landing page exists to show, and makes a pending marker impossible.
  • A poll_commands table — an audit trail in disguise, and whether admin actions need one is fog
    hanging on #117.
  • Indefinite pause, and a site = '*' pseudo-row for a global one — a second meaning for the
    primary key of a six-row table.
## Answer Two pieces of database state, written by the admin page and read by the Lane. Nothing calls the poller: `LaneReporter` stays the single read-only `LaneStatus()`, so the admin page keeps being testable with a fake and no poller running. ### Forced Poll — one column on `series` Migration **0013** (0012 is #116's index): `ALTER TABLE series ADD COLUMN force_poll_at bigint NOT NULL DEFAULT 0` — unix ms, 0 means never asked. - Written by `ForceSeriesPoll(site, seriesID)`: `UPDATE series SET force_poll_at = $3 WHERE site = $1 AND series_id = $2`. Idempotent; pressing again re-stamps the request time. - **Pending is derived, never stored**: `force_poll_at > latest_checked_at`. It clears itself with no second write and no sweeper, because `checkOne` stamps `MarkLatestChecked` *before* the fetch (poller.go:428) — the first attempt ends the pending state whatever the attempt returns. That stamp-before-fetch order is load-bearing here; a change to it silently makes forced requests sticky. - **No expiry.** A request the Lane never reaches keeps ageing in the UI rather than vanishing: an old pending marker is evidence a Lane is stuck, which is the symptom this dashboard exists to surface. `DueForLatestCheck` (store.go:972) gains the flag in three places, and `s.force_poll_at` joins the `GROUP BY` list alongside the other series columns so the plain predicate is usable throughout: ``` WHERE s.site = $1 AND s.series_url <> '' AND (s.latest_checked_at <= $2::bigint OR s.force_poll_at > s.latest_checked_at) HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0 OR s.force_poll_at > s.latest_checked_at) AND (COUNT(*) > 1 OR s.latest_sighted_at <= $2::bigint OR s.latest_checked_at <= $3::bigint OR s.force_poll_at > s.latest_checked_at) ORDER BY (s.force_poll_at > s.latest_checked_at) DESC, reader_count DESC, s.latest_checked_at ASC ``` So a Forced Poll **overrides the waiting rules**: the one-hour rest cutoff, the Sighting-deferral clause (issue #103, ADR-0011), and the finished-only bucket — the owner asking is direct evidence someone cares about a Series every Reader shelved. It **never overrides**: `series_url <> ''` (nothing to fetch — that row's repair is #121), the `bookmarks` join (an Orphan Series is #125), the Lane's refusal backoff (ADR-0010's containment: hand-forcing a request at a Site that is actively refusing is the one move that makes it worse), the sidecar-down skip, or the Lane's gap. A forced Series behind a refusing Site simply goes on the first pass after the 15 minutes. One pass-level gate it *does* open: `browserWakeDue`. A forced Series wakes a sleeping Chrome — the wake thresholds exist to stop the machine waking itself for a single unattended check, and a human asking is not that. Cost: seconds of the single shared tab per press, and if the home machine is off nothing happens at all and the request ages visibly. ### Paused Lane — one small table Same migration: `CREATE TABLE poll_lanes (site text PRIMARY KEY, paused_until bigint NOT NULL)`. A row exists only while paused; resume deletes it. Store surface: `PauseLane(site, until)` (upsert, rejecting an `until` not in the future), `ResumeLane(site)`, `PausedLanes()` for the page, `LanePausedUntil(site)` for the pass. - `runLanePass` reads its pause row first, ahead of the refusal check: when paused it records the Lane state and sleeps until the expiry. Its Series stay due and unstamped — the identical state a missing browser leaves them in, so nothing new has to handle it, and the queue is waiting intact when the pause lifts. - **Expiry is mandatory**, offered as 1h / 6h / 24h. An indefinite pause is a silent outage on the one surface whose job is proving the poller is alive. - **Survives a restart**, because it is a fact about the Site rather than about the process. - **No global runtime pause.** `LATEST_CHAPTER_POLL_ENABLED` (main.go:98) stays the only whole-poller stop and still needs a redeploy; the case for a runtime one is the case where you already have a shell. - **Acquisition is unaffected.** `Acquirer.Acquire` (acquire.go:67) is its own path — a Reader's first bookmark of a Series on a paused Site still reads the page. Pause governs the Lane only. ### Routes and UI, extending #115's map - `POST /admin/series/{key}/poll` → `ForceSeriesPoll`, answering with the swapped Series-row fragment. **Both surfaces carry the button**, from one handler and one fragment: the Series list (the real workflow is arriving from a hygiene count into a filtered list) and the detail page, which embeds the same fragment. - `POST /admin/lanes/{site}/pause` (duration from the form) and `POST /admin/lanes/{site}/resume`, both answering with the swapped lanes block. - Label **"Check now"**, not confirm-gated — nothing is destroyed and no row leaves the list, so Cinder's confirm rule does not apply. Pending renders as `check requested <age> ago`, with **no ETA**: the page would have to guess the Lane's next wake, and that guess is wrong on a browser Site whenever the home machine is off. The button is not offered at all for a Series with no `series_url` or with zero Readers, rather than offered and quietly ineffective. - Pause: a duration picker plus **Pause** per Lane row, **Resume** on a paused row, neither confirm-gated — picking a duration is already deliberate, and the state is visible, bounded and instantly reversible. - **The Lanes page reads the pause rows straight from the database**, not through `LaneState`. Memory is empty after a restart while a pause survives one, so a memory-only pause would render a genuinely paused Site as "no data yet". This does not break #115's split rule — that rule forbids showing the same figure from two sources, and pause has no in-memory twin. - Accent: the existing `.attention` class. Never `--ember` (New Chapter only), never `--danger` (destruction). ### Pushed onto other tickets - **#116**: `SeriesRow` projects `force_poll_at` and exposes the derived pending bool. The `unchecked` and `stale` filters are untouched, because force never writes `latest_checked_at`. - **#122**: the prototype now covers a Series row with *Check now* plus an ageing pending marker, and a Lane row with a duration picker, *Pause* and *Resume*. - **#121**: a Forced Poll re-judges any outstanding Sighting through `judgeSighting` for free, so "check now" is also the manual remedy for a suspicious Latest Chapter and can mark the Reader who raised it. Whether the correction UI advertises it that way is #121's call. - Fog cleared: *whether a paused Lane survives a backend restart, and where that state lives* — yes, a `poll_lanes` row. - An ADR is worth writing when this is implemented (the through-the-database seam plus the queue-jump rules), not now: this map produces the spec. Glossary: CONTEXT.md gains **Forced Poll** and **Paused Lane**. ### Rejected - **`latest_checked_at = 0` as the force signal** — zero migration, but it corrupts the never-checked and stale counts the landing page exists to show, and makes a pending marker impossible. - **A `poll_commands` table** — an audit trail in disguise, and whether admin actions need one is fog hanging on #117. - **Indefinite pause**, and a `site = '*'` pseudo-row for a global one — a second meaning for the primary key of a six-row table.
Author
Owner

Amendment from #117 (closed):

  • poll_lanes becomes the per-Site Lane state row, not a pause-only row. #117 persists
    refusal as poll_lanes.refuse_until (migration 0014), and a refusing-but-unpaused Lane needs a
    row — so "a row exists only while paused; resume deletes it" cannot survive. Instead:
    paused_until and refuse_until both default 0, ResumeLane zeroes paused_until rather
    than deleting the row, and PausedLanes() filters paused_until > now. One row read at the top
    of a pass now serves both gates.
  • LaneReporter and LaneStatus() are deleted. This ticket kept them as the single read-only
    method so the admin page stayed testable with no poller running; #117 reaches the same goal with
    no interface at all, because the store becomes the source of Lane state — an admin test inserts
    a pass row instead of constructing a fake. The Lanes page reading pause straight from the
    database (already decided here) generalises to everything on it.
  • skip = 'paused' is #117's Lane Pass record of the pause exit specified here, so a paused
    Lane renders as paused rather than as a Lane with nothing due.
Amendment from #117 (closed): - **`poll_lanes` becomes the per-Site Lane state row, not a pause-only row.** #117 persists refusal as `poll_lanes.refuse_until` (migration 0014), and a refusing-but-unpaused Lane needs a row — so "a row exists only while paused; resume deletes it" cannot survive. Instead: `paused_until` and `refuse_until` both default 0, `ResumeLane` **zeroes** `paused_until` rather than deleting the row, and `PausedLanes()` filters `paused_until > now`. One row read at the top of a pass now serves both gates. - **`LaneReporter` and `LaneStatus()` are deleted.** This ticket kept them as the single read-only method so the admin page stayed testable with no poller running; #117 reaches the same goal with no interface at all, because the store becomes the source of Lane state — an admin test inserts a pass row instead of constructing a fake. The Lanes page reading pause straight from the database (already decided here) generalises to everything on it. - **`skip = 'paused'`** is #117's Lane Pass record of the pause exit specified here, so a paused Lane renders as paused rather than as a Lane with nothing due.
Author
Owner

Amendment from #124 (closed) — one of your three override cases is replaced:

  • Your HAVING shape is void. HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0 OR s.force_poll_at > s.latest_checked_at) disappears entirely: #124 deletes the finished Lifecycle bucket, so the plain JOIN bookmarks is the whole "someone holds it" test. The gate moves into WHERE as (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at), and s.finished_at joins the GROUP BY list next to s.force_poll_at.
  • "The finished-only bucket" is now "a Finished Series" (series.finished_at, migration 0016, owner-written). Your rationale carries over word for word — the owner asking is evidence someone cares about a Series the owner shelved rather than one every Reader shelved. Nothing else in your never-overrides list changes.
  • EligibleSeriesCount gains s.finished_at = 0 and deliberately no force clause. That count is the pace divisor, so admitting a forced Series would make one press speed up every other fetch on the Site. The asymmetry between the two queries is intentional and load-bearing.
  • A forced pass never clears finished_at. Your self-clearing story is unchanged for force_poll_at itself — checkOne's stamp-before-fetch still ends the pending marker — but the finish survives, because at stamp time nothing has been read yet. Un-finishing is a separate owner action on the Series detail page.
  • Consequence for your nothing-eligible interaction with #117: a Site whose Series are all finished counts 0 eligible and logs the skip. A Lane that declined and said why, not a stall.
Amendment from #124 (closed) — one of your three override cases is replaced: - **Your `HAVING` shape is void.** `HAVING (COUNT(*) FILTER (WHERE b.status <> 'finished') > 0 OR s.force_poll_at > s.latest_checked_at)` disappears entirely: #124 deletes the finished Lifecycle bucket, so the plain `JOIN bookmarks` is the whole "someone holds it" test. The gate moves into `WHERE` as `(s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)`, and `s.finished_at` joins the `GROUP BY` list next to `s.force_poll_at`. - **"The finished-only bucket" is now "a Finished Series"** (`series.finished_at`, migration 0016, owner-written). Your rationale carries over word for word — the owner asking is evidence someone cares about a Series the *owner* shelved rather than one every Reader shelved. Nothing else in your never-overrides list changes. - **`EligibleSeriesCount` gains `s.finished_at = 0` and deliberately no force clause.** That count is the pace divisor, so admitting a forced Series would make one press speed up every other fetch on the Site. The asymmetry between the two queries is intentional and load-bearing. - **A forced pass never clears `finished_at`.** Your self-clearing story is unchanged for `force_poll_at` itself — `checkOne`'s stamp-before-fetch still ends the pending marker — but the finish survives, because at stamp time nothing has been read yet. Un-finishing is a separate owner action on the Series detail page. - Consequence for your `nothing-eligible` interaction with #117: a Site whose Series are all finished counts 0 eligible and logs the skip. A Lane that declined and said why, not a stall.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#119