--- id: pg-engine-seam-tx name: Postgres engine — `begin_tx`, `TxHandle` (pooled-object handle), all eleven `*_tx` methods status: completed depends_on: [pg-engine-open-opts] scope: moderate risk: high impact: component level: implementation tags: [wave-4, postgres-engine, seam] --- ## Description Implement the transactional seam — the pg arm of ADR-007, the shape every other mechanism task rides, and the wave's highest-risk piece (the wave-3 review's writer-slot findings show exactly the defect class seam error arms breed): - **No bridge, no `spawn_blocking`** — the pg handle holds the pooled object directly (tokio-postgres `Client` is `Send + Sync`, POC #2 compile-probe; ADR-007's pg arm). `begin_tx`: pool checkout + `BEGIN`, return the boxed `PgTxHandle` holding `Option`; ops are straight `.await`s; commit/ rollback return the object to the pool. - **Drop = rollback** (ADR-021 §4): the handle's `Drop` issues `ROLLBACK` and re-pools the object — panic-safe (no panic escapes drop). A failed rollback's disposition is pinned (the object is discarded, not re-pooled — its state is unknowable; the pool accounting stays exact, deployment.md's ADR-021 note). Ops after commit/consumption fail closed with `Error::Closed` (the wave-3 fail-closed guard shape). - **All eleven `*_tx` methods, for real** (nothing stubs — the schema exists, the SQL is this task's to write): - Writes: `enqueue_tx` (stamps the derived plain-queue defaults 300/3/5/none — the no-handle-open shape, ADR-020 §3a), `publish_tx`, `publish_with_key_tx` (empty-`Some`-key → `InvalidName`, ADR-015 §2), `notify_tx` (`SELECT pg_notify($1, $2)` inside the caller's tx — delivers at commit, native commit-atomicity, ADR-007; the 8000-byte client-side check fires here and on the auto-commit twin — typed `PayloadTooLarge` with the limit in the variant, before any round trip, ADR-016 §5), `save_offset_tx` (monotone upsert), `outbox_enqueue_tx` (derives the `__alkstore_outbox:{name}` backing queue name engine-side, stamps the outbox's 60/5/5 set — ADR-014 §1). - Reads (ADR-021 §1, read-your-own-writes through the same held tx): `get_job_tx` (queue-scoped — a found row's queue column must match the argument, the wave-3 seam's scoping rule), `get_offset_tx`, `read_since_tx`, `read_from_consumer_tx` (offset ASC; the extent guard `limit <= 0` → empty `Vec` at trait-impl entry, ADR-023 §2). - `commit(self: Box)`: `COMMIT` + re-pool. A failed COMMIT discards the object (unknowable state) — the wave-3 review's finding (c), pre-empted here. - **Name validation at every entry point** via core's `validation` helpers (shared-namespace kinds vs consumer-local; ADR-008 §4) — before any round trip. - **Payload encoding**: core's fallible `encode_payload` at the seam; `?` the `Codec` error (ADR-023 §1). Decode via the `#[doc(hidden)]` `from_row` constructors; malformed rows → `Codec` (the posture guard). - **The engine-side arithmetic lives here first** (one owner per formula, ADR-012 §2 — the pg engine's own `resolution.rs`, the SQLite one is reference not dependency): `now` (the single clock read — `now()` at the SQL layer or one `std::time` read; pick one, document it), `plain_queue_default_stamps()` (300/3/5/none), `outbox_default_stamps()` (60/5/5), `outbox_backing_queue_name()` (the reserved-prefix derivation), `resolve_enqueue_opts` (delay-over-`run_at`, relative-`expires`, one clock read per enqueue). The auto-commit mechanism tasks reuse these directly — same shape as the SQLite engine's `resolution.rs`. - **`pg_notify` payload carriage**: the notify payload is the serde_json serialization's UTF-8 text (≤ 8000 bytes checked client-side). Large payloads do NOT ride a side-table in v1 — the typed rejection is the contract's answer (ADR-016 §5's occurrence asymmetry; the outbox shape is the documented large-payload pattern, not engine machinery). The mechanism tasks then add what the tx path deliberately lacks: the auto-commit handles, the forwarder-driven receivers, the backoff curve, and the scheduler runner. ## Acceptance Criteria - [x] `begin_tx` checks out + `BEGIN`; concurrent `begin_tx` draws distinct pooled objects (pool-bounded); pool exhaustion blocks (deadpool's acquire semantics) — tested - [x] All eleven `*_tx` methods implemented with entry-point validation; writes commit-atomic, reads see the tx's own writes (read committed, both directions POC-pinned); opts resolution (delay-over-run_at, relative-expires, 300/3/5/none and 60/5/5 stamp sets) pinned by tests - [x] `commit` commits + re-pools; drop (without commit) rolls back + re-pools — no-ghosts tested for job rows, stream events, notifications (a listener hears nothing from a rolled-back `notify_tx`), and offset saves - [x] Drop is panic-safe; failed COMMIT/ROLLBACK arms discard the object (pool accounting exact); ops-after-consume → `Closed` - [x] `notify_tx`/`notify`'s 8000-byte check: typed `PayloadTooLarge { limit: 8000 }` client-side, before any round trip; ≤ 8000 crosses - [x] `with_tx` (core's provided method) works end-to-end on this engine: Ok ⇒ commit, Err ⇒ rollback — tested - [x] `encode_payload`'s `Codec` error propagates; extent guard on the tx stream reads; `get_job_tx` queue-scoped - [x] `cargo test -p alkstore-postgres` (harness server), clippy `-D warnings`, fmt clean; gates green server-less ## References - docs/architecture/decisions/007-transactional-seam.md (pg arm) - docs/architecture/decisions/021-tx-reads-and-value-shape-fixes.md §1, §2, §4 - docs/architecture/decisions/014-outbox-tx-enqueue.md §1 - docs/architecture/decisions/020-enqueue-opt-semantics-and-bridges.md §1–§4 - docs/architecture/decisions/016-deployment-honesty.md §5 - docs/architecture/decisions/023-fourth-review-round.md §1, §2 - docs/architecture/engine-postgres.md (Mapping the contract: begin_tx, handle ops) - docs/research/poc-pg-posture-findings.md (Sub-module T — the shape (a) evidence, read-your-writes, notify-at-commit) - alkstore/src/tx.rs (the trait to implement) - alkstore-sqlite/src/tx.rs, resolution.rs (the structural twin) ## Notes Decisions of record made while implementing (the description didn't pin them): - **The `Drop` = rollback is a detached-task rollback (the async-native drop posture), not a synchronous await**: `Drop` cannot `.await`, and the POC's `__private_api_rollback`-style sync submission is a private tokio-postgres API. The handle captures the construction runtime's `Handle`; `Drop` takes the held object and spawns the teardown task — `ROLLBACK`, then re-pool by dropping the object (re-pool = the `Object` drop arm; discard = `Object::take`, deadpool's permanent-take, which reduces the pool size exactly). Probes verified `Handle::spawn` is captured-handle-bound (never panics on the captured handle's liveness — usable from any thread, e.g. a sync-context drop) and the runtime-independent futures don't panic either. The runtime-shutdown-before-task-runs arm drops the object, whose connection closes server-side and **the server itself aborts the open transaction** (probe-verified) — the no-ghosts property holds even there. The `begin_concrete`/`pooled_client`/ `consume_shell` `#[cfg(test)]` accessors are the tests' fault-injection surface (poisoning a tx needs raw in-tx SQL; the boxed trait object cannot downcast, ADR-007). - **The unknowable-state discard arm is probe-pinned, and the failing-COMMIT arm is induced with a deferred FK**: an *aborted* tx commits fine server-side by design (`COMMIT` on an aborted tx = a no-op success — probe-verified both protocols), so the failing-COMMIT arm is induced with a DEFERRABLE INITIALLY DEFERRED FK violation (surfaced at commit only); the failed commit discards the object (`Object::take`) — the wave-3 review's finding (c) pre-empted, the pool accounting exact (fresh draw proceeds, usable). The failed `BEGIN` arm in `begin_tx` discards too (same posture); a `ROLLBACK`ed-then-failed-ROLLBACK residue was probe-verified not to strand a session. - **The 8000-byte check's predicate is `len >= limit`, not `len > limit`** — probe-pinned against the harness server (7999-byte text delivers; 8000 is server-rejected both protocols; the pg payload budget is the wire's 8000 bytes *counting the NUL terminator*). The variant's `limit: 8000` stays the contract's pinned number (ADR-008 §5); the check's quantity is contract-pinned (the serde_json serialization — the stored-bytes twin, ADR-020 §4), and the typed arm mirrors the server exactly so no oversized payload ever surfaces as an opaque server-side reject. Contract text says "≤ 8000 bytes"; the server's answer (and now ours) is "< 8000 serialization bytes plus NUL ≤ 8000" — the largest *crossing* serialization is 7999 bytes (a 7997-char bare string). Flagged here as a core-text reconciliation note for wave 5's suite (the pg-side rejection arm's row) — no variant number changes. - **The clock is a `std::time` read, not a SQL-side clock function** (the "pick one, document it" point): `resolution.rs`'s `now_unix()` is the single read; one instant stamps `run_at`/`created_at`/ `expires_at` as plain `i64` binds (second-precision integer arithmetic, schema.rs's engine-wide timestamp posture). The SQL layer never reads a clock in the enqueue/publish paths the tx seam carries. - **Dead rows are `get_job_tx`-visible** (the auto-commit `get_job` shape, ADR-010 §1's "dead rows included"): the tx read is queue-scoped *and* dead-diagnosis-carrying (`last_error`/`died_at`) — two typed selects (dead table first, then live with NULL-cast diagnosis columns), one decode owner (`job_from_row`, the `#[doc(hidden)]` constructor's 18-column uniform shape). The 18-column select's two `NULL::text`/`NULL::bigint` casts matter (probe: untyped NULL carries no type info for `Option` decode). - **Extended-protocol param typing is deliberate at three sites** (probe-pinned: the server infers param types from context): `GREATEST($3::bigint, 0)` (the bare `$3` beside the literal `0` infers int4 — an i64 bind errors), the `LIMIT $3` bind rides the inferred int8 fine, and `pg_notify($1, $2)`'s payload rides as UTF-8 text (the server infers Text; binding `Vec` errors WrongType — the notify payload's contract carriage *is* the serialization's UTF-8 text, so no cast is needed client-side). - **`save_offset_tx` clamps a negative save at 0 in the INSERT seed** (`GREATEST($3::bigint, 0)`): the offsets table's domain CHECK (`offset >= 0`, schema.rs) would abort the tx on a negative first-save; clamping preserves the checkpoint's non-negative domain and the monotone rule (a negative save at-or-below the stored checkpoint is the same silent no-op a 0-save is). The trait docs do not pin a negative-offset save's disposition — the clamp is this engine's honest answer (typed never, no tx-poisoning from a caller's negative cursor). - **`enqueue_row` (the shared INSERT) and the trait-stub `notify/listen/stream/queue/outbox/try_lock/schedule/unschedule/ run_schedules` shapes** ride the open task's stub text unchanged; only `begin_tx` is wired here (the seam task's scope). The open task's `store_trait_methods_are_wiring_stubs` test updated: `begin_tx` now boots + commits (the stub posture's replacement is this task's landing), the stub-message assertion moved to the `notify` stub (still `Database`, still "wiring lands with"). - **Test harness details**: the dead-row plant rides typed `execute` binds (the simple protocol — `batch_execute` — carries no parameters; probe-pinned E42P02 "there is no parameter $1" is exactly the simple/extended boundary); the tx-reads test's poison-induction and the no-ghosts listener test use schema-qualified quoted DDL (identifier quoting at the test's composition sites, matching production's `quote_identifier` posture). ## Summary Landed alkstore-postgres's transactional seam: `resolution.rs` (the engine-side arithmetic — the single std-time clock read, the plain-queue 300/3/5/none and outbox 60/5/5 derived stamp sets, the `__alkstore_outbox:{name}` reserved-prefix derivation, `stamps_with_override`'s max-attempts-only override, and `resolve_enqueue_opts`' delay-over-`run_at` + relative-`expires` resolution, one clock read per enqueue — every formula unit-tested), `tx.rs` (the `PgTxHandle` pooled-object handle: `begin` = pool checkout + `BEGIN` with the exhaustion-blocking, fail-`Database` arms; the shared `enqueue_row` INSERT both enqueue shapes ride; all eleven `*_tx` methods implemented with entry-point validation before any round trip, core's fallible `encode_payload` at the seam with the typed `Codec` error `?`-ed, the client-side `PayloadTooLarge { limit: 8000 }` check at `len >= 8000` (the server-mirroring, NUL-counting predicate — probe-pinned), the monotone `save_offset_tx` upsert clamp-seeded at 0, queue-scoped dead-visible `get_job_tx`, the extent guards (`limit <= 0` → empty `Vec`) on both tx stream reads, `commit` = `COMMIT` + re-pool with the failed arm discarding (`Object::take`), and drop = rollback via the detached-task teardown (RAII paths roll back through a detached `ROLLBACK`+re-pool; failed/`is_closed` arms discard; the runtime-gone arm's session close aborts server-side — no ghosts on any arm) and ops-after-consume failing closed `Error::Closed`), and the `PgStore::begin_tx` wiring replacing its stub. Verified against the harness server: 16 new tx-seam tests + 8 resolution unit tests (39 lib tests green ×2 runs) covering the acceptance rows — pool bounds + blocked begin, the stamp/opts resolutions, read-your-own-writes both directions, keyed publish + monotone offsets + silent-no-op saves, the uniform no-ghosts list (jobs/events/notifies/offsets — a listener hears nothing from a rolled-back notify and delivers a committed one, native commit-atomicity), the unknowable-state arms (deferred-FK-induced failing COMMIT → discard + exact accounting), the fail-closed shell (all eleven ops + double-commit `Closed`), panic-through + mid-tx-cancellation rollbacks, `with_tx` Ok⇒commit / Err⇒rollback, the typed 8000-byte boundary (crossing 7999 vs typed rejects at 8000 and 8002), entry-point validation on every name (shared kinds reserved+empty, consumer-local kinds empty — residue- free rejects), and the 8-parallel-tx exactly-once fan-out; workspace `cargo test` green server-less (236→260 tests, pg tests skipping per convention); `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` clean workspace-wide.