6.3 KiB
6.3 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pg-engine-streams | Postgres engine — streams (`StreamHandle`, reads, subscribe, trim) | pending |
|
moderate | medium | component | implementation |
|
Description
Implement the stream mechanism's pg arm — the full StreamHandle trait
and the durable subscribe receiver, over the schema task's events/
offsets tables and the forwarder's wake substrate (ADR-015's depth,
engine-postgres.md's streams mapping row):
publish/publish_with_key(auto-commit): pool checkout,INSERTinto the events table (bigserial id = the assigned offset; nullable key column — carried metadata, empty-Some-key →InvalidNameper the tx path's rule),encode_payloadat the seam (Codecpropagation), return to pool. Then wake:pg_notifyon the stream's wake channel (the mechanism-name-is-the-channel realization pinned in the plan's Wave 4 section) — a separate statement on the pooled connection (auto-commit: its own implicit tx; the wake is best-effort, not commit-atomic with the insert — the no-replay hole covers a gap between them; the durable row is the truth).read_since/read_from_consumer: pool reads,ORDER BY id ASC(global FIFO — bigserial offsets, immutable, never renumbered; ADR-015 §4's ordering row), the extent guard (limit <= 0→ emptyVecat trait-impl entry, ADR-023 §2 — neverLIMIT -1-class dialect semantics; pg'sLIMITwith a negative is an error, which the guard makes unreachable).read_from_consumeranchors at the consumer's stored offset.save_offset/get_offset: the monotone upsert (a save below the stored checkpoint is a silent no-op —GREATEST-guarded upsert orWHERE offset > stored; one owner of the monotonicity rule) and the checkpoint read. Both save forms'Errarm carriesDatabaseonly (ADR-021 §5).trim_to(horizon):DELETE … WHERE id <= horizonon a pool connection (wakes nothing — ADR-015 §5's posture; the boundary argument is total: a negative horizon deletes nothing, idempotently), returning the deleted count. Surviving events keep their offsets (gaps legal).subscribe(consumer)— the durable receiver, the structural twin of the SQLite engine's bridge with the pg deltas:- Attach-before-read ordering (subscribe to the stream's wake channel before the initial cursor read — a commit cannot fall between attach and wait).
- Initial cursor read of the consumer's stored offset; page-drain
to the tail (
offset ASC); park on the wake feed (the forwarder fanout for this stream's channel); re-drain on wake (wake-driven with table re-read — events never require polling to become visible, core-contract.md's streams section). - Close semantics (the pg arm, ADR-021 §5): the receiver stays
open across forwarder reconnects — reconnect-wakes arrive on the
wake feed and simply trigger re-drains (which is the recovery:
the no-replay hole's gap is healed by the re-drain reading the
durable rows); closes only at engine shutdown (terminal
None). - Position semantics:
position= the last-yielded event's offset (0 before any yield ⇒save_offset()before any yield is a no-op, ADR-021 §5); the bridge's internal cursor advances on drained-from-storage events — a stalled consumer'ssave_offset()never checkpoints unobserved events. Backpressure: bounded channel + backpressure-aware send. recv()'sErrcarriesDatabaseonly (ADR-021 §5 — remap non-Databaseread errors with the original as source, the SQLitedatabase_onlyhelper's shape);try_recvarms mirror the wake receiver (idleOk(None),Err(Closed)after shutdown-close).- The receiver's
read_since(anchored, extent-guarded) and syncsave_offset(the last-yielded offset through the same monotone op) per ADR-019 §6.
- Validation: stream names via
validate_shared_nameatstore.stream()and (defensively) the handle's reads; consumers viavalidate_local_name(non-empty only — reserved prefixes legal for consumers). - Closed-store posture: every handle op checks the store's closed
flag first and fails closed with
Database(the wave-3 shape).
Acceptance Criteria
publish/publish_with_keyreturn the assigned bigserial offset; key round-trips (NonevsSome);encode_payload'sCodecpropagates; empty-Some-key rejectedInvalidName- Reads yield
offset ASC(global FIFO); extent guard (limit <= 0→ emptyVec) at trait-impl entry; offsets immutable (trim leaves gaps, never renumbers — tested) save_offsetmonotone (regression save = silent no-op);get_offsetreads the checkpoint; saveErr=Databaseonlytrim_toexact-boundary (<=), returns deleted count, wakes nothing, negative horizon deletes nothing; reads resume at the horizon's first remaining row; saved offsets below the horizon stay validsubscribe: replay from stored offset, wake-driven delivery, position = last-yielded offset, backpressure holds, receiver stays open across forwarder reconnects (reconnect-wake triggers re-drain — the gap-healing property tested), terminal close at engine shutdown only- Restart durability: publish → close store → reopen →
subscriberesumes from the saved offset - Validation + closed-store fail-closed on all entry points
cargo test -p alkstore-postgres(harness server), clippy-D warnings, fmt clean; gates green server-less
References
- docs/architecture/core-contract.md (streams section)
- docs/architecture/decisions/015-streams-depth.md
- docs/architecture/decisions/019-mechanism-handle-surfaces.md §6
- docs/architecture/decisions/021-tx-reads-and-value-shape-fixes.md §5
- docs/architecture/decisions/023-fourth-review-round.md §2 (extent guard)
- docs/architecture/engine-postgres.md (Mapping the contract: streams)
- alkstore-sqlite/src/stream.rs (the structural twin)
- alkstore/src/stream_handle.rs, event_receiver.rs (the traits)
Notes
To be filled by implementation agent
Summary
To be filled on completion