Third review round follow-through: enqueue_tx stamp source, sweep_expired handle form, with_tx signature, and B-block ambiguity pins
This commit is contained in:
1 parent
08dc1bf011
commit
83767e880b
11 files changed
+258
-101
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-10-07 (ADR-021 — third review round resolved)
|
||||
last_updated: 2026-10-07 (ADR-021 + third-round follow-through resolved)
|
||||
---
|
||||
|
||||
# alkstore — Architecture
|
||||
@@ -27,7 +27,7 @@ pending architecture review and OQ resolution.
|
||||
| [core-contract.md](core-contract.md) | draft | The unified trait surface, delivery guarantees, tx seam | OQ-10 (resolved) |
|
||||
| [engine-sqlite.md](engine-sqlite.md) | draft | SQLite engine: forked-substrate/rusqlite mapping | OQ-06 (resolved), OQ-12 (resolved), OQ-13 (resolved) |
|
||||
| [engine-postgres.md](engine-postgres.md) | draft | Postgres engine: tokio-postgres/LISTEN mapping | OQ-08 (resolved), OQ-12 (resolved), OQ-13 (resolved) |
|
||||
| [queues.md](queues.md) | draft | Queue/scheduler/outbox semantics depth (resolved: ADR-009/ADR-010) | OQ-06 (resolved) |
|
||||
| [queues.md](queues.md) | draft | Queue/scheduler/outbox semantics depth (resolved: ADR-009/ADR-010) | OQ-05 (resolved), OQ-09 (resolved), OQ-06 (resolved) |
|
||||
| [deployment.md](deployment.md) | draft | Host semantics, connection budgets, knobs, matrix | OQ-08 (resolved) |
|
||||
| [open-questions.md](open-questions.md) | draft | OQ tracker (promoted from OQ-ST register) | — |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-10-07 (ADR-021 — third review round: tx reads, claimed_at, schedule queue validation, drop=rollback, receiver arms)
|
||||
last_updated: 2026-10-07 (ADR-021 + third-round follow-through: enqueue_tx stamp source, sweep_expired handle form, handle-op/ack_batch/opts-type pins, with_tx signature, payload-limit basis, per-stream offsets)
|
||||
---
|
||||
|
||||
# Core contract
|
||||
@@ -91,8 +91,9 @@ pure reads (no ops ride them). Reads and saves are tx-shaped;
|
||||
claim/maintenance ops never are (no `claim_tx` — a claim's visibility
|
||||
deadline must not be tied to a business transaction's lifetime;
|
||||
deliberate absence, not an omission). **Dropping a handle without
|
||||
`commit`/`rollback` rolls it back** ([ADR-021](decisions/021-tx-reads-
|
||||
and-value-shape-fixes.md) §4) — the no-ghosts property holds through
|
||||
`commit`/`rollback` rolls it back**
|
||||
([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §4) — the
|
||||
no-ghosts property holds through
|
||||
RAII paths, not only the explicit ones.
|
||||
|
||||
`outbox_enqueue_tx` takes the **outbox name** and derives the backing
|
||||
@@ -141,7 +142,11 @@ methods — object safety of the boxed handles,
|
||||
[ADR-008](decisions/008-contract-v1-pinning.md) §2); `payload_as<T>`
|
||||
decodes on concrete returned values (`Job`, `StreamEvent`). The
|
||||
`with_tx` closure wrapper ships over the handle shape (the POC-verified
|
||||
composition direction, [ADR-007](decisions/007-transactional-seam.md)).
|
||||
composition direction, [ADR-007](decisions/007-transactional-seam.md);
|
||||
signature pinned there 2026-10-07: `with_tx(f) -> Result<()>` with
|
||||
`f: FnOnce(&mut dyn TxHandle) -> BoxedFuture<Result<()>>` — `Ok` ⇒
|
||||
commit, `Err`/drop/panic ⇒ rollback; non-generic for object safety,
|
||||
values return through captured state, no handle escapes the closure).
|
||||
Mechanism handles (`Queue`, `StreamHandle`, `Outbox`, `Lock`) are
|
||||
core-owned trait objects like the tx handle — engine types never appear
|
||||
in consumer signatures. Their trait surfaces are pinned by
|
||||
@@ -164,7 +169,10 @@ never sees it.
|
||||
|
||||
- `notify(channel, payload)` — payload ≤ 8000 bytes on Postgres
|
||||
(client-side checked, typed `PayloadTooLarge` before the round
|
||||
trip, verified by POC #2); no limit on SQLite. The error variant is
|
||||
trip, verified by POC #2; the measured quantity is the serde_json
|
||||
serialization of the payload `Value` — ADR-020 §4's stored-bytes
|
||||
form, so check and storage measure one thing); no limit on SQLite.
|
||||
The error variant is
|
||||
contract-wide (callers match it identically on both engines), the
|
||||
*occurrence* is the documented engine asymmetry
|
||||
([ADR-008](decisions/008-contract-v1-pinning.md) §5) — with no
|
||||
@@ -289,19 +297,38 @@ Durable at-least-once work
|
||||
obligations:
|
||||
|
||||
- `enqueue` / `enqueue_tx` — commit-atomic; `EnqueueOpts { delay,
|
||||
run_at, priority, max_attempts, expires }`; **option semantics
|
||||
run_at, priority, max_attempts, expires }`; **field types pinned
|
||||
(2026-10-07, third review round follow-through)**: `delay`,
|
||||
`run_at`, `expires` are `Option<i64>` (honker-rs parity,
|
||||
`packages/honker-rs/src/lib.rs:439-447` — unset is distinguishable
|
||||
from zero, and the resolution rules below are the unset behavior),
|
||||
`priority` is a plain `i64` with default **0** (the claim-order
|
||||
key; the row schema's `DEFAULT 0`), and `max_attempts` is
|
||||
`Option<i64>` with `None` = the queue-level stamp decides (the
|
||||
§3a override; no honker counterpart — our field). Every field has
|
||||
a default-resolved meaning, so all are constructor-settable with
|
||||
`..Default::default()`. Queue-level
|
||||
`QueueOpts { visibility_timeout_s, max_attempts, backoff_base_s,
|
||||
dead_letter_retention_s }` ([ADR-010](decisions/010-queue-semantics-depth.md)
|
||||
§3/§3a): plain `i64` fields (`dead_letter_retention_s:
|
||||
Option<i64>`, `None` = forever) with a `Default` providing the
|
||||
pinned defaults 300/3/5/none (honker parities except retention,
|
||||
which is ours); `ScheduleOpts { priority, max_attempts, expires }`
|
||||
types identical to their `EnqueueOpts` twins. **Option semantics
|
||||
([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md))**:
|
||||
ready time resolves `delay` → `now + delay` (wins) over `run_at` →
|
||||
literal absolute over neither → now; `expires` is relative seconds
|
||||
from enqueue resolved to an absolute row `expires_at` (`None` =
|
||||
never); the resolved values — not the raw fields — are what
|
||||
`get_job` shows. Queue-level
|
||||
`QueueOpts { visibility_timeout_s, max_attempts, backoff_base_s,
|
||||
dead_letter_retention_s }` ([ADR-010](decisions/010-queue-semantics-depth.md)
|
||||
§3/§3a) stamp onto each job row at enqueue, resolved over the
|
||||
handle's opts (auto-commit/tx paths) or the engine's derived
|
||||
defaults (outbox backing queues, scheduler boundary fires — the
|
||||
no-handle-open shapes, ADR-014/ADR-020 §3).
|
||||
`get_job` shows. The `QueueOpts` stamps land on each job row at
|
||||
enqueue (§3a), resolved per
|
||||
[ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §3 by
|
||||
call shape: the auto-commit `Queue::enqueue` (the one handle-carrying
|
||||
shape) resolves over that handle's opts; `enqueue_tx` (a
|
||||
`TxHandle` carries no queue opts) and every other no-handle-open
|
||||
path — outbox backing queues, scheduler boundary fires — stamp the
|
||||
engine's derived defaults (plain 300/3/5/none; the outbox's
|
||||
60/5/5 set).
|
||||
- `claim_one` / `claim_batch` — exactly-once handout under concurrency
|
||||
(POC-pinned on both engines); take a caller-supplied `worker_id`
|
||||
(claimant identity — consumer-local, no reserved rule, non-empty;
|
||||
@@ -312,7 +339,13 @@ obligations:
|
||||
- Job handle: `ack / retry / fail / heartbeat` on the boxed
|
||||
`JobHandle` a claim returns ([ADR-019](decisions/019-mechanism-handle-surfaces.md)
|
||||
§3 — `job(&self) -> &Job` beside the ops; one-shot `self: Box<Self>`
|
||||
ops like the tx commit). `ack` deletes the row;
|
||||
ops like the tx commit). The one-shot/repeatable split is
|
||||
deliberate: `ack`/`retry`/`fail` consume the handle (each is a
|
||||
terminal transition of the claim); **`heartbeat(self, extend)`
|
||||
takes `&self` and is repeatable** — renewal is inherently repeated
|
||||
within one claim; do not "fix" this asymmetry at implementation
|
||||
([ADR-019](decisions/019-mechanism-handle-surfaces.md) §3). `ack`
|
||||
deletes the row;
|
||||
`heartbeat(extend)` is *renewal* — an absolute reset of the claim
|
||||
deadline (extend is the new full deadline from now, **not additive**
|
||||
to elapsed time); late heartbeat refused; **a reclaim consumes an
|
||||
@@ -332,13 +365,17 @@ obligations:
|
||||
(default forever), no redrive API
|
||||
([ADR-010](decisions/010-queue-semantics-depth.md) §1–§4).
|
||||
`QueueOpts` stamp onto the job row at enqueue (§3a) — queues are
|
||||
names, not config owners. The remaining v1-skeleton ops (`ack_batch`,
|
||||
`cancel` — unconditional delete, not an interrupt) pin in
|
||||
names, not config owners. The remaining v1-skeleton ops (`ack_batch`
|
||||
— the batch ack applies the uniform predicate per id, lapsed or
|
||||
non-claimed ids silently not counted; `cancel` — unconditional
|
||||
delete, not an interrupt) pin in
|
||||
[ADR-010](decisions/010-queue-semantics-depth.md) §1.
|
||||
- `sweep_expired(queue)` — moves *every* past-expiry row (any state)
|
||||
to dead + enforces dead-letter retention — the no-stranded-rows
|
||||
- `sweep_expired()` — moves *every* past-expiry row (any state) to
|
||||
dead + enforces dead-letter retention — the no-stranded-rows
|
||||
property ([ADR-010](decisions/010-queue-semantics-depth.md) §5);
|
||||
cadence recipe in [queues.md](queues.md) (no ambient sweeper).
|
||||
the queue-scoped handle form ([ADR-019](decisions/019-mechanism-handle-surfaces.md)
|
||||
§1 — the name rides the handle); cadence recipe in
|
||||
[queues.md](queues.md) (no ambient sweeper).
|
||||
|
||||
### named locks
|
||||
|
||||
@@ -376,7 +413,11 @@ delivery callable's shape pinned by
|
||||
job, run
|
||||
the delivery closure, `ack` on `Ok`, `retry(err, None)` (the queue's
|
||||
curve) on `Err`; the consumer calls it in its own loop. **No
|
||||
heartbeat inside delivery** (honker parity, inherited deliberately):
|
||||
engine-issued heartbeat inside delivery** (honker parity, inherited
|
||||
deliberately — the helper does not auto-heartbeat on the caller's
|
||||
behalf; the boxed `JobHandle`'s own `heartbeat` remains available to
|
||||
the delivery closure, and renewal inside the deadline is the
|
||||
consumer's obligation):
|
||||
delivery slower than the backing queue's stamped visibility timeout
|
||||
(default 60 s for outbox-backed queues) can have its claim expire
|
||||
mid-delivery and be redelivered — the dual-execution window;
|
||||
@@ -563,8 +604,12 @@ before the engine specs are called `stable`:
|
||||
([ADR-010](decisions/010-queue-semantics-depth.md) §2): the
|
||||
late-heartbeat boundary (refused exactly when the deadline has
|
||||
lapsed), the post-lapse-ack-refusal (at-least-once), and the
|
||||
ack-vs-reclaim race (one wins atomically); both engines' SQL pin
|
||||
identical outcomes in the contract suite.
|
||||
ack-vs-reclaim race (one wins atomically); `ack_batch` applies the
|
||||
predicate per id (lapsed or non-claimed ids silently not counted);
|
||||
`retry`'s refusal is `false` with the row untouched; the
|
||||
engine-default strings land identically (`fail(None)` →
|
||||
`"failed"`, exhaustion → `"max attempts exceeded"`); both engines'
|
||||
SQL pin identical outcomes in the contract suite.
|
||||
- **`outbox_enqueue_tx` commit-atomicity on both engines**
|
||||
([ADR-014](decisions/014-outbox-tx-enqueue.md)) — rollback drops
|
||||
the backing-queue job row together with the business write (no
|
||||
@@ -573,11 +618,15 @@ before the engine specs are called `stable`:
|
||||
(`ReservedName`/`InvalidName`); stamped opts visible via `get_job`
|
||||
equal for both engines.
|
||||
- **Cross-engine stream equivalence** ([ADR-015](decisions/015-streams-depth.md)
|
||||
§3/§4) — publish sequence → identical offset sequence → identical
|
||||
`read_since` output order on both engines, direct and subscriber
|
||||
reads alike; keyed/unkeyed interleavings preserve global FIFO;
|
||||
`key` round-trips exactly (`None` vs `Some`), `stream`/`created_at`
|
||||
fields equal.
|
||||
§3/§4) — publish sequence → identical *(per-stream)* offset
|
||||
sequence → identical
|
||||
`read_since` output order on both engines, direct and subscriber
|
||||
reads alike (offsets are global counters engine-side — pg
|
||||
bigserial, SQLite AUTOINCREMENT — so equivalence is per-stream
|
||||
relative order, never absolute offset values); keyed/unkeyed
|
||||
interleavings preserve global FIFO;
|
||||
`key` round-trips exactly (`None` vs `Some`), `stream`/`created_at`
|
||||
fields equal.
|
||||
- **`publish_with_key_tx` commit-atomicity on both engines**
|
||||
([ADR-015](decisions/015-streams-depth.md) §2) — rollback drops the
|
||||
keyed event row with the business write (no ghost event); commit
|
||||
@@ -609,7 +658,11 @@ before the engine specs are called `stable`:
|
||||
- **Enqueue-opts resolution equivalence** ([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md)
|
||||
§1/§2/§3) — delay-over-run_at precedence, relative-expires
|
||||
resolution, and the scheduler-fired jobs' derived-default stamps
|
||||
(`get_job`-visible via the row) are identical on both engines.
|
||||
(`get_job`-visible via the row) are identical on both engines;
|
||||
plain `enqueue_tx` stamps the same plain-queue derived defaults
|
||||
(300/3/5/none) on both engines — auto-commit-handle enqueues stamp
|
||||
the handle's opts, no-handle-open shapes stamp derived defaults
|
||||
(the outbox's 60/5/5 set being the one exception, ADR-014 §1).
|
||||
- **In-tx read-your-own-writes on both engines**
|
||||
([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §1) —
|
||||
reads issued on the tx handle see the transaction's own writes
|
||||
|
||||
@@ -56,6 +56,24 @@ handle.commit() / handle.rollback()
|
||||
wrapper over* the handle shape, not instead of it (the POC verified
|
||||
the reverse composition doesn't work: a closure cannot outlive
|
||||
itself, so caching-subscriber state can't escape it).
|
||||
*(Signature pinned 2026-10-07, third review round follow-through —
|
||||
the last "shape at implementation" deferral this ADR carried:*
|
||||
```text
|
||||
store.with_tx(f) -> Result<()> // on the Store trait
|
||||
f: FnOnce(&mut dyn TxHandle) -> BoxedFuture<Result<()>>
|
||||
```
|
||||
*The trait method is deliberately **non-generic** (a `Store` trait
|
||||
object must stay object-safe, ADR-008 §2's posture) and returns
|
||||
`Result<()>`: the closure signals commit/rollback by its own result
|
||||
(`Ok` ⇒ commit, `Err` ⇒ rollback; drop/panic across the closure ⇒
|
||||
rollback, ADR-021 §4's disposition) and returns values through the
|
||||
caller's captured state (the handle never escapes the closure —
|
||||
the same box-in/box-out composition the POC's closure-scoped arm
|
||||
(b) verified works, with (a)'s preference unchanged: `with_tx` is
|
||||
the convenience, `begin_tx` the seam). The closure's future boxes
|
||||
for object safety. An engine may additionally offer a generic
|
||||
value-returning `with_tx` convenience on its *concrete* store type;
|
||||
that ergonomics layer is engine surface, not contract.)*
|
||||
- Both engines deliver the property **natively** — no emulation:
|
||||
in-tx notify/Notify delivers only at commit; rollback drops job rows,
|
||||
business rows, and notifications together (the POC property tests on
|
||||
|
||||
@@ -212,7 +212,11 @@ struct Wake { channel: String }
|
||||
the receiver's forms: `recv() -> None` means closed (source death);
|
||||
`try_recv`/`recv_timeout` return `Err(Closed)` when the source is
|
||||
closed, versus `Ok(None)` when merely no wake is pending right now
|
||||
— closed and idle are distinguishable states.
|
||||
— closed and idle are distinguishable states. *(Timeout arm pinned
|
||||
2026-10-07, third review round follow-through: `recv_timeout(d)`'s
|
||||
expiry without a wake returns `Ok(None)` — idle is the documented
|
||||
`Ok(None)` arm; `Err` on that method carries `Database` (storage
|
||||
failure) or `Closed` (source death), never a timeout signal.)*
|
||||
|
||||
### 4. Reserved namespace — exact strings
|
||||
|
||||
@@ -238,7 +242,14 @@ struct Wake { channel: String }
|
||||
consumer arguments, `subscribe(consumer)`) are *not* a reserved-
|
||||
namespace kind — they are consumer-local identifiers, not shared
|
||||
engine namespaces; no prefix rule applies (an engine may still quote
|
||||
them).
|
||||
them). *(Annotated 2026-10-07, third review round follow-through:
|
||||
the consumer-local identifier classes take the **non-empty rule
|
||||
only** — an empty stream-consumer name or empty `try_lock` owner is
|
||||
`InvalidName`, mirroring `worker_id`'s rule
|
||||
([ADR-019](019-mechanism-handle-surfaces.md) §2) — with no
|
||||
reserved-prefix rejection (they are not a shared namespace, so a
|
||||
leading `__alkstore_` is the caller's own local name, no engine
|
||||
namespace to collide with).)*
|
||||
- Per-engine internal names:
|
||||
- SQLite: honker's machinery owns two categories of internal names,
|
||||
and the contract treats them differently. Its `_honker_*` *table*
|
||||
@@ -283,7 +294,11 @@ v1 variants (guaranteed-matchable on every engine):
|
||||
- `PayloadTooLarge { limit }` — **universal variant**, produced by the
|
||||
Postgres engine's client-side check (8000 bytes, POC #2
|
||||
verified-before-round-trip) and by contract never on SQLite (no
|
||||
limit — the documented engine asymmetry). The variant lives in the
|
||||
limit — the documented engine asymmetry). The measured quantity
|
||||
*(pinned 2026-10-07, third review round follow-through)* is the
|
||||
**serde_json serialization of the payload `Value`** — the same
|
||||
byte string ADR-020 §4 pins as the stored row bytes — the check and
|
||||
the storage measure one quantity. The variant lives in the
|
||||
shared taxonomy so the limit asymmetry is visible to engine-agnostic
|
||||
code — a caller can match it without knowing which engine is behind
|
||||
the store, and the error itself documents the boundary. The
|
||||
@@ -397,7 +412,14 @@ trait EventReceiver {
|
||||
No `save_every`, no save-on-drop — checkpointing is the consumer's
|
||||
explicit call (the §1 obligation). The consumer's *name* is fixed at
|
||||
`subscribe(consumer)`; it is a consumer-local identifier (no reserved
|
||||
prefix, §4).
|
||||
prefix, §4). *(Annotated 2026-10-07, third review round follow-through:
|
||||
`EventReceiver` deliberately carries **no `recv_timeout`** — unlike
|
||||
`WakeReceiver` (§3) — because a stream consumer's steady state is
|
||||
wake-driven re-read (the queues split applies: the durable row is the
|
||||
truth, the wake is the hint) and its polling-shaped needs are already
|
||||
served by `try_recv` and the offset-anchored `read_since`; a
|
||||
bounded-wait convenience has no row naming it. Stated so the absence
|
||||
is read as design, not oversight.)*
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ Contract job states: **`pending` → `processing` → `dead`** (+ absence:
|
||||
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).
|
||||
*(Acknowledged 2026-10-07, third review round follow-through: the
|
||||
batch form applies §2's full validity predicate — processing state
|
||||
**and** unexpired claim deadline — per id, not claim-state alone;
|
||||
an id whose deadline lapsed is silently not counted, identically to
|
||||
an acked-elsewhere or cancelled id. Per-id outcomes are
|
||||
independent; partial success is ordinary.)*
|
||||
- Claim ordering (same queue): `priority DESC`, then ready-time
|
||||
(`run_at`) ascending, then enqueue order — FIFO under equal
|
||||
priority, both references agree; pinned.
|
||||
@@ -224,8 +230,10 @@ budget, exhaustion-by-reclaim, and `expires` lapses (§5).
|
||||
|
||||
### 5. `sweep_expired`: the no-stranded-rows property
|
||||
|
||||
Contract semantics for `sweep_expired(queue)` (the v1 skeleton's
|
||||
maintenance entry point):
|
||||
Contract semantics for `sweep_expired` (the v1 skeleton's
|
||||
maintenance entry point; the queue-scoped handle form — name on the
|
||||
handle — is pinned by [ADR-019](019-mechanism-handle-surfaces.md) §1,
|
||||
2026-10-06):
|
||||
|
||||
- It moves to dead (`last_error = 'expired'`) **every row of the
|
||||
queue past its `expires_at` in any state** — pending rows (honker
|
||||
|
||||
@@ -188,9 +188,16 @@ as already pinned):
|
||||
> offsets immutable and never renumbered.*
|
||||
|
||||
This is the row the contract suite pins cross-engine stream
|
||||
equivalence against: two engines, same publish sequence → same offset
|
||||
sequence → same `read_since` output order, for direct and subscriber
|
||||
reads alike. It is deliberately *not* stronger than both engines can
|
||||
equivalence against: two engines, same publish sequence → same
|
||||
*(per-stream)* offset sequence → same `read_since` output order, for
|
||||
direct and subscriber reads alike. *(Per-stream stated 2026-10-07,
|
||||
third review round follow-through: both engines' offsets are global
|
||||
counter values — pg bigserial, SQLite AUTOINCREMENT — so a fresh
|
||||
stream's first event does not carry offset 1; equivalence means the
|
||||
same publish sequence into one stream produces the same *relative*
|
||||
offset sequence per that stream, a property safe under either
|
||||
counter scheme; cross-stream offset values are engine-internal and
|
||||
never compared.)* It is deliberately *not* stronger than both engines can
|
||||
implement by a shared SQL shape (`ORDER BY offset ASC` is already
|
||||
both engines' claim path) and not weaker than the mechanism's purpose
|
||||
(replay demands monotone positions).
|
||||
|
||||
@@ -189,12 +189,23 @@ trait JobHandle {
|
||||
job(&self) -> &Job // the row, as a value
|
||||
ack(self: Box<Self>) -> bool
|
||||
retry(self: Box<Self>, err: Option<String>, delay: Option<i64>)
|
||||
// None delay = queue curve
|
||||
-> bool // None delay = queue curve
|
||||
fail(self: Box<Self>, err: Option<String>) -> bool
|
||||
heartbeat(self, extend: i64) -> bool // absolute reset
|
||||
}
|
||||
```
|
||||
|
||||
*(Annotated 2026-10-07, third review round follow-through: the
|
||||
original sketch elided `retry`'s return type — pinned `-> bool`,
|
||||
the same validity predicate as its siblings. A refused retry —
|
||||
deadline lapsed, row reclaimed/cancelled/acked elsewhere — leaves
|
||||
the handle's op **not applied**: the handle was consumed by
|
||||
`retry(self: Box<Self>, …)` regardless (caller-side), but the row
|
||||
keeps its pre-call state (still `processing`, or gone if acked/cancelled)
|
||||
with its original stamps; no dead-letter, no schedule-side delay is
|
||||
recorded. `false` = the caller's window closed, at-least-once
|
||||
redelivery governs.)*
|
||||
|
||||
— and **`get_job` returns `Option<Job>`** (pure data). The *same
|
||||
uniform validity predicate* ([ADR-010](010-queue-semantics-depth.md)
|
||||
§2 — `processing` + unexpired caller deadline) governs a
|
||||
@@ -205,9 +216,18 @@ trait JobHandle {
|
||||
because renewal is inherently repeated within one claim (the same
|
||||
one-shot/repeatable split the receiver shapes carry; do not
|
||||
"fix" the asymmetry at implementation).
|
||||
`err: Option<String>`: `None` = the engine's
|
||||
documented-default string (taxonomy-consistent: these strings are
|
||||
row content, `get_job` data — not matched error variants).
|
||||
`err: Option<String>`: `None` = the engine's
|
||||
documented-default string (taxonomy-consistent: these strings are
|
||||
row content, `get_job` data — not matched error variants).
|
||||
*(Annotated 2026-10-07, third review round follow-through: the
|
||||
default string is pinned — **`fail(None)` records `"failed"`**;
|
||||
`retry(None, …)` that exhausts the budget records
|
||||
`"max attempts exceeded"`, per
|
||||
[ADR-010](010-queue-semantics-depth.md) §4's path-based rule —
|
||||
exhaustion is engine-observed state, so the path's pinned string
|
||||
wins over the caller-None case; a successful `retry(None, …)`
|
||||
records nothing (the row returns to pending, `last_error` exists
|
||||
only on dead rows).)*
|
||||
*(Struct corrected 2026-10-07 by
|
||||
[ADR-021](021-tx-reads-and-value-shape-fixes.md) §2: `claimed_at`
|
||||
was missing from the original pinning —
|
||||
|
||||
@@ -105,6 +105,17 @@ parities: 300 s / 3 / 5 s / none), not a handle's opts and not
|
||||
- **`ScheduleOpts` field growth post-release is class 3** (opts
|
||||
struct, [ADR-017](017-contract-versioning.md) §3's exemption) —
|
||||
stated so the pricing is visible now.
|
||||
- *(Annotated 2026-10-07, third review round follow-through: §3a's
|
||||
"resolved over the queue handle's `QueueOpts`" reading applies only
|
||||
to the auto-commit `Queue::enqueue` — the one call shape with a
|
||||
queue handle in scope. Plain
|
||||
`enqueue_tx(queue_name, opts, payload)` is a no-handle-open shape
|
||||
too (a `TxHandle` never carries queue opts), so it stamps the
|
||||
engine's plain-queue defaults (300 s / 3 / 5 s / none) exactly as
|
||||
boundary fires do; the outbox's `outbox_enqueue_tx` keeps its
|
||||
60 s/5/5 derived set ([ADR-014](014-outbox-tx-enqueue.md) §1).
|
||||
Without this note the SQLite and pg implementers could legitimately
|
||||
diverge on the tx path's stamp source.)*
|
||||
|
||||
### 4. The payload encoding: serde_json serialization of the trait `Value`, byte-exactly
|
||||
|
||||
|
||||
@@ -12,6 +12,22 @@ corrections to accepted decisions found by the review — ADR-019 §1's
|
||||
tx-methods claim and ADR-019 §3's `Job` field list — corrected inline
|
||||
there, per this repo's convention that wrong statements in decisions
|
||||
are fixed where they stand, not left for readers to discover.)
|
||||
*(Follow-through 2026-10-07, same round, second session: the review's
|
||||
remaining ambiguities — all decidable from decided material — were
|
||||
closed by single-sentence/annotation edits against the already-decided
|
||||
ADRs and specs, recorded in the annotations' own text rather than a
|
||||
new ADR: A4 (`enqueue_tx` stamps the derived engine defaults —
|
||||
annotated onto ADR-020 §3), A5 (stale `sweep_expired(queue)` shapes
|
||||
aligned to ADR-019 §1's handle form in core-contract.md and queues.md),
|
||||
`retry -> bool` + the refused-retry residue and the `fail(None)`
|
||||
default string (ADR-019 §3), the one-shot/repeatable split restated
|
||||
(core-contract), `ack_batch`'s per-id predicate (ADR-010 §1), opts
|
||||
field types/defaults (core-contract, honker-rs parity), the
|
||||
consumer-local non-empty rules (ADR-008 §4), the `with_tx` signature
|
||||
(ADR-007), the `PayloadTooLarge` measurement basis (ADR-008 §5),
|
||||
per-stream offset-sequence equivalence (ADR-015 §4), the
|
||||
`recv_timeout` timeout arm and `EventReceiver`'s no-`recv_timeout`
|
||||
posture (ADR-008 §3/§8).)*
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -377,57 +377,6 @@ narrowed to the pinning work its own record already scoped.)*
|
||||
that removed the substrate's versioning surface), OQ-11 (the
|
||||
cherry-pick procedure governing the substrate-side non-events).
|
||||
|
||||
## Theme: Deployment and capabilities
|
||||
|
||||
### OQ-08: Where does the honest single-host/multi-host boundary live in the trait surface? *(== OQ-ST-08)* — **RESOLVED**
|
||||
|
||||
- **Origin**: [deployment.md](deployment.md)
|
||||
- **Status**: resolved (2026-10-06, Phase 1 —
|
||||
[ADR-016](decisions/016-deployment-honesty.md))
|
||||
- **Priority**: medium
|
||||
- **Resolution**: Pinned by
|
||||
[ADR-016](decisions/016-deployment-honesty.md): **no runtime
|
||||
capability surface — in v1 and by default ever**. The honest
|
||||
single-host/multi-host boundary lives in the two places it is
|
||||
already true, which compose rather than rival: (1)
|
||||
**compile-time engine identity** — the engine crate a binary
|
||||
depends on *is* the deployment statement (single-driver binaries,
|
||||
[ADR-001](decisions/001-crate-split.md); constructors in engine
|
||||
crates, [ADR-008](decisions/008-contract-v1-pinning.md) §6 —
|
||||
"the engine choice is a dependency-graph fact, not a runtime
|
||||
branch"); (2) **deployment.md's documented matrix** — the
|
||||
ops-facing facts of record, unchanged. Option 2
|
||||
(`Store::capabilities()`) rejected field-by-field under ADR-008
|
||||
§5's act-differently rule generalized to surface: host semantics
|
||||
admit no in-process action (a flag would invite the engine-type
|
||||
branch principle 4 bans); payload limits already have their runtime
|
||||
carriage — the universal, contract-wide matchable
|
||||
`PayloadTooLarge` variant (a `capabilities()` field would be a
|
||||
second normative home); wake cadence and knobs are engine-crate
|
||||
config (§6's split); and no consumer-inventory row names any
|
||||
runtime-adapt need. No `engine_name()`, no `#[cfg]` capability
|
||||
axes. The "must not pretend SQLite is multi-host" obligation
|
||||
resolves into three standing statements (contract text carries
|
||||
asymmetries via the taxonomy, engine-crate docs carry posture,
|
||||
deployment.md carries ops facts); the misconfiguration case
|
||||
(SQLite as shared network storage) follows the family's
|
||||
deployment-asserts-truth posture (alkblobs precedent) — documented
|
||||
boundary, no fabricated detection. Contract-suite row added:
|
||||
`PayloadTooLarge` occurrence asymmetry (pg client-side pre-round-
|
||||
trip, SQLite never) — with no capabilities API, the variant is the
|
||||
one runtime carriage of an engine asymmetry, so its matchability
|
||||
is pinned. Re-entry gate: a consumer-inventory row naming a
|
||||
runtime-adapt need.
|
||||
- **Cross-references**: OQ-04 (the pinning that parked this),
|
||||
OQ-10 (narrowed by this — no capability struct to govern), OQ-11,
|
||||
[ADR-001](decisions/001-crate-split.md),
|
||||
[ADR-006](decisions/006-wake-and-delivery-contract.md),
|
||||
[ADR-008](decisions/008-contract-v1-pinning.md) §1/§5/§6,
|
||||
[ADR-012](decisions/012-forked-substrate-design.md),
|
||||
[deployment.md](deployment.md).
|
||||
|
||||
|
||||
|
||||
## Theme: Core contract (Phase 1 review finds)
|
||||
|
||||
### OQ-12: Streams depth — key semantics, `StreamEvent` shape, ordering row, retention — **RESOLVED**
|
||||
@@ -512,8 +461,9 @@ narrowed to the pinning work its own record already scoped.)*
|
||||
like `store.outbox(name)` — `InvalidName`/`ReservedName`), derives
|
||||
the backing queue name `__alkstore_outbox:{outbox}` engine-side,
|
||||
and lands the job row in the caller's transaction with
|
||||
`EnqueueOpts` stamped per [ADR-010](decisions/010-queue-semantics-
|
||||
depth.md) §3a over the backing queue's derived `QueueOpts` —
|
||||
`EnqueueOpts` stamped per
|
||||
[ADR-010](decisions/010-queue-semantics-depth.md) §3a over the
|
||||
backing queue's derived `QueueOpts` —
|
||||
commit-atomic with the business write, rollback drops both (the
|
||||
no-ghosts property). The reserved-prefix rejection is unchanged: it
|
||||
governs directly-supplied names; the derivation is legitimate
|
||||
@@ -533,6 +483,55 @@ narrowed to the pinning work its own record already scoped.)*
|
||||
(outbox scope row), ADR-007/ADR-008 (the seam design), ADR-010 §3
|
||||
(the outbox's derived QueueOpts — unchanged by this).
|
||||
|
||||
## Theme: Deployment and capabilities
|
||||
|
||||
### OQ-08: Where does the honest single-host/multi-host boundary live in the trait surface? *(== OQ-ST-08)* — **RESOLVED**
|
||||
|
||||
- **Origin**: [deployment.md](deployment.md)
|
||||
- **Status**: resolved (2026-10-06, Phase 1 —
|
||||
[ADR-016](decisions/016-deployment-honesty.md))
|
||||
- **Priority**: medium
|
||||
- **Resolution**: Pinned by
|
||||
[ADR-016](decisions/016-deployment-honesty.md): **no runtime
|
||||
capability surface — in v1 and by default ever**. The honest
|
||||
single-host/multi-host boundary lives in the two places it is
|
||||
already true, which compose rather than rival: (1)
|
||||
**compile-time engine identity** — the engine crate a binary
|
||||
depends on *is* the deployment statement (single-driver binaries,
|
||||
[ADR-001](decisions/001-crate-split.md); constructors in engine
|
||||
crates, [ADR-008](decisions/008-contract-v1-pinning.md) §6 —
|
||||
"the engine choice is a dependency-graph fact, not a runtime
|
||||
branch"); (2) **deployment.md's documented matrix** — the
|
||||
ops-facing facts of record, unchanged. Option 2
|
||||
(`Store::capabilities()`) rejected field-by-field under ADR-008
|
||||
§5's act-differently rule generalized to surface: host semantics
|
||||
admit no in-process action (a flag would invite the engine-type
|
||||
branch principle 4 bans); payload limits already have their runtime
|
||||
carriage — the universal, contract-wide matchable
|
||||
`PayloadTooLarge` variant (a `capabilities()` field would be a
|
||||
second normative home); wake cadence and knobs are engine-crate
|
||||
config (§6's split); and no consumer-inventory row names any
|
||||
runtime-adapt need. No `engine_name()`, no `#[cfg]` capability
|
||||
axes. The "must not pretend SQLite is multi-host" obligation
|
||||
resolves into three standing statements (contract text carries
|
||||
asymmetries via the taxonomy, engine-crate docs carry posture,
|
||||
deployment.md carries ops facts); the misconfiguration case
|
||||
(SQLite as shared network storage) follows the family's
|
||||
deployment-asserts-truth posture (alkblobs precedent) — documented
|
||||
boundary, no fabricated detection. Contract-suite row added:
|
||||
`PayloadTooLarge` occurrence asymmetry (pg client-side pre-round-
|
||||
trip, SQLite never) — with no capabilities API, the variant is the
|
||||
one runtime carriage of an engine asymmetry, so its matchability
|
||||
is pinned. Re-entry gate: a consumer-inventory row naming a
|
||||
runtime-adapt need.
|
||||
- **Cross-references**: OQ-04 (the pinning that parked this),
|
||||
OQ-10 (narrowed by this — no capability struct to govern), OQ-11,
|
||||
[ADR-001](decisions/001-crate-split.md),
|
||||
[ADR-006](decisions/006-wake-and-delivery-contract.md),
|
||||
[ADR-008](decisions/008-contract-v1-pinning.md) §1/§5/§6,
|
||||
[ADR-012](decisions/012-forked-substrate-design.md),
|
||||
[deployment.md](deployment.md).
|
||||
|
||||
## Deferred / Blocked
|
||||
|
||||
None. **The open question set is empty** — every OQ above is resolved
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-10-07 (ADR-021 — third review round: tx reads, claimed_at, schedule queue validation)
|
||||
last_updated: 2026-10-07 (ADR-021 + third-round follow-through: handle-form sweep_expired, ack_batch predicate, opts types)
|
||||
---
|
||||
|
||||
# Queues, scheduler, outbox — semantics depth
|
||||
@@ -88,9 +88,10 @@ made under; the ADRs carry the WHY.
|
||||
▼
|
||||
(gone)
|
||||
|
||||
expires lapse (any state) — sweep_expired(q) moves the row to
|
||||
dead with last_error='expired'; also enforces dead-letter
|
||||
retention. ack/cancel leave no row; get_job sees dead rows.
|
||||
expires lapse (any state) — sweep_expired() on the queue handle
|
||||
moves the row to dead with last_error='expired'; also enforces
|
||||
dead-letter retention. ack/cancel leave no row; get_job sees
|
||||
dead rows.
|
||||
```
|
||||
|
||||
- **States**: `pending` → `processing` → `dead` (+ absence:
|
||||
@@ -151,7 +152,9 @@ made under; the ADRs carry the WHY.
|
||||
|
||||
## Sweep / maintenance (ADR-010 §5–§6)
|
||||
|
||||
- `sweep_expired(queue)` moves *every* past-`expires_at` row (pending
|
||||
- `sweep_expired()` on the queue handle (the queue-scoped form,
|
||||
[ADR-019](decisions/019-mechanism-handle-surfaces.md) §1) moves
|
||||
*every* past-`expires_at` row (pending
|
||||
**and** processing) to dead, and enforces dead-letter retention —
|
||||
the **no-stranded-rows property**
|
||||
([ADR-010](decisions/010-queue-semantics-depth.md) §5): a job with
|
||||
|
||||
Reference in new issue
Block a user