132 lines
6.1 KiB
Markdown
132 lines
6.1 KiB
Markdown
# 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](008-contract-v1-pinning.md) §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**:
|
|
|
|
```text
|
|
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 `await` points; every
|
|
`*_tx` operation lands in *the caller's transaction*; commit/rollback
|
|
are explicit and owned by the caller. Non-`_tx` operations are the
|
|
auto-commit convenience counterparts, each atomic alone.
|
|
*(Drop disposition pinned 2026-10-07 by
|
|
[ADR-021](021-tx-reads-and-value-shape-fixes.md) §4: a handle
|
|
dropped without `commit`/`rollback` rolls 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 documented
|
|
`Transaction` `Drop` behavior is the inherited precedent.)*
|
|
- **Postgres** ([ADR-004]): the handle holds the pooled connection
|
|
object directly (tokio-postgres `Client` is `Send + Sync` — verified
|
|
by a compile-time probe). Straight `.await`s, no bridge, no hop
|
|
cost. Commit/rollback return the object to the pool.
|
|
- **SQLite** ([ADR-003]): the handle is a **writer-slot lease** —
|
|
`begin_tx` acquires the `Writer` slot and opens `BEGIN IMMEDIATE`;
|
|
every handle op round-trips `spawn_blocking` to that connection
|
|
(rusqlite is not `Send`-across-await / not `Sync`). Holding the slot
|
|
across `await` points *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:*
|
|
```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
|
|
both engines green: no ghosts).
|
|
|
|
The POC's trait sketch carried two frictions, both now Phase 1
|
|
contract-pinning input rather than open risk:
|
|
|
|
1. The `as_any_mut` downcast per engine for `*_tx` methods on a
|
|
`dyn TxHandle` — small, but a generic or enum-favored handle may be
|
|
cleaner. Decided by [ADR-008](008-contract-v1-pinning.md) §2
|
|
(with the full contract).
|
|
2. 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 `*_tx` calls, 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_blocking` hop on SQLite tx ops (measured fine at
|
|
~0.35 ms p50; the dedicated-thread bridge is the recorded
|
|
optimization).
|
|
- `with_tx` wrapping 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 `*_tx` seam bullet.
|
|
- [ADR-003](003-sqlite-driver.md), [ADR-004](004-postgres-driver.md) —
|
|
the per-engine handle mechanics.
|
|
- [ADR-006](006-wake-and-delivery-contract.md) — commit-atomicity is
|
|
the `*_tx` property this seam carries into the notify contract.
|
|
- [core-contract.md](../core-contract.md) — the spec whose transaction
|
|
section this ADR defines.
|
|
[ADR-003]: 003-sqlite-driver.md
|
|
[ADR-004]: 004-postgres-driver.md
|