262 lines
15 KiB
Markdown
262 lines
15 KiB
Markdown
---
|
||
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. |