15 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pg-engine-seam-tx | Postgres engine — `begin_tx`, `TxHandle` (pooled-object handle), all eleven `*_tx` methods | completed |
|
moderate | high | component | implementation |
|
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-postgresClientisSend + Sync, POC #2 compile-probe; ADR-007's pg arm).begin_tx: pool checkout +BEGIN, return the boxedPgTxHandleholdingOption<deadpool::Object>; ops are straight.awaits; commit/ rollback return the object to the pool. - Drop = rollback (ADR-021 §4): the handle's
DropissuesROLLBACKand 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 withError::Closed(the wave-3 fail-closed guard shape). - All eleven
*_txmethods, 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 — typedPayloadTooLargewith 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 guardlimit <= 0→ emptyVecat 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.
- Writes:
- Name validation at every entry point via core's
validationhelpers (shared-namespace kinds vs consumer-local; ADR-008 §4) — before any round trip. - Payload encoding: core's fallible
encode_payloadat the seam;?theCodecerror (ADR-023 §1). Decode via the#[doc(hidden)]from_rowconstructors; 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 onestd::timeread; 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'sresolution.rs. pg_notifypayload 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
begin_txchecks out +BEGIN; concurrentbegin_txdraws distinct pooled objects (pool-bounded); pool exhaustion blocks (deadpool's acquire semantics) — tested- All eleven
*_txmethods 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 commitcommits + 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-backnotify_tx), and offset saves- Drop is panic-safe; failed COMMIT/ROLLBACK arms discard the
object (pool accounting exact); ops-after-consume →
Closed notify_tx/notify's 8000-byte check: typedPayloadTooLarge { limit: 8000 }client-side, before any round trip; ≤ 8000 crosseswith_tx(core's provided method) works end-to-end on this engine: Ok ⇒ commit, Err ⇒ rollback — testedencode_payload'sCodecerror propagates; extent guard on the tx stream reads;get_job_txqueue-scopedcargo 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:Dropcannot.await, and the POC's__private_api_rollback-style sync submission is a private tokio-postgres API. The handle captures the construction runtime'sHandle;Droptakes the held object and spawns the teardown task —ROLLBACK, then re-pool by dropping the object (re-pool = theObjectdrop arm; discard =Object::take, deadpool's permanent-take, which reduces the pool size exactly). Probes verifiedHandle::spawnis 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. Thebegin_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 (
COMMITon 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 failedBEGINarm inbegin_txdiscards too (same posture); aROLLBACKed-then-failed-ROLLBACK residue was probe-verified not to strand a session. - The 8000-byte check's predicate is
len >= limit, notlen > 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'slimit: 8000stays 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::timeread, not a SQL-side clock function (the "pick one, document it" point):resolution.rs'snow_unix()is the single read; one instant stampsrun_at/created_at/expires_atas plaini64binds (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-commitget_jobshape, 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 twoNULL::text/NULL::bigintcasts matter (probe: untyped NULL carries no type info forOption<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$3beside the literal0infers int4 — an i64 bind errors), theLIMIT $3bind rides the inferred int8 fine, andpg_notify($1, $2)'s payload rides as UTF-8 text (the server infers Text; bindingVec<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_txclamps 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-stubnotify/listen/stream/queue/outbox/try_lock/schedule/unschedule/ run_schedulesshapes ride the open task's stub text unchanged; onlybegin_txis wired here (the seam task's scope). The open task'sstore_trait_methods_are_wiring_stubstest updated:begin_txnow boots + commits (the stub posture's replacement is this task's landing), the stub-message assertion moved to thenotifystub (stillDatabase, still "wiring lands with").- Test harness details: the dead-row plant rides typed
executebinds (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'squote_identifierposture).
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_txOk⇒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; workspacecargo testgreen server-less (236→260 tests, pg tests skipping per convention);cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean workspace-wide.