Files
alkstore/docs/architecture/decisions/010-queue-semantics-depth.md
T
glm-5.3-flash d401908f13 docs: Phase 1 review round — wake wording honesty, job-handle validity predicate, outbox/streams depth OQs
- C2: 'at-least-once wake delivery' collapsed to 'best-effort hints'
  (ADR-006 §1 + core-contract) — coalescing and the pg no-replay hole
  contradict per-commit wake promises; the guarantee table's row was
  already correct.
- W2: uniform job-handle validity predicate pinned (ADR-010 §2,
  core-contract, queues.md; verification-backlog row): processing
  state + unexpired deadline; D-12's missing-check not inherited.
- W1: outbox run_once worker semantics pinned in core-contract
  (pull op, ack/retry-on-curve, no heartbeat in delivery — honker
  parity, dual-execution window documented).
- W3: named-locks tx-seam posture stated (no lock_tx; acquisition is
  auto-commit; TTL discipline governs).
- C1 -> OQ-12: streams depth (key semantics w/ honker ground, event
  shape, ordering row, retention); method names pinned, depth open.
- New find -> OQ-13: the v1 TxHandle surface cannot express the
  transactional outbox enqueue (derived backing-queue name is
  reserved-prefix-rejected; honker's raw-Transaction seam unavailable);
  option set + decision rule sketched.
- README resolution order refreshed (OQ-06 resolved); ScheduleOpts +
  stream consumption-trigger lines added to core-contract.
2026-10-05 12:50:33 +00:00

385 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-010: Queue semantics depth — visibility, retry/backoff, dead-letter, sweep, layout
## Status
Accepted (2026-10-05, Phase 1 — OQ-05's resolution; composes with
[ADR-009](009-scheduler-collapse.md))
## Context
Contract v1 pinned the queue *skeleton* ([ADR-008](008-contract-v1-pinning.md)
§1) and explicitly left the semantics depth to OQ-05: retry policy
shape, visibility/renewal mechanics, dead-letter move-vs-flag and
retention, sweep/maintenance design, the result-storage cut-flag's
disposition, and queue/stream/lock **table** layout (the co-tenancy
collision surface, [queues.md](../queues.md)).
The evidence base: honker's queue machinery (the SQLite-side incumbent
— `/workspace/honker` @ f4e53c6, whose functions the engine rides
directly) and the pg-boss family (the pg-side design reference —
`/workspace/pgboss-rs` @ 98f7d9e standing in for node pg-boss v10;
design-reference only per [ADR-005](005-dependency-ownership.md)).
Both models were read in full for this resolution (reference notes:
`docs/research/reference-honker-machinery.md`,
`docs/research/reference-pgboss-rs-semantics.md`). The hard
driver-coupled properties (transactional enqueue, exactly-once claim)
are already POC-pinned on both engines.
The two references disagree on several semantics points, and honker
has real gaps (a dead-letter table `get_job` can't see; expired
processing rows no path can reach — the "zombie" hole; no dead-row
retention mechanics; no backoff in the core at all). OQ-05 is the
place to fix what re-derivation should fix and inheriting should
inherit.
## Decision
### 1. The job state machine: three states, delete-on-ack
Contract job states: **`pending` → `processing` → `dead`** (+ absence:
`ack` and `cancel` delete the row).
- Honker's model, adopted whole — one live table, move-to-dead
(physical row move, savepoint-guarded), not the pg-boss seven-state
enum. `max_attempts` is frozen at enqueue; `attempts` counts
every claim.
- **`ack` deletes** (honker parity): completed work leaves no row.
The completed-job-with-output model (pg-boss's `completed` state +
`output` column) is **not** adopted — it is result storage under
another name, and result storage is a cut-flag row
([ADR-002](002-feature-scope.md)); see §7.
- **`get_job` sees dead jobs.** The returned job carries state,
payload, attempts, priority, and the timestamps `created_at`,
`run_at`, `claimed_at`, `expires_at` — plus, dead-only, `last_error`
and `died_at` (no job = `None`). This deliberately *narrows* honker's
surface (honker's `get_job` reads only live rows — post-mortem
diagnosis was SQL-only); diagnosis-by-API is the honest fix, and it
is what makes move-to-dead (vs flag-in-place) inspectable without
raw SQL.
- **`cancel` is unconditional** (honker parity): it deletes the row in
either `pending` or `processing`, regardless of which worker holds
it. Not an interrupt — the holder's next ack/heartbeat returns
false, the same shape as expiry. `ack_batch` is the batch form of
ack (count returned; non-claimed ids silently not counted).
- Claim ordering (same queue): `priority DESC`, then ready-time
(`run_at`) ascending, then enqueue order — FIFO under equal
priority, both references agree; pinned.
### 2. Visibility timeout and heartbeat: explicit renewal, late-heartbeat refusal
One answer, contract-pinned (honker's design; pg-boss's
no-renewal/expire-sweep model is not inherited):
- `visibility_timeout_s` is a per-job stamped value (see §3a's
resolution rule — default **300 s**, honker parity); each claim sets
the row's deadline from the job's stamp.
- **`heartbeat(extend)` is renewal** — an absolute reset of the
claim deadline from now. There is no progress-signal meaning; a
consumer signaling progress uses its own channels. Renewal cadence
is the consumer's obligation: handlers that might outlive the
visibility timeout must heartbeat inside it.
- **A late heartbeat is refused** (deadline already passed ⇒ returns
false): it can never steal the job back from a reclaimer. The
consequence is the honest at-least-once window — between deadline
lapse and another worker's reclaim, the original worker may still
complete: dual-execution is possible and idempotence is the
consumer's job (matches the locks row's silent-expiry honesty,
[ADR-008](008-contract-v1-pinning.md) §7).
- **A reclaim consumes an attempt** (honker's counting): a claim is
an attempt, whether fresh or a visibility reclaim. Documented
explicitly as the contract's counting rule — the footgun is stated,
not discovered: a handler that forgets to heartbeat looks like a
repeatedly-failing job and dead-letters on budget exhaustion.
- Deadline lapse itself is lazy: an expired-claim row becomes
claimable by ordinary claim (no transition fires on lapse); there
is no separate reaper for expired claims, only the budget rules
below and the no-stranded-rows sweep (§5).
- **Job-handle op validity predicate** (the D-12 disposition, stated
uniformly): every handle op (`ack`/`heartbeat`/`retry`/`fail`)
succeeds only when the row is in `processing` **and** the caller's
claim deadline has not lapsed — ADR-008 §5's false-case list
("expired, acked elsewhere, cancelled") is the same rule one
predicate-wide: deadline lapse refuses *all* ops, not just
heartbeat. The dual-execution window stays honest under this: the
original worker between lapse and reclaim may still *complete its
work* (the side effects happen), but its ack will not land and the
row is reprocessed at reclaim — at-least-once, as documented. The
reclaim wins atomically when it races the ack (one statement, same
predicate). This state check is engine-pinned in both engines'
implementations (upstream's missing-check defect class D-12 is
*not* inherited).
### 3. Retry and backoff: explicit delay or the queue's curve
- Job handle: `ack()`, `retry(err, delay)`, `fail(err)`, and
`heartbeat(extend)` — the v1 skeleton ([ADR-008](008-contract-v1-pinning.md)
§1), with `heartbeat`'s meaning pinned by §2 and `renew` remaining
the *lock*-side term ([ADR-008](008-contract-v1-pinning.md) §8's
split).
- **`retry(err, None)`** — the deferred delay — is computed by the
engine from the queue's curve; **`retry(err, Some(d))`** overrides
it. Honker's core takes a caller-supplied delay with no curve;
pg-boss computes in-engine from a base. The contract pins the
curve so both engines compute identically.
- **The curve: equal-jitter exponential**, `base * 2^(attempt-1)`
with uniform jitter over the *lower half* of the doubling range —
`delay ∈ [base·2^(a−1)/2, base·2^(a−1)]` — capped at **1 hour** (a
pinned contract constant; no `backoff_max` knob — no consumer names
one; the explicit-delay override is the escape hatch for bespoke
policies). The range, not a jitter-label, is the definition: this
is *not* AWS-canonical full jitter (uniform over `[0, cap]`), and
the formula above is what both engines compute identically. The
attempt index `a` is the value of `attempts` on the row at the
moment the retry is scheduled (post-claim count, including
reclaims). Jitter prevents sync-failure herding across a fleet;
honker's wrappers have no jitter (a spread-out fleet of failing
workers retries in lockstep) and the pg-boss family's jittered
exponential is the design adopted.
- **`QueueOpts { visibility_timeout_s, max_attempts, backoff_base_s,
dead_letter_retention_s }`**
— queue-level defaults (respectively 300 s / 3 / 5 s / none, honker
parities except retention which is ours); resolved and stamped
per §3a. `backoff_base_s` feeds the curve. This is the
`QueueOpts` depth contract v1 deliberately withheld
([ADR-008](008-contract-v1-pinning.md) §1) — pinned here as the
extension path. **Outbox**: `outbox(name)` carries no opts of its
own; its backing queue uses a *derived* QueueOpts set as engine
defaults (honker's outbox parities: visibility 60 s, max_attempts 5,
backoff base 5 s) — engine-documented, not separately configurable
in v1.
- Explicit `fail(err)` = immediate dead-letter (honker parity; it is
the "stop retrying this" operator). `retry` at exhausted budget =
dead-letter. Exhaustion via reclaim = dead-letter. *(The pre-claim
sweep of already-exhausted reclaimable rows — a laziness optimization
in both upstream lineages — is engine-side and optional; SQLite rides
the fork's re-derivation
([ADR-011](011-sqlite-substrate-fork.md)), pg re-derives it
([engine-postgres.md](../engine-postgres.md)).)*
### 3a. QueueOpts resolution: stamped at enqueue, per job
Queues are names, not registered objects — so queue-level `QueueOpts`
need a defined attachment point, and the contract pins one:
- **Every job row carries the resolved opts stamped at enqueue**:
enqueue resolves `EnqueueOpts.max_attempts` over the queue handle's
`max_attempts` (the one per-job override the v1 skeleton names), and
stamps the result — `visibility_timeout_s`, `backoff_base_s`,
`dead_letter_retention_s`, `max_attempts` — onto the job row. Claims,
heartbeats, retries, and sweeps read the **job's own stamps**, never
a live registry.
- **Why stamped**: uniform across engines (no per-queue config state
to invent on either side — SQLite needs no new table beyond honker's
columns, Postgres is job-table columns), and it makes a job's
behavior immutable and inspectable (`get_job` returns the stamps
with the rest of the row) no matter which process's handle enqueued
it. Two processes opening handles with different `QueueOpts` for the
same queue name do not fight — each enqueue stamps its own opts;
the queue name is a routing key, not a configuration owner.
- The consequence, stated: opts changes apply to *future enqueues
only*; in-flight jobs keep their stamps. This is the honest,
durable-row semantics — the same reason `max_attempts` was already
frozen at enqueue in the references.
- SQLite realization note: honker's `claim_batch` takes one uniform
`timeout_s` per call, while per-job stamps make deadlines row-local —
bridging that gap (post-claim re-stamp, per-row claiming, or another
shape) was implementation work over honker's function surface and
rode the same OQ-06 assessment as §5's zombie fix. *(Resolved
2026-10-05: the fork fired ([ADR-011](011-sqlite-substrate-fork.md));
the claim statement is re-derived with per-row visibility from the
stamps, in owned
contract-blind substrate code ([ADR-012](012-forked-substrate-design.md)
§2).) The *contract* (deadline from the job's stamp) is
engine-independent either way.
### 4. Dead-letter: move, retention = never-expire by default, no redrive API
Move-to-table (both references converge; flag-in-place is not
adopted — a dead row must not occupy the claim path's indexes):
dead rows physically move to engine-owned dead storage with
`last_error` and `died_at`. Triggers: explicit `fail`, `retry` at
budget, exhaustion-by-reclaim, and `expires` lapses (§5).
- **Retention**: dead rows live until explicitly removed by default
(honker's posture). `QueueOpts.dead_letter_retention_s` (default
`None` = forever) makes the sweep enforce per-queue dead TTL (§5) —
the retention *mechanism* is ours (honker has none); the default
stays non-magical. `last_error` source strings are contract-pinned
for the engine-triggered cases: `"max attempts exceeded"` (budget
exhaustion, any path) and `"expired"` (sweep-by-`expires`); explicit
`fail(err)`/`retry(err, …)` carry the *caller's* error string.
- **No redrive/requeue API in v1.** The recipe is pinned instead:
`get_job` (now sees dead rows, §1) + fresh `enqueue` — both
primitives already on the surface. No consumer names an API shape
for redrive; when one does, it gets a contract extension with its
own OQ.
- The engine-owned dead table/rows are storage-internal — not a
consumer namespace (no reserved-prefix question; they're not
name-addressable).
### 5. `sweep_expired`: the no-stranded-rows property
Contract semantics for `sweep_expired(queue)` (the v1 skeleton's
maintenance entry point):
- It moves to dead (`last_error = 'expired'`) **every row of the
queue past its `expires_at` in any state** — pending rows (honker
parity) *and* processing rows whose job-level expiry passed. This
is the **no-stranded-rows property**: an enqueued job with an
`expires` deadline is eventually in exactly one of pending,
processing, or dead — never stuck unreachable. Honker fails this
property: an expired *processing* row whose worker died is
unreachable by claim (predicate requires future expiry), by
pre-claim dead-lettering (same), and by `sweep_expired` (pending
only) — the zombie hole, a real defect found in the reference read
(D-6 in the quality read's register). This ADR fixes it at the
contract level; the Postgres engine
enforces it directly; the SQLite engine's realization was
implementation work **contingent on OQ-06** — *(resolved
2026-10-05: the fork fired ([ADR-011](011-sqlite-substrate-fork.md))
and the both-states sweep lands in owned code.)*
- When `dead_letter_retention_s` is set, the same sweep also deletes
dead rows past their retention (the only sweeper dead rows ever
have — nothing runs without a caller).
- Sweep is single-statement atomic per queue (SQLite: writer
serialization; Postgres: row-locked `UPDATE … RETURNING`); **no
leader lock is required** for a bare `sweep_expired` call — the
multi-process recipe gets its coordination from §6's machinery, not
from the sweep itself.
### 6. Sweep/maintenance cadence: no ambient sweeper; the collapse recipe
- **The engine ships no background sweeper, no maintenance thread, no
default cadence** — the family's no-ambient-timers posture
(alkblobs ADR-005) and honker's own verified posture (nothing in
honker-core auto-runs; the only implicit maintenance is laziness in
claim paths). The engine's *responsibility* is confined to
correctness-of-transition (§1–§5); **timeliness is the
consumer's**.
- The pinned recipe for the family-wide "who sweeps" problem is the
collapse machinery ([ADR-009](009-scheduler-collapse.md)):
`store.schedule("maintenance", "@every 300s", maintenance_queue, …)`
+ `run_schedules` (leader-elected) + a worker whose handler calls
`sweep_expired` per queue. One schedule row, one worker — the
pattern every consumer was going to hand-roll, answered twice in
the contract instead of N times above it.
- Dead-letter and notifications-table hygiene (SQLite engine):
`prune_notifications`/`prune_notifications_keep_latest` remain
**out of the contract** (ADR-008 §8's disposition, resolved here by
disposition): the notifications table is the SQLite wake
mechanism's *transport* detail — consumers interact with wakes, not
rows. Its hygiene is engine-internal: **an at-attach pruning cap
(engine opts)** — the mechanism the fork scope realizes
([ADR-011](011-sqlite-substrate-fork.md)); no cadence, ambient or
engine-side, contradicts this section's no-ambient-sweeper bullet.
The fix for upstream's unbounded notifications growth must not become
a consumer's chore. *(Annotated 2026-10-05: the earlier "engine-side
maintenance cadence" wording was wrong — nothing in the fork scope
realizes a cadence; the at-attach cap is the pin.)*
### 7. Result storage: cut-flag stands
Reconsidered per OQ-05's mandate and **left cut**: no consumer row
names outcome-query-by-id; the §1 delete-on-ack decision keeps
completed jobs out of storage entirely (the pg-boss `output`/completed
-row model is that feature under another name — adopting it would
silent-include the cut flag); the documented workaround (a job writes
its own result record atomically with ack) rides the tx seam
([ADR-007](007-transactional-seam.md)) cleanly: business row + result
write + ack-equivalent in one transaction. Re-entry stays gated on a
consumer-inventory row.
### 8. Table layout: engine-owned schemas/tables; queues are rows, not tables
- **Postgres**: all engine-owned tables (job, dead, stream log,
offsets, schedule rows, internal state) live in one **engine-owned
PostgreSQL schema** — default `alkstore`, overridable per-engine
option — co-tenanted safely with consumer tables (alkblobs ADR-008's
co-tenancy precedent; schema scoping is the collision answer, and
the reserved *name* namespace ([ADR-008](008-contract-v1-pinning.md)
§4) governs the name column values inside it). Queue names become
row values in one job table — **no per-queue tables, no per-queue
schemas** (pg-boss's partition-per-queue opt-in is not inherited;
at this scale one table + partial indexes matches honker's proven
shape and keeps `queue(name)` a name, not a DDL operation — queue
creation is not registry-gated, per
[ADR-009](009-scheduler-collapse.md) §5). Per-queue config storage
is likewise stamped per §3a.
- **SQLite**: honker's `_honker_*` table family in the caller's
database file — storage-internal per
[ADR-008](008-contract-v1-pinning.md) §4. *(Annotated
2026-10-05: the expectation that job-stamped opts would ride honker's
existing columns with no schema change was falsified by the quality
read — no stamp columns exist in the upstream schema (D-7). The
family's fate is resolved: the fork ([ADR-011](011-sqlite-substrate-fork.md))
re-owns the whole family as `__alkstore_*`, and the stamp columns +
`claimed_at` are added by the re-derivation on contract v1
([ADR-012](012-forked-substrate-design.md) §5). Nothing
consumer-visible changes.)*
### 9. Error taxonomy: no delta
The depth adds **no new error variants** — checked against
[ADR-008](008-contract-v1-pinning.md) §5's act-differently rule: no
work claimed is a value; deadline operations are booleans; dead-letter
is observable state (`get_job`), not an error thrown; queues are
unregistered names, so a payload enqueued to a queue nobody claims
yet is not an error (it's durable work awaiting a worker).
`InvalidSpec { spec }` was the scheduler's one variant
([ADR-009](009-scheduler-collapse.md) §6) and is the only taxonomy
delta this track produces.
## Consequences
**Positive**
- The queue mechanism is fully specified end-to-end: both references'
semantics decisions are made once, pinned identically on both
engines, with the references' gaps (zombie rows, dead-job
invisibility, no retention mechanics, no jitter) fixed at the
contract level rather than inherited.
- The counting/heartbeat rules and the opts-stamping resolution are
stated as contract text — the honest at-least-once story
(dual-execution window, reclaim-eats-budget) is documented consumer
obligation, not per-engine surprise, and per-queue configuration
never fights across processes.
- One engine-owned pg schema keeps co-tenancy mechanical.
**Negative**
- Three-state + delete-on-ack means no completion history and no
built-in outcome storage — by design (the cut-flag stands, §7);
consumers needing audit trails build them on the tx seam.
- Honker's zombie fix, dead-row `get_job` visibility, and per-job
visibility stamps over the uniform-claim-timeout function surface
required either an over-machinery complement or a fork on the SQLite
side — resolved by the fork ([ADR-011](011-sqlite-substrate-fork.md),
designed in [ADR-012](012-forked-substrate-design.md)).
- The backoff curve's 1-hour cap and jitter formula are pinned
constants — a consumer needing a different policy uses
`retry(err, Some(d))` per attempt (correct, but manual).
## References
- OQ-05 (`docs/architecture/open-questions.md`) — this ADR's
resolution (with OQ-09, resolved by [ADR-009](009-scheduler-collapse.md)).
- `docs/research/reference-honker-machinery.md` and
`docs/research/reference-pgboss-rs-semantics.md` — the full
reference reads (schemas, state machines, defect list with
file/line cites) this resolution was made over.
- [ADR-002](002-feature-scope.md) — scope (cut-flags §7 stands),
[ADR-005](005-dependency-ownership.md) — the design-reference
postures the two reads serve,
[ADR-006](006-wake-and-delivery-contract.md) — the queues guarantee
row this ADR's §2/§3 make precise,
[ADR-007](007-transactional-seam.md) — the tx seam §7's workaround
rides,
[ADR-008](008-contract-v1-pinning.md) — the v1 skeleton this ADR
extends, §5's rule §9 applies, §8's dispositions §6 resolves.
- [ADR-009](009-scheduler-collapse.md) — the collapse machinery §6's
recipe composes with.
- OQ-06 — the SQLite-side fork candidates (§5, §3a realization note) —
resolved by the fork ([ADR-011](011-sqlite-substrate-fork.md),
designed in [ADR-012](012-forked-substrate-design.md)).
- [queues.md](../queues.md), [core-contract.md](../core-contract.md),
engine specs — the specs carrying this depth.