Pause and resume one Site's Lane, with a mandatory expiry #147

Closed
opened 2026-08-21 16:39:25 +07:00 by sulthan · 1 comment
Owner

Parent

Spec #134.

What to build

If a Site is being hammered or is misbehaving, the owner currently has no way to stop its Lane short of a redeploy with the whole poller switched off. This ticket lets them stop asking one Site without stopping the other five. Like Check now, it writes a row the poller reads on its next pass and never commands the poller, so it survives a restart -- a pause is a fact about the Site, not about the process.

The pause row is read at the top of a pass, ahead of the refusal check. A paused Lane records its pass with the paused skip value 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 intact when the pause lifts: a resumed Lane finds its full queue waiting, because a pause delays work rather than discarding it.

Expiry is mandatory, offered as 1h / 6h / 24h. A pause with no expiry is a silent outage left behind on the one surface whose job is proving the poller is alive. No global runtime pause: the deploy-time poll switch stays the only whole-poller stop, and the case for a runtime one is the case where you already have a shell. Rejected: an indefinite pause, and a wildcard pseudo-row for a global one (a second meaning for the primary key of a six-row table).

Acquisition is unaffected. A Reader's first bookmark of a Series on a paused Site still reads the page -- pausing a Lane must never break somebody adding a Series. Pause governs the Lane only.

On the Lanes page a pause renders as paused, not as stalled -- the owner's own act must not be reported back as a fault -- in patina, as a paused-and-resumes-in phrase, because a deliberate pause is not trouble. The duration select and the pause action live in one bar, and resume replaces them in the same slot. Both actions answer with the freshly rendered Lanes block.

Acceptance criteria

  • Pausing a Site with each offered duration writes a future expiry and answers with the freshly rendered Lanes block
  • A pause with no duration, or one not in the offered set, is rejected
  • A paused Lane makes no fetch, records its pass with the paused skip value, and leaves its Series due and unstamped
  • The pause is read ahead of the refusal check
  • A paused Lane survives a freshly constructed poller and stays paused until its expiry
  • The Lanes page renders a paused Lane in patina as paused with its remaining time, never as stalled or as trouble
  • Resume zeroes the pause and the Lane's next pass finds its full queue waiting
  • A Reader's first bookmark of a Series on a paused Site still reads the page
  • Form bodies on both action routes are capped the way the API path caps them, and both routes join the route list rather than gating inside themselves
  • go test ./... green

Blocked by

  • #141 — The poller records one pass row per exit, with a skip reason and outcome counts
  • #145 — Lanes page reads the database, and the in-memory Lane state is deleted
## Parent Spec #134. ## What to build If a Site is being hammered or is misbehaving, the owner currently has no way to stop its Lane short of a redeploy with the whole poller switched off. This ticket lets them stop asking one Site without stopping the other five. Like *Check now*, it writes a row the poller reads on its next pass and never commands the poller, so it survives a restart -- a pause is a fact about the Site, not about the process. The pause row is read **at the top of a pass, ahead of the refusal check**. A paused Lane records its pass with the paused skip value 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 intact when the pause lifts: a resumed Lane finds its full queue waiting, because a pause delays work rather than discarding it. **Expiry is mandatory**, offered as 1h / 6h / 24h. A pause with no expiry is a silent outage left behind on the one surface whose job is proving the poller is alive. **No global runtime pause**: the deploy-time poll switch stays the only whole-poller stop, and the case for a runtime one is the case where you already have a shell. Rejected: an indefinite pause, and a wildcard pseudo-row for a global one (a second meaning for the primary key of a six-row table). **Acquisition is unaffected.** A Reader's first bookmark of a Series on a paused Site still reads the page -- pausing a Lane must never break somebody adding a Series. Pause governs the Lane only. On the Lanes page a pause **renders as paused, not as stalled** -- the owner's own act must not be reported back as a fault -- in patina, as a paused-and-resumes-in phrase, because a deliberate pause is not trouble. The duration select and the pause action live in one bar, and resume replaces them in the same slot. Both actions answer with the freshly rendered Lanes block. ## Acceptance criteria - [ ] Pausing a Site with each offered duration writes a future expiry and answers with the freshly rendered Lanes block - [ ] A pause with no duration, or one not in the offered set, is rejected - [ ] A paused Lane makes no fetch, records its pass with the paused skip value, and leaves its Series due and unstamped - [ ] The pause is read ahead of the refusal check - [ ] A paused Lane survives a freshly constructed poller and stays paused until its expiry - [ ] The Lanes page renders a paused Lane in patina as paused with its remaining time, never as stalled or as trouble - [ ] Resume zeroes the pause and the Lane's next pass finds its full queue waiting - [ ] A Reader's first bookmark of a Series on a paused Site still reads the page - [ ] Form bodies on both action routes are capped the way the API path caps them, and both routes join the route list rather than gating inside themselves - [ ] `go test ./...` green ## Blocked by - #141 — The poller records one pass row per exit, with a skip reason and outcome counts - #145 — Lanes page reads the database, and the in-memory Lane state is deleted
sulthan added the ready-for-agent label 2026-08-21 16:39:25 +07:00
sulthan self-assigned this 2026-08-22 01:05:44 +07:00
Author
Owner

