Files
alkstore/docs/architecture/decisions/006-wake-and-delivery-contract.md
T

137 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-006: Wake contract — opaque wake + re-read, and the notify-vs-streams delivery split
## Status
Accepted (the contract skeleton the evidence supports); the surface
pinning is done — [ADR-008](008-contract-v1-pinning.md) §3/§4 pin the
wake type and the reserved strings
## Context
The two engines' wake mechanisms are structurally different — SQLite
wakes by a watcher polling `PRAGMA data_version` (deliver-on-commit,
no server push), Postgres by LISTEN/NOTIFY (server push,
connection-bound, no replay). A naive unification either collapses to
polling behavior on the strong side or promises semantics only one
engine has. Both POCs (2026-10-04) measured the pieces and — the
load-bearing finding — verified that **the two engines can share the
same wake contract unchanged**: wake is opaque, delivery is not
guaranteed to be precise, and consumers re-read state.
Honker's own processing-guarantees table is the cautionary prior art:
per-binding auto-checkpoint-vs-manual-save ambiguity produces
*different* guarantees under one function name. A single-crate version
must pick one answer per mechanism, not inherit the table.
## Decision
The contract fixes three things:
### 1. The unified wake contract: opaque wake + re-read state
`listen()` (and stream/queue subscription wake) delivers an **opaque
wake signal: "something changed; re-read the state you care about."**
The contract never carries semantic content, ordering promises, or
change descriptions. Consumers that need the actual data re-read it
(the hot-path pattern the ecosystem already uses: a subscriber holding
a cache invalidates on wake and re-reads, instead of re-polling).
- SQLite: `data_version` watcher fires on commit; wakes **coalesce**
under bursts (overtriggering on purpose — "one indexed SELECT is
cheap; a missed wake is a correctness bug").
- Postgres: LISTEN delivers per-notification (no coalescing), and the
forwarder's synthetic reconnect-wake covers connection gaps.
- Both: consumer code must be correct if wakes repeat, coalesce, or
arrive in any order. **Wakes are best-effort hints, not a per-commit
delivery guarantee** — SQLite coalescing and Postgres's no-replay hole
(a commit during a connection gap is never re-delivered) both mean
individual changes can get *no* wake; recovery is consumers' re-read,
the reconnect-wake, and idempotence — never a promised per-commit
deliver. Exactly-once *processing* semantics belong to queues/streams
(below), never to the wake layer.
Measured ground: wake latency p50 ≈ 1.1–2.2 ms on both engines at
default cadence; per-payload burst delivery verified (30/30 on pg,
coalesced-but-complete re-read on SQLite).
### 2. Delivery guarantees, pinned per mechanism (no per-binding table)
| Mechanism | Durability | Replay | Atomicity | Guarantee |
|---|---|---|---|---|
| `notify` / listen | none | never | commit-atomic (delivers at commit; rollback drops) | fire-and-forget, at-most-once per listener session |
| streams | durable table row | yes, per-consumer offset, replay-on-attach default | publish is commit-atomic | every committed event readable by every consumer that hasn't passed its offset; **global FIFO by offset within each stream** (read paths yield `offset ASC`; offsets immutable, never renumbered — [ADR-015](015-streams-depth.md) §4) |
| queues | durable row | claim/ack model | enqueue is commit-atomic | at-least-once *work* with visibility timeouts |
- The trait **does not promise replay under `listen()`** — on either
engine. Durability+replay needs are what streams are for; this split
is the contract's load-bearing line.
- Listener sessions start "from now" (SQLite: the watcher's current
state; Postgres: `MAX(id)`-equivalent at attach). History belongs to
streams.
- Wake *delivery* failures surface, not silence: watcher death closes
subscriber channels (SQLite, honker-core's
`WatcherDeathGuard` behavior); Postgres connection gaps surface as
the synthetic reconnect-wake on a reserved channel
([ADR-004], [engine-postgres.md]).
### 3. Reserved names are a contract surface
The Postgres forwarder's synthetic reconnect-wake needs a reserved
channel namespace no consumer channel may collide with. The naming
convention for reserved/meta channels (and any internal queue/stream
names) is pinned in the core contract — exact strings pinned by
[ADR-008](008-contract-v1-pinning.md) §4; the *existence of a reserved
namespace* is decided here.
### The caching-subscriber pattern rides the same contract
A subscriber that also holds a cache gets invalidation from the
opaque wake + re-read: the contract deliberately does not carry
key-level payloads, so cache clients key their invalidation on their
own read-set, informed by channel names (the channel name is the one
piece of semantic content `listen()` carries). Key-payload design,
if any consumer needs richer invalidation, is a
consumer-inventory-row-gated extension — not assumed now.
## Consequences
**Positive**
- One wake contract, verified identically on both engines — the
"how does a change become visible" question alkstore exists to
answer, answered once.
- The delivery-guarantee table is pinned per mechanism, killing the
per-binding ambiguity class honker's docs exhibit.
- Consumer code branches on *mechanism choice* (notify vs streams vs
queues), never on engine type (guiding principle 4; the honest
single/multi-host line is [deployment.md]'s and OQ-08's, not this
contract's).
**Negative**
- The opaque wake is deliberately less informative than
key-payload notify systems; consumers needing fine-grained
invalidation pay a re-read or need streams.
- Wake overtriggering on SQLite means consumers must be idempotent on
wake (a documented consumer obligation, part of the contract text).
## References
- `docs/research/poc-sqlite-posture-findings.md` §Probe 2,
§"What feeds where"; `docs/research/poc-pg-posture-findings.md`
§Sub-module W.
- OQ-ST-04 (`docs/research/phase-0.md`) — de-risked by these
measurements; the contract remainder resolved by
[ADR-008](008-contract-v1-pinning.md) over `core-contract.md`.
- [ADR-002](002-feature-scope.md) — notify/listen and streams are
first-class; the guarantee split is why both exist.
- [ADR-004](004-postgres-driver.md) — the forwarder owning the
Postgres side of this contract.
- [ADR-007](007-transactional-seam.md) — commit-atomicity's mechanism
(the `*_tx` seam).
- [core-contract.md](../core-contract.md) — the spec carrying this
contract's full surface.
[ADR-004]: 004-postgres-driver.md
[deployment.md]: ../deployment.md
[engine-postgres.md]: ../engine-postgres.md