6.1 KiB
ADR-007: Transactional seam — caller-held tx handle with *_tx methods
Status
Accepted (the seam shape both POCs verified); handle-type ergonomics
decided by ADR-008 §2 — the *_tx
methods live on the handle trait, no downcast
Context
The load-bearing property of the whole store (guiding principle 2) is
transactional local-adjacency: enqueue/publish/notify inside the
same transaction as the caller's business write; rollback drops both.
Serving it through an async trait over two transaction models —
rusqlite's sync, thread-bound connection vs tokio-postgres's
Send + Sync client — is the one genuinely driver-coupled design
point (OQ-ST-03's framing). Both POCs (2026-10-04) built the candidates
and measured them.
Decision
The core contract's transactional seam is a caller-held transaction handle:
store.begin_tx() -> TxHandle
handle.enqueue_tx(name, opts, payload) -> job id
handle.publish_tx(name, payload) -> event id
handle.notify_tx(channel, payload)
handle.save_offset_tx(stream, consumer, offset)
handle.commit() / handle.rollback()
- The handle is owned by the caller across
awaitpoints; every*_txoperation lands in the caller's transaction; commit/rollback are explicit and owned by the caller. Non-_txoperations are the auto-commit convenience counterparts, each atomic alone. (Drop disposition pinned 2026-10-07 by ADR-021 §4: a handle dropped withoutcommit/rollbackrolls back — the no-ghosts property holds through RAII paths, not only the explicit ones; SQLite's writer-slot lease releases with the rollback, the pg pooled client rolls back and re-pools; honker's documentedTransactionDropbehavior is the inherited precedent.) - Postgres ([ADR-004]): the handle holds the pooled connection
object directly (tokio-postgres
ClientisSend + Sync— verified by a compile-time probe). Straight.awaits, no bridge, no hop cost. Commit/rollback return the object to the pool. - SQLite ([ADR-003]): the handle is a writer-slot lease —
begin_txacquires theWriterslot and opensBEGIN IMMEDIATE; every handle op round-tripsspawn_blockingto that connection (rusqlite is notSend-across-await / notSync). Holding the slot acrossawaitpoints is how the lease works — a long transaction parks the single WAL writer, which is SQLite's own shape. - Closure-scoped transactions (
with_tx) are available as a 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:The trait method is deliberately non-generic (astore.with_tx(f) -> Result<()> // on the Store trait f: FnOnce(&mut dyn TxHandle) -> BoxedFuture<Result<()>>Storetrait object must stay object-safe, ADR-008 §2's posture) and returnsResult<()>: 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_txis the convenience,begin_txthe seam). The closure's future boxes for object safety. An engine may additionally offer a generic value-returningwith_txconvenience 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 both engines green: no ghosts).
The POC's trait sketch carried two frictions, both now Phase 1 contract-pinning input rather than open risk:
- The
as_any_mutdowncast per engine for*_txmethods on adyn TxHandle— small, but a generic or enum-favored handle may be cleaner. Decided by ADR-008 §2 (with the full contract). - Thread-affinity of rusqlite tx ops (SQLite handle ops must round-trip the same blocking thread) — inherent to [ADR-003]'s bridge, documented as a contract note ("SQLite tx ops are serialized by the writer slot"), not a defect.
Consequences
Positive
- The load-bearing property is one seam, both engines, POC-verified end-to-end — the dual-write problem is structurally prevented, not consumer-disciplined.
- Long transactions compose naturally (checkout-then-work shape); nothing about the seam forbids multi-statement business transactions with interleaved reads.
- Per-engine bridging differences are invisible to consumer code: the
same
*_txcalls, the same commit/rollback ownership.
Negative
- The SQLite handle parks the writer for its duration — a slow consumer transaction throttles all writers on the file. This is honest (WAL single-writer), but contract docs must say it so consumers budget transactions accordingly.
- Per-op
spawn_blockinghop on SQLite tx ops (measured fine at ~0.35 ms p50; the dedicated-thread bridge is the recorded optimization). with_txwrapping is a convenience surface we must ship and test so it doesn't accrete ad-hoc in consumer code.
References
docs/research/poc-sqlite-posture-findings.md§Arm A, §Probe 1/3;docs/research/poc-pg-posture-findings.md§Sub-module T.- OQ-ST-04 (
docs/research/phase-0.md), the*_txseam bullet. - ADR-003, ADR-004 — the per-engine handle mechanics.
- ADR-006 — commit-atomicity is
the
*_txproperty this seam carries into the notify contract. - core-contract.md — the spec whose transaction section this ADR defines. [ADR-003]: 003-sqlite-driver.md [ADR-004]: 004-postgres-driver.md