Merged into spec-134 (branch ticket/147-lane-pause, commit cbe0a28).

Pause and resume land as POST /admin/lanes/{site}/pause and .../resume, both in the route list rather than gating inside themselves, both body-capped, both answering with the freshly rendered Lanes block. Expiry is mandatory and validated against a fixed 1h/6h/24h allow-list; a missing or unoffered duration is a 400, as is an unknown Site. The duration select and Pause live in one bar and Resume replaces them in the same slot.

The poller side needed no change: #141's gate already reads the pause ahead of the refusal check. It is now under test - a Lane both paused and inside a refusal backoff records the paused skip value, not the refusing one. Also tested: a paused Lane makes no fetch and leaves its Series due and unstamped, a freshly constructed poller still sees the pause, resume restores the full queue, and a Reader's first bookmark of a Series on a paused Site still reads the page.

Security invariants preserved: parameterised SQL only, MaxBytesReader on both form bodies, owner gate in adminRoutes(), generic client errors with detail logged.

Review: both axes clean, no findings on the spec axis.

Two non-blocking observations from the implementer, both inherited rather than introduced:

  • a paused Site with no pass row yet renders no row and so no control, because the Lanes page lists Sites that have passed (from #145);
  • a resumed Lane whose last recorded pass was the paused one shows a transient 'resumes in soon' until its next pass records.

go test ./... green on the merged base.

Merged into `spec-134` (branch `ticket/147-lane-pause`, commit cbe0a28). Pause and resume land as `POST /admin/lanes/{site}/pause` and `.../resume`, both in the route list rather than gating inside themselves, both body-capped, both answering with the freshly rendered Lanes block. Expiry is mandatory and validated against a fixed 1h/6h/24h allow-list; a missing or unoffered duration is a 400, as is an unknown Site. The duration select and Pause live in one bar and Resume replaces them in the same slot. The poller side needed no change: #141's gate already reads the pause ahead of the refusal check. It is now under test - a Lane both paused and inside a refusal backoff records the paused skip value, not the refusing one. Also tested: a paused Lane makes no fetch and leaves its Series due and unstamped, a freshly constructed poller still sees the pause, resume restores the full queue, and a Reader's first bookmark of a Series on a paused Site still reads the page. Security invariants preserved: parameterised SQL only, `MaxBytesReader` on both form bodies, owner gate in `adminRoutes()`, generic client errors with detail logged. Review: both axes clean, no findings on the spec axis. Two non-blocking observations from the implementer, both inherited rather than introduced: - a paused Site with no pass row yet renders no row and so no control, because the Lanes page lists Sites that have passed (from #145); - a resumed Lane whose last recorded pass was the paused one shows a transient 'resumes in soon' until its next pass records. `go test ./...` green on the merged base.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#147