ADR-021: third review round — tx-read methods, Job.claimed_at, schedule() queue validation, drop=rollback, receiver arms

This commit is contained in:
glm-5.3-flash committed 2026-10-07 06:25:39 +00:00
1 parent 8323a7853e
commit 08dc1bf011
15 files changed
+499 -29

No files matched your search

+73 -3
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-10-06 (ADR-019/020 — handle surfaces, enqueue semantics, payload bridge)
last_updated: 2026-10-07 (ADR-021 — third review round: tx reads, claimed_at, schedule queue validation, drop=rollback, receiver arms)
---
# Core contract
@@ -72,11 +72,29 @@ trait TxHandle {
publish_with_key_tx(stream, key, payload) -> offset
notify_tx(channel, payload)
save_offset_tx(stream, consumer, offset)
get_job_tx(queue, job_id) -> Option<Job> // ADR-021
get_offset_tx(stream, consumer) -> i64 // ADR-021
read_since_tx(stream, offset, limit) -> Vec<StreamEvent> // ADR-021
read_from_consumer_tx(stream, consumer, limit) -> Vec<StreamEvent> // ADR-021
outbox_enqueue_tx(outbox, opts, payload) -> job_id
commit(self: Box<Self>) -> Result<()> // or rollback
}
```
The tx read methods (`get_job_tx` / `get_offset_tx` / `read_since_tx`
/ `read_from_consumer_tx`,
[ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §1) make
read-your-own-writes possible inside the business transaction — they
take the mechanism name (mirroring `enqueue_tx`/`publish_tx`'s
name-taking shape), read the caller's own transaction's rows, and are
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
RAII paths, not only the explicit ones.
`outbox_enqueue_tx` takes the **outbox name** and derives the backing
queue engine-side (the reserved prefix makes the derived name
unreachable by `enqueue_tx` by design —
@@ -223,7 +241,11 @@ business-tx shape). Depth pinned by
(`EventReceiver::save_offset(&mut self)`, which saves the
last-yielded event's offset through the same op,
[ADR-019](decisions/019-mechanism-handle-surfaces.md) §6)
interleave freely and cannot regress one another. A saved offset
interleave freely and cannot regress one another. Both save
forms' `Err` arm carries `Database` only
([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §5) —
regression refusal is a silent no-op, so a save result errs only
on storage failure. A saved offset
below the trim horizon
([ADR-015](decisions/015-streams-depth.md) §5) stays a valid
position marker.
@@ -241,6 +263,16 @@ business-tx shape). Depth pinned by
consumption: attach, read to current tail, resume after restart
from the stored offset; explicit `save_offset` on the receiver
(shape in [ADR-008](decisions/008-contract-v1-pinning.md) §8).
**Error and close arms pinned**
([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §5):
`recv()`'s `Err` arm carries `Database` only (payloads cross raw;
`Codec` lives at `payload_as<T>`; `Closed` is the `None` arm);
close is terminal and its *cause* is the documented engine
asymmetry — SQLite's receiver closes on watcher death (POC-pinned),
the pg receiver stays open across forwarder reconnects (reconnect
wakes arrive on it) and closes only at engine shutdown. A closed
receiver never reopens; recovery is a fresh `subscribe(consumer)`
resuming from the saved offset.
Consumption is wake-driven with table re-read — the same
mechanism split as queues (durable row, LISTEN/watcher wake,
[ADR-006](decisions/006-wake-and-delivery-contract.md)); the
@@ -375,7 +407,15 @@ Collapsed into queues per [ADR-009](decisions/009-scheduler-collapse.md):
runner (`StopToken` is a core-crate cloneable cancel handle —
[ADR-019](decisions/019-mechanism-handle-surfaces.md) §4; the
leader-elected leadership lock is the reserved name
`__alkstore_scheduler`). Schedules never fire
`__alkstore_scheduler`). Both name-bearing arguments validate at the
entry point: the schedule *name* like a queue name (non-empty,
reserved-prefix rejected —
[ADR-009](decisions/009-scheduler-collapse.md) §1) and, since
[ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §3, the
**queue argument** identically (`InvalidName`/`ReservedName`) — a
schedule cannot target a reserved name, so the outbox backing queue
stays reachable only through the outbox surface
([ADR-014](decisions/014-outbox-tx-enqueue.md)). Schedules never fire
without a runner (no ambient timers). v1 spec grammar:
`@every <n><unit>` only (`s|m|h|d`); cron strings are rejected
(`InvalidSpec`) pending a consumer-inventory row naming wall-clock
@@ -570,6 +610,30 @@ before the engine specs are called `stable`:
§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.
- **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
(`get_job_tx` a row the same tx enqueued; `read_since_tx` an event
the same tx published); another engine connection sees none of it
pre-commit; post-rollback the reads' subjects are gone (no-ghosts
for reads).
- **Drop = rollback on both engines**
([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §4) —
a handle dropped before commit leaves no job/event/notify/offset
residue (no-ghosts via RAII); afterward the SQLite writer slot is
reusable and the pg client re-poolable.
- **`schedule()` queue-argument validation on both engines**
([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §3) —
reserved-prefix and empty queue arguments rejected at registration
with `ReservedName`/`InvalidName`; stored schedules fire only into
non-reserved queue names.
- **Receiver error/close arms on both engines**
([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §5) —
`recv()`'s `Err` carries `Database` only; `save_offset`'s `Err`
carries `Database` only; regression saves are silent no-ops;
SQLite's receiver closes on watcher death (terminal `None`); the
pg receiver stays open across forwarder reconnects (reconnect-wakes
arrive; no close) and closes only at engine shutdown.
## Design Decisions
@@ -590,6 +654,7 @@ before the engine specs are called `stable`:
| [017](decisions/017-contract-versioning.md) | Contract versioning (governs this surface's changes) | core crate's semver *is* the contract version; four change classes; amend-in-place ends at first release; pairing = manifest pin + version-stamped contract suite + docs |
| [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism handles (amends 008 §1/§2/§8) | handle traits pinned (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`); `worker_id` claimant identity; `Job`/`Schedule` struct shapes; core-owned `StopToken`; save-offset composition |
| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue options + bridges (amends 008 §1/§2, 009 §3) | `delay` wins over `run_at`; `expires` = relative seconds; scheduler stamps from derived queue defaults; serde_json byte-level payload encoding |
| [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round (amends 008 §2/§8, 019 §1/§3, 009 §1) | tx-side read methods on `TxHandle` (read-your-own-writes); `Job.claimed_at` restored; `schedule()` queue argument validated; drop = rollback; receiver close/error arms pinned |
## Open Questions
@@ -619,3 +684,8 @@ its findings resolved directly as
surfaces, value shapes, stop token) and
[ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md)
(enqueue-option semantics, scheduler stamp source, payload encoding).
The third review round (2026-10-07, pre-decomposition) likewise found
no new open questions — its five findings resolved directly as
[ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) (tx-side
reads, `Job.claimed_at`, `schedule()` queue-validation, drop =
rollback, receiver close/error arms).