Files
alkstore/docs/architecture/decisions/007-transactional-seam.md
T
glm-5.3-flash 2949612e2c ADR-012: forked-substrate design — contract-blind boundary, fidelity posture, port deltas
Follow-through on OQ-06/ADR-011: pin the fork's structural decisions
(alkstore-substrate as a vendored path-dep crate, contract-blind API
boundary with contract formulas computed engine-side and pinned
equivalent by the contract suite, keep-the-kept-half API fidelity for
cheap cherry-picks, the W-1/W-2/dead-man's-switch/W-4 port deltas
decided per item, bootstrap re-keying off error-string matching, no
rename migration, deliberate upstream tracking).

Consistency sweep across the doc set for the fork: annotate ADR-003/
005/009/010 and core-contract for superseded ownership facts, fix
schedule-storage table naming (ADR-009 §5, queues.md), re-key ADR-010
§6's notifications hygiene to the at-attach cap the fork scope
realizes, add OQ-11 (scaffold-time residue), and complete both ADR
indexes. Independent review: 0 critical, warnings addressed.
2026-10-05 05:00:55 +00:00

4.7 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 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 .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_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. Decided by ADR-008 §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, ADR-004 — the per-engine handle mechanics.
  • ADR-006 — commit-atomicity is the *_tx property 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