Files
alkstore/tasks/pg-engine-seam-tx.md
T

262 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<deadpool::Object>`; 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<Self>)`: `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<T>` 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<u8>` 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.