Files
alkstore/docs/architecture/decisions/007-transactional-seam.md
T
glm-5.3-flash 4391f6e879 docs: open Phase 1 — architecture spec set over the Phase 0 evidence
docs/architecture/ now exists: README index, overview, five component
specs (core-contract, engine-sqlite, engine-postgres, queues,
deployment), ADR-001..007 carrying the Phase 0 resolved decisions
(crate split, feature scope, per-engine drivers, dependency
ownership, wake contract, tx seam), and the centralized
open-questions tracker promotion: OQ-ST-01..08 mirror to OQ-01..08
one-to-one with statuses/resolutions carried; new Phase 1 questions
append (OQ-09 scheduler collapse, OQ-10 contract versioning).
Open Phase 1 work: OQ-04 contract pinning (high), OQ-05 queue
semantics depth (high), OQ-06 honker-core quality read (high;
fork-trigger gate), OQ-08 capability surface, OQ-09, OQ-10.

Erratum fixed in phase-0 OQ-ST-04 (thread-affinity friction is
SQLite-side, previously garbled as pg-side) and a stale scheduler-
boundary pointer corrected in consumer-inventory.md. Two review
passes run (findings: OQ-promotion numbering faithfulness, ADR
back-reference sync) — all critical/warning findings resolved.
2026-10-04 18:13:10 +00:00

102 lines
4.5 KiB
Markdown

# ADR-007: Transactional seam — caller-held tx handle with `*_tx` methods
## Status
Accepted (the seam shape both POCs verified); handle-type ergonomics
(generic vs downcast) is OQ-04 contract work
## 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.
- **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).
- 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. OQ-04 decides 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.