- C2: 'at-least-once wake delivery' collapsed to 'best-effort hints' (ADR-006 §1 + core-contract) — coalescing and the pg no-replay hole contradict per-commit wake promises; the guarantee table's row was already correct. - W2: uniform job-handle validity predicate pinned (ADR-010 §2, core-contract, queues.md; verification-backlog row): processing state + unexpired deadline; D-12's missing-check not inherited. - W1: outbox run_once worker semantics pinned in core-contract (pull op, ack/retry-on-curve, no heartbeat in delivery — honker parity, dual-execution window documented). - W3: named-locks tx-seam posture stated (no lock_tx; acquisition is auto-commit; TTL discipline governs). - C1 -> OQ-12: streams depth (key semantics w/ honker ground, event shape, ordering row, retention); method names pinned, depth open. - New find -> OQ-13: the v1 TxHandle surface cannot express the transactional outbox enqueue (derived backing-queue name is reserved-prefix-rejected; honker's raw-Transaction seam unavailable); option set + decision rule sketched. - README resolution order refreshed (OQ-06 resolved); ScheduleOpts + stream consumption-trigger lines added to core-contract.
137 lines
6.4 KiB
Markdown
137 lines
6.4 KiB
Markdown
# 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 |
|
||
| 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
|