Postgres engine: transactional seam — begin_tx, PgTxHandle, all eleven *_tx methods with drop=rollback detached teardown, probe-pinned unknowable-state discard arms, engine-side resolution arithmetic (task pg-engine-seam-tx)
This commit is contained in:
1 parent
c6a7eeaa45
commit
cf5ceea70e
7 files changed
+2318
-29
No files matched your search
+144
-11
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: pg-engine-seam-tx
|
||||
name: Postgres engine — `begin_tx`, `TxHandle` (pooled-object handle), all eleven `*_tx` methods
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [pg-engine-open-opts]
|
||||
scope: moderate
|
||||
risk: high
|
||||
@@ -82,28 +82,28 @@ curve, and the scheduler runner.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `begin_tx` checks out + `BEGIN`; concurrent `begin_tx` draws
|
||||
- [x] `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
|
||||
- [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
|
||||
- [ ] `commit` commits + re-pools; drop (without commit) rolls back +
|
||||
- [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
|
||||
- [ ] Drop is panic-safe; failed COMMIT/ROLLBACK arms discard the
|
||||
- [x] 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
|
||||
- [x] `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
|
||||
- [x] `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
|
||||
- [x] `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
|
||||
- [x] `cargo test -p alkstore-postgres` (harness server), clippy
|
||||
`-D warnings`, fmt clean; gates green server-less
|
||||
|
||||
## References
|
||||
@@ -122,8 +122,141 @@ curve, and the scheduler runner.
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
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
|
||||
|
||||
> To be filled on completion
|
||||
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.
|
||||
Reference in new issue
Block a user