Conversation
Written from a production deployment that got every one of these wrong first. Each entry is a defect that shipped, with the measurement that found it. New `docs/guides/sweeps.md` — the page/fan-out/recurse pattern for enumerating a population: keyset paging over a column the walk does not write, the cursor-advance ordering, per-item isolation boundaries, and idempotency BY ROLE. That last table is the one that cost the most to learn: dedup has no status predicate, so a recursive successor must carry neither `unique_for` nor `identity_key` (a terminal row from an earlier link silently swallows the next one, and the chain ends at link two), while the cron root wants both. The alternative -- a capped batch per tick -- drained 500 rows/hour against a fan-out that could do 900, so a 6,000-row population took 12 hours and any failure restarted it. troubleshooting.md gains entry 19, `job_events`/`job_attempts` growing without bound. A denied reservation writes four durable rows per cycle and consumes no retry budget (`mark_snoozed` assigns no `attempt`), so without `schedule_to_close` it can be denied forever -- and `prune` cannot reach the rows, because a looping job is never terminal and has `finished_at` reset to NULL on every denial. Measured: 2,074,421 denials against 168,963 successes, 2.5 GB, 32% of the database. The fix is tier separation by what a job OCCUPIES: 19.4x oversubscription to 1.60x took 1.06M denials/day to 6,341 (167x). The bound that actually matters is a BLOCKING rate limiter -- an actor that sleeps inside the job holds its slot and cannot be denied; one that releases and re-queues is what mints them. Also: cron.md (start-if-not-running roots), retries.md (Snooze and RetryAfter(consume_budget=False) as the 429 answer, so nobody writes a custom classifier), workers.md (the workgroup `--max-concurrency` argv override, which outranks env and is invisible to the other three concurrency layers), jobs-clients.md, ops.md. `docs/review/2026-09-12-api-ergonomics-review.md` records the API friction behind these defects, for maintainer triage rather than as a change request.
ruff format applies to fenced python in markdown, and CI runs `ruff format --check .` over the whole tree. Comment alignment and one signature wrap only -- no prose or snippet semantics changed.
|
Sequencing note (from a full stack audit): this PR is complementary to the stack (#120 ← #148 ← the third layer) — no duplication — but it touches six files the stack also changed (cron.md, jobs-clients.md, ops.md, troubleshooting.md, workers.md, mkdocs.yml), so textual conflicts are certain, and several passages document pre-fix behavior the stack changes: troubleshooting's denial-churn entry describes exactly what #139 (in flight on the third layer) removes, and jobs-clients.md's 'the collision is silent and successful' text is contradicted by #140's landed dedup observability. Recommendation: hold until the stack merges, rebase onto main, re-verify those passages against post-fix behavior, then land as the docs layer. Also: this PR's review doc is the source of #140–#146; #142–#144 and #146 had not carried their verdicts into the tracker until today (now assigned). |
|
Post-stack review complete (two-agent audit against the fully-stacked state, Mechanical: rebasing this PR onto the post-stack main is conflict-free (rehearsed — both commits replay clean; every hunk pair is disjoint). The risk was never git — it was semantics: this PR's docs describe pre-stack behavior the stack changed. Every factual contradiction found and reworked (branch
Verification: Landing sequence (per the audit): wait for the in-flight #139 SQL on #170's branch and the stack's merge (this PR's §19 contract framing matches upgrading.md's declared contract either way) → merge post-stack main into this branch (rehearsed conflict-free; a merge keeps the push a fast-forward) → push. The reworked branch is staged locally and ready. |
Written from a production deployment that got every one of these wrong first. Each entry is a defect that shipped, with the measurement that found it.
New:
docs/guides/sweeps.mdThe page/fan-out/recurse pattern for enumerating a population — keyset paging over a column the walk does not write, cursor-advance ordering, per-item isolation boundaries, and idempotency by role.
That last table was the expensive one. Dedup is
ON CONFLICT (idempotency_scope, idempotency_key) DO NOTHINGwith no status predicate, and theunique_forpreflight only runs when bothunique_forandidentity_keyare present. So:idempotency_keyunique_for+identity_keyA successor carrying either one dedups against its own still-
runningparent and the chain ends at link one. A root without them stacks a fresh chain per tick.The alternative we shipped first — a capped batch per cron tick — drained 500 rows/hour against a fan-out completing 900/hour, so a 6,000-row population took 12 hours and any failure restarted it from the head.
New:
troubleshooting.mdentry 19 — unboundedjob_events/job_attemptsA denied
ConcurrencyReservationwrites four durable rows per denial cycle (~1,193 bytes), and two properties make that unbounded:mark_snoozed'sSETlist contains noattemptassignment;attemptadvances only via the dispatch lease, so both sides ofattempt < max_attemptsmove together and the failure gate is unreachable. The only terminal exit isdeadline_failed, gated onschedule_to_close— without it, a job can be denied forever. One job reachedattempt=1015/1018over 6h12m.prunecannot reach the rows. A looping job is never terminal and hasfinished_atreset toNULLon every denial, so the FK cascade never fires.job_eventshas no archive table and appears in no expiry CTE — whilejob_attemptsis archived.Measured in production: 2,074,421 denials against 168,963 successes (12.3:1), 2.5 GB across the two tables, 32% of a 7.85 GB database.
The fix is tier separation by what a job occupies: 19.4× oversubscription → 1.60× took 1.06M denials/day → 6,341/day (167×), 1.27 GB/day → ~8 MB/day. The bound that decides it is a blocking rate limiter — an actor that
await asyncio.sleep()s inside the job holds its slot and cannot be denied; one that releases and re-queues is what mints denials.Additions to existing guides
Snooze/RetryAfter(consume_budget=False)as the 429 answer, so nobody writes a custom classifier for it--max-concurrencyargv override, which outranks env and is invisible to the other three concurrency layersdocs/review/2026-09-12-api-ergonomics-review.mdThe API friction behind these defects, for maintainer triage — not a change request.
Docs only; no library code touched.