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

15 KiB
Raw Blame History

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
pg-engine-open-opts
moderate high component implementation
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 .awaits; 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

  • begin_tx checks out + BEGIN; concurrent begin_tx draws distinct pooled objects (pool-bounded); pool exhaustion blocks (deadpool's acquire semantics) — tested
  • 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
  • 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
  • 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: typed PayloadTooLarge { limit: 8000 } client-side, before any round trip; ≤ 8000 crosses
  • with_tx (core's provided method) works end-to-end on this engine: Ok ⇒ commit, Err ⇒ rollback — tested
  • encode_payload's Codec error propagates; extent guard on the tx stream reads; get_job_tx queue-scoped
  • 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 ROLLBACKed-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.