feat(out)!: commit-lane consumer gaps — readable commit position, lane-aware fence - #155
bodymindarts wants to merge 2 commits into
Conversation
…e-aware fence
Closes both consumer-side gaps that blocked lana's DW relay from adopting
`Ordering::Commit` against the released 0.11.1 crate
(obix-dev/handoff-commit-lane-consumer-gaps.md rev 1).
Gap A — the commit position was unreachable from a subscriber. It lived on
`CommitOrderedEnvelope`, which only the raw `listen_commit_ordered` listener
yields; the runner unwrapped it into `LaneItem` and kept it to itself. The
relay's whole job is to write that number into the warehouse, so it could
not be written. Now:
- `EventCtx::commit_position() -> Option<(CommitSequence, bool)>` — the
event's lane position and whether it closes its source transaction.
`&self`, so a handler reads it and *then* resolves the ctx.
- `FlushOp::commit_position() -> Option<CommitSequence>` — the position
the batch landed at, which is the last fully handled event's, not a max
over the collected rows (those differ whenever a group's trailing
members were skipped).
Both are `None` on the insert lane. Keyed subscribers never run on the
commit lane, so their `FlushOp` always reports `None`.
Gap B (§10 of handoff-commit-ordered-delivery.md, deferred at #151) — the
snapshot and the fence reported insert-lane positions on both lanes. Under
`Ordering::Commit` the field they read (`state.sequence`, assigned per
delivery in singleton.rs) is a permutation of insert order, so it is
NON-MONOTONE: a single delivery whose insert sequence happens to reach the
sampled frontier satisfied the barrier while events at lower insert
sequences were still undelivered. That breaks the documented semantics-1
guarantee ("never returns early"), it does not merely confuse a number.
- new `StreamPosition { Insert(EventSequence), Commit(CommitSequence) }`
- `SubscriptionSnapshot::{ordering, checkpoint, frontier}` are lane-aware;
the commit-lane frontier is `commit_log_state().last_commit_seq`
- `SubscriptionStreamStatus::{lag, is_caught_up}` keep their signatures
- `await_caught_up` picks the fence from stored state; `await_sequence`
keeps its signature and stays insert-lane (documented as such)
CORRECTION to §10 as written — its step 2 would HANG. It fences on
`commit_log_state().logged_through_sequence >= H`, which is true of what
that watermark MEANS but not of how it MOVES: it is written only by
`append_commit_group`, and the fold returns early (touching no row) for a
placeholder, a `logged_ahead` seed, or a member of an already-seen group.
It is an append watermark, not the fold's stream position, so an aborted
transaction at the head — exactly what inflates `H`, which counts
allocations including aborted ones — parks it below `H` forever. Quiet
periods have the same shape.
Implemented option 1 of the handoff's §4 instead, as recommended: the
sequencer publishes its insert-lane fold position on an `AtomicU64`
alongside the commit head, advanced for EVERY delivery it examines
(placeholders included), and the fence waits on that. No extra writes, no
schema change, no migration. Per-process is acceptable and already implied:
§10 documents that this fence requires a live sequencer, and one is
guaranteed while the subscription's own job is alive. The correction is
recorded in the design doc as CORRECTION 3, at both points the defective
recipe appeared.
Breaking (Rust API only — no migration, no schema, no wire change):
`SubscriptionSnapshot::checkpoint()`/`frontier()` and
`SubscriptionError::CaughtUpTimeout`'s `checkpoint`/`target` fields change
from `EventSequence` to `StreamPosition`. Downstream reached by matching or
wrapping in `StreamPosition::Insert(..)`; `lag()`, `is_caught_up()`,
`await_caught_up()` and `await_sequence()` are source-compatible. The
insert lane is behaviourally untouched, so this is safe to deploy with zero
commit-ordered subscribers.
Tests (handoff §6), each confirmed RED against the unfixed code:
reverting the ctx plumbing fails 1 and 3 on the missing position; reverting
`await_caught_up` to its lane-blind form fails 4 (returns early with half
the lane undelivered) and 5 (wedges on the aborted tail). The interleavings
are seeded directly, as tests/commit_ordered.rs already does, because
insert sequences are allocated in `PersistEvents::pre_commit` — the write
path cannot produce a chosen interleaving of two transactions' sequences,
and a suite whose transactions commit in the order they opened cannot
distinguish the two cursors at all.
Not included, unchanged from #151: keyed subscribers on the commit lane,
and the README "Delivery lanes" section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tarts Matches the insert lane's documented contract — "the frontier read happens before the deadline starts, so the reported `waited` measures the polling, and total call time is that read plus at most `timeout`". The commit-lane fence was starting its clock first, charging the H read against the caller's timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fadcc9b. Configure here.
| // skips — fold_position must advance on placeholders too, or the | ||
| // commit-lane fence stalls on an aborted tail. | ||
| self.fold_position | ||
| .store(u64::from(sequence), Ordering::Release); |
There was a problem hiding this comment.
Fence can return before append
High Severity
The commit-lane await_caught_up fence treats fold_position as proof that every group up to the call-time insert head is in the commit log, but fold publishes that atomic at the start of each delivery — before append returns. On a single-event transaction at the head (the usual publish-then-fence case), and especially while append is retrying, the fence can sample a stale last_commit_seq and return while that event is still unlogged and undelivered. That reopens the early-return race this change is meant to close.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit fadcc9b. Configure here.
| Some(commit_sequence) => StreamPosition::Commit(commit_sequence), | ||
| None => StreamPosition::Insert(state.sequence), | ||
| } | ||
| } |
There was a problem hiding this comment.
Fresh commit lane uses insert fence
Medium Severity
await_caught_up infers the lane only from a persisted commit_sequence. A commit-lane subscription that has not checkpointed yet is classified as insert and waits on state.sequence, which is not monotone under commit order. Until the first flush or consume, the same permutation that this PR fixes can still satisfy the insert-cursor fence while later groups are undelivered.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit fadcc9b. Configure here.
|
Superseded by a lane-typed redesign ( The Gap B fence correction and the regression tests from this PR are carried into the replacement, which is implemented from |
…ry is accounted for `fold_position` was stored on entry to `fold`, before `append_commit_group` runs. The commit-lane fence reads that position and then reads the log head, so there was a window — one append round trip wide — in which it saw the fold past the insert frontier but read a head the in-flight append had not written yet. It then waited for a commit position already reached and returned Ok with the frontier event undelivered. A single-event transaction at the frontier is the common case, not a corner. Found by Bugbot on #156. The handoff (§6, and PR #155 before it) specified this placement explicitly — "FIRST statement after `let sequence = ...`" — for a real reason: the position must advance on placeholders and already-logged members, or the fence stalls on an aborted tail. That reason is satisfied by advancing on every early return, not by advancing before the work. Splitting the body into `place()` and publishing after it keeps the stall fix and removes the early-return hazard. Test 19 holds the `FOR UPDATE` row lock `append_commit_group` needs, so the sequencer parks inside the append and the window stays open deterministically instead of being one statement wide. Confirmed RED first: `await_caught_up` returned `Ok(())` with commit position 2 undelivered. Test 7 (the aborted tail) still passes, which is the property the old placement protected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry is accounted for `fold_position` was stored on entry to `fold`, before `append_commit_group` runs. The commit-lane fence reads that position and then reads the log head, so there was a window — one append round trip wide — in which it saw the fold past the insert frontier but read a head the in-flight append had not written yet. It then waited for a commit position already reached and returned Ok with the frontier event undelivered. A single-event transaction at the frontier is the common case, not a corner. Found by Bugbot on #156. The handoff (§6, and PR #155 before it) specified this placement explicitly — "FIRST statement after `let sequence = ...`" — for a real reason: the position must advance on placeholders and already-logged members, or the fence stalls on an aborted tail. That reason is satisfied by advancing on every early return, not by advancing before the work. Splitting the body into `place()` and publishing after it keeps the stall fix and removes the early-return hazard. Test 19 holds the `FOR UPDATE` row lock `append_commit_group` needs, so the sequencer parks inside the append and the window stays open deterministically instead of being one statement wide. Confirmed RED first: `await_caught_up` returned `Ok(())` with commit position 2 undelivered. Test 7 (the aborted tail) still passes, which is the property the old placement protected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry is accounted for `fold_position` was stored on entry to `fold`, before `append_commit_group` runs. The commit-lane fence reads that position and then reads the log head, so there was a window — one append round trip wide — in which it saw the fold past the insert frontier but read a head the in-flight append had not written yet. It then waited for a commit position already reached and returned Ok with the frontier event undelivered. A single-event transaction at the frontier is the common case, not a corner. Found by Bugbot on #156. The handoff (§6, and PR #155 before it) specified this placement explicitly — "FIRST statement after `let sequence = ...`" — for a real reason: the position must advance on placeholders and already-logged members, or the fence stalls on an aborted tail. That reason is satisfied by advancing on every early return, not by advancing before the work. Splitting the body into `place()` and publishing after it keeps the stall fix and removes the early-return hazard. Test 19 holds the `FOR UPDATE` row lock `append_commit_group` needs, so the sequencer parks inside the append and the window stays open deterministically instead of being one statement wide. Confirmed RED first: `await_caught_up` returned `Ok(())` with commit position 2 undelivered. Test 7 (the aborted tail) still passes, which is the property the old placement protected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry is accounted for `fold_position` was stored on entry to `fold`, before `append_commit_group` runs. The commit-lane fence reads that position and then reads the log head, so there was a window — one append round trip wide — in which it saw the fold past the insert frontier but read a head the in-flight append had not written yet. It then waited for a commit position already reached and returned Ok with the frontier event undelivered. A single-event transaction at the frontier is the common case, not a corner. Found by Bugbot on #156. The handoff (§6, and PR #155 before it) specified this placement explicitly — "FIRST statement after `let sequence = ...`" — for a real reason: the position must advance on placeholders and already-logged members, or the fence stalls on an aborted tail. That reason is satisfied by advancing on every early return, not by advancing before the work. Splitting the body into `place()` and publishing after it keeps the stall fix and removes the early-return hazard. Test 19 holds the `FOR UPDATE` row lock `append_commit_group` needs, so the sequencer parks inside the append and the window stays open deterministically instead of being one statement wide. Confirmed RED first: `await_caught_up` returned `Ok(())` with commit position 2 undelivered. Test 7 (the aborted tail) still passes, which is the property the old placement protected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ct flush position, lane-aware fence, commit lane opt-in (#156) * feat(out)!: lane-typed delivery — one handler API for both lanes A position is a property of a delivery on a lane, not of the event row: a pre-commit event cannot have a commit position, and the same event has one on the commit lane and none on the insert lane. 0.11 expressed that with a second envelope type on one lane only, which left the commit position unreadable from a subscriber and the flush watermark unreadable on both. Make the lane a type parameter instead. `Delivery<L, T>` carries `L::Position` and derefs to what it wraps, so `EventDelivery<P, L>` behaves like the `Arc<PersistentOutboxEvent<P>>` it replaces; `SingletonSubscriber<P, L = InsertOrder>`, `FlushOp<'_, L>`, `Subscription<P, Tables, L>` and `SubscriptionSnapshot<L>` follow it. Insert-lane code reads `EventSequence`, commit-lane code reads `CommitSequence`, and the compiler refuses to run a commit-lane handler on the insert lane. The lane now comes from the handler's impl rather than `OutboxEventJobConfig::ordering`: it is a semantic contract (what a flush boundary is, what the checkpoint counts), and 0.11 already refused to move an established subscription between lanes. Also closes two holes the old shape left open: `handle_undecodable` receives the lane position, and `FlushOp::position()` reports exactly where a batch lands — the last fully handled event, which `max` over the collected items understates whenever the batch ended on skipped events. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(out): lane-typed delivery — positions, the exact flush watermark, the corrected fence, and the opt-in lane Every mechanism test here was confirmed RED against the unfixed code, not merely green after: - flush watermark not threaded from the job state -> tests 1, 3 report position 0 instead of the last fully handled event; - commit position taken from the event's insert sequence -> tests 2, 4, 5 report 1,4,7,2,5,8,3,6,9 instead of a dense 1..=9, and slot 2 instead of 3; - the commit-lane fence comparing the insert cursor against the insert frontier -> test 6 returns with half the stream undelivered; - the fence polling logged_through_sequence instead of the sequencer's fold position -> test 7 wedges on an aborted tail (checkpoint insert:1, target insert:3, timed out); - the commit-lane frontier read from the sequence generator -> test 8 reports 13 instead of the log head 3; - load() without the lane-agreement check -> test 9 reports a number counting the other lane instead of refusing; - the sequencer spawned regardless of config -> test 12 finds a log where none should exist; - the sequencer starting at the head instead of logged_through_sequence -> tests 13, 14 never see the history at all. Three of those reverts also exposed tests that were green for the wrong reason and were re-seeded so they discriminate: 4, 5 and 8 all had layouts in which the commit slot and the insert sequence happened to be the same number. Interleaved group layouts are seeded by SQL with explicit sequence/commit_xid plus a setval, because insert sequences are allocated in PersistEvents::pre_commit — the write path cannot produce a chosen interleaving, and without the setval the frontier reads 0 and every fence passes trivially. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(out): drop redundant explicit link targets in the lane module `cargo doc` denies `rustdoc::redundant_explicit_links`. Baseline on main is 3 pre-existing broken private-item links; this keeps the count at exactly those 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(out): one handle, one transport, one listener for both lanes The lane type stopped at the handler; below it the two lanes still carried two copies of everything between the source and the runner. The two handles had the same four fields; the two listeners were the same ~230-line state machine (backfill drain with `can_deliver`, broadcast drain to capacity, `pop_first` contiguity loop, backfill request when behind the head), differing only in whether they refreshed `latest_known` from the head. The irreducible asymmetry is how a position is ASSIGNED: Postgres allocates the insert position inside the writer's transaction (hence the microsecond in-process broadcast), the sequencer assigns the commit position at read time by a CAS append (hence one round trip and a DB source). That is the source. Everything downstream of "a stream of positioned deliveries, a head, and a backfill channel" is lane-agnostic, so the lane type now reaches exactly that far and no further — the backfill servers and page queries stay lane-specific and untouched. - `LaneHandle<L, P>` replaces `CacheHandle` and `CommitLaneHandle`. - `Transport<L, P> = Delivery<L, PersistentDelivery<P>>` replaces `CommitDelivery`: the internal item IS the public shape, so there is no third envelope type and one `transpose` yields the stream item. - `LaneListener<L, P>` replaces both listeners; `PersistentOutboxListener` and `CommitOrderedListener` are aliases of it and keep their signatures. One behavioural change, and it is an improvement on both lanes: the head refresh the commit listener already did now runs before the pop loop on the insert lane too. Without it a listener that lagged out of the broadcast only discovers the dropped range when the *next* broadcast arrives, so a quiet stream leaves it parked indefinitely. Test 15 pins it. Both lanes keep their lag span names (`obix.persistent_listener.lagged`, `obix.commit_listener.lagged`) so nothing in dashboards moves. The addendum proposed a `LAGGED_SPAN` const for this; `#[instrument]`'s `name` takes a literal, so the lane picks the recorder instead. Net -47 lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(out): lane-type the last insert-sequence leaks, and add the lane-generic entry points `FlushError { after, through }` and `BatchTracker` reported insert sequences on BOTH lanes — the same inconsistency this PR fixes everywhere else. A commit-lane handler downcasting a flush failure to re-attribute it got a range in the other lane's numbering. `FlushError` crosses `handle_persistent` as a boxed error, so it stays non-generic and its positions become `StreamPosition`; `BatchTracker` holds the same dynamic form. `OutboxEventJobState::position() -> i64` is gone. `EventCtx` / `KeyedEventCtx` stay lane-free, which is the point: `CtxParts` travels on them, so an `L` there would leak onto the ctx. The addendum's fallback was to pass the position into `flush_batch`, but `EventCtx::consume` calls it too and has no lane. Instead the erased flusher — the one component that statically knows `L` — answers `position_of(&state)`, and the lane-free plumbing asks it. Stored execution state is unchanged: `{"sequence":N}` on insert, plus `commit_sequence` on commit, both still pinned by their tests. A6 then falls out: `Outbox::listen::<L>` and `Outbox::frontier::<L>` are the general forms, and `listen_persisted` / `listen_commit_ordered` / `highest_known_persistent_sequence` are thin wrappers keeping their signatures. The runner's `select_lane` calls `listen::<L>` too. With one listener type, `Lane` no longer needs a `Listener` GAT — only `handle()`, the single point where the two lanes still differ. `LaneHandle` is sealed the way the lane ops are (private module, reachable but unnameable), so no lane internals reach the public API. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(out): the lane type below the handler — lag recovery, one listener, total boundary, typed flush errors Reverts run for each: - boundary `false` on the insert lane -> test 17's insert half collapses three `max_batch_size = 1` flushes into one: the runner never force-flushes because it believes it is mid-group; - `FlushError` positions taken from the insert cursor -> test 18's commit half reports `flush of batch (commit:0, insert:3]`, the two numberings mixed inside one range; - the backfill request suppressed -> test 15 stalls at 0 of 20. REPORTED, not worked around: the addendum expected test 15 to discriminate A3's head refresh, and it does not. A lagged `BroadcastStream` yields `Lagged(n)` and then the surviving suffix, whose highest position IS the head, so `latest_known` reaches the head through the broadcast with or without the refresh. The refresh is kept — the commit listener always had it, and it covers the case where the cache advanced `highest_known_sequence` while a contiguity gap held the broadcast back — but no test here separates the two and the test's doc comment now says so rather than claiming a red condition that does not reproduce. Test 16 is the compile-time half: one function generic over `Lane` drives either lane's listener, which only type-checks because they are one type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(out): say what the commit-lane fence does during a catch-up `await_caught_up` on a lane that has just been enabled on a database with history waits for the whole backfill to reach the sampled frontier. That is correct — nothing below it has been delivered on this lane yet — but it is a fence over a backfill, and a caller sizing a timeout for steady state will read it as a stall. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(out): publish the sequencer's fold position only after the delivery is accounted for `fold_position` was stored on entry to `fold`, before `append_commit_group` runs. The commit-lane fence reads that position and then reads the log head, so there was a window — one append round trip wide — in which it saw the fold past the insert frontier but read a head the in-flight append had not written yet. It then waited for a commit position already reached and returned Ok with the frontier event undelivered. A single-event transaction at the frontier is the common case, not a corner. Found by Bugbot on #156. The handoff (§6, and PR #155 before it) specified this placement explicitly — "FIRST statement after `let sequence = ...`" — for a real reason: the position must advance on placeholders and already-logged members, or the fence stalls on an aborted tail. That reason is satisfied by advancing on every early return, not by advancing before the work. Splitting the body into `place()` and publishing after it keeps the stall fix and removes the early-return hazard. Test 19 holds the `FOR UPDATE` row lock `append_commit_group` needs, so the sequencer parks inside the append and the window stays open deterministically instead of being one statement wide. Confirmed RED first: `await_caught_up` returned `Ok(())` with commit position 2 undelivered. Test 7 (the aborted tail) still passes, which is the property the old placement protected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(out)!: compute commit positions instead of materialising a commit log Implements the handoff's REV 3 ADDENDUM. The commit lane keeps every public shape from rev 1/rev 2 — and the same position VALUES — but stops writing them down. Measured motivation (sb-max15, both lanes running with zero commit-lane consumers): the sequencer was 4.1% of DB statement exec time, 7.5% of shared blocks, 2.4 GB of WAL and 1.16 GB on disk, at 137 blocks per append for ~9 rows — the append's group CTE had no `sequence` bound, so every call walked every partition's `commit_xid` index. It kept up at 2 loans/s and fell behind at 12, ending the ladder 4.28 M rows behind with a 49-minute catch-up. The premise rev 2's A7 withdrew is now withdrawn itself: the numbering does NOT need the CAS. §6b already proved placement is a pure function of the persisted table; I9 shows the numbering is too — when the fold reaches a group's lowest member, the rows emitted before it are determined by the table and that position, so `head + k` is exactly what `last_commit_seq + ROW_NUMBER()` computed under the lock. Every `Enabled` process derives the same positions independently, with no lock, no log and no coordination. - Schema: `persistent_outbox_commit_log` (+ partitions) and `persistent_outbox_commit_log_state` are dropped; `persistent_outbox_commit_checkpoints` replaces them — one sparse row per `commit_checkpoint_every` groups or `commit_checkpoint_interval`, holding `(sequence, commit_seq, open_groups)`. - `MailboxTables`: the four commit-log methods become `load_group_members` (one statement per PAGE of first-sight groups, bounded below by the lowest MIN so it prunes partitions) plus three checkpoint accessors. - Sequencer: `append` becomes a batched fetch; `deliver_tail` and `logged_ahead` are deleted (`seen` seeded from `open_groups` does that job); checkpoints are written fire-and-forget off the fold. - Backfill: re-folds from the nearest checkpoint instead of paging a log, handing off to the live broadcast at the head sampled when the request arrived. - Fence and frontier read this process's in-process head. Documented semantic change: `SubscriptionSnapshot::frontier()` on the commit lane is now this process's head, not a cluster-wide one. Deviation from R3, reported: the checkpoint write is an UNTARGETED `ON CONFLICT DO NOTHING`, not `ON CONFLICT (sequence)`. Two processes need not pick the same `sequence`, and two different sequences can share a `commit_seq` when only placeholders or straddling members separate them, so naming one target would collide with the `commit_seq` UNIQUE and fail the write. Either row is a valid resume point. The evidence that no consumer-visible number changed is that the entire lane suite from rev 1/rev 2 — exact positions, densities, group contiguity, boundaries, the two-stage fence, late enable, restart resumption — passes UNCHANGED against the computed implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(out): retry an empty group fetch instead of dropping the group Bugbot: a first-sighted payload-bearing group whose load_group_members result came back without that group at all was treated as "no members" and silently, permanently skipped from the commit lane — the fold position still advanced past it. Fetch retried on SQL errors but not on this shape of miss. By construction every member of a commit group is inserted in the same transaction as the one that produced the first-sighted delivery, so by the time that delivery is visible on the insert lane every other member is already committed too. An empty result is therefore never the group's true membership; it can only be a transient visibility miss, and is now retried exactly like a fetch error (with a warn-level span, since this should never fire) rather than accepted. Also documents the migration-checksum consequence Bugbot flagged separately: the setup migration is amended in place per this repo's established pre-1.0 convention (5aa5377, 2d233eb), so a database that already has the superseded commit_log tables never gets commit_checkpoints and needs recreating — not a new defect, but undocumented at the point of change until now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: cut the comments back to what earns its keep The comment budget had drifted into narrative: design history, rejected alternatives, and prose restating code that reads fine on its own. Every site is now one or two lines at most. Deleted outright is everything that only parses against a superseded design — the materialised commit log and its `logged_through_sequence` watermark, the amend-in-place migration-checksum convention, the enable-the-lane-later runbook and its operator UPDATE, the `await_sequence` rename note, the "before the fix" listener stories, and the REV-numbered test headers. obix is pre-release, so there is no prior version for a reader to reconcile against. What survives: the INVARIANT comments that stop a correctness regression (the fold-position publish order, the empty-group fetch, the listener's head refresh before its pop loop, and the two SQL invariants in the macro), the seed-layout decoders the lane tests are unreadable without, and one-line statements of what each public item is for. Comments only — no code, test name, assertion, attribute or import changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(out): measure the busy-stream precondition in time, not publishes `a_member_passivates_while_the_shared_stream_stays_busy` asserted that at least 5 events had been published by the time the member passivated, as a proxy for "the stream was still busy". The proxy encoded machine speed: the window to passivation is the 150ms linger plus the 20ms poll granularity, so 5 publishes at a 30ms cadence needed a publish to cost under ~4ms. Locally one costs ~0ms and 6 land; on a contended CI runner one costs ~13ms and 4 do, so the precondition failed while the behaviour under test was correct. Measure the property directly instead: the publisher records when it last published, and the test asserts the stream had not been quiet for longer than the member's own linger when it passivated. That is machine-speed independent — and strictly stronger, since the cadence also drops to 10ms, putting more foreign traffic across the member's stream during the linger window than before. The guard itself is untouched: passivation still has to happen against live traffic, which is what `Outcome::Skip` leaving the linger deadline armed buys. Verified by construction — emulating CI's publish latency reproduces the old failure exactly (`published: 4`), the new assertions pass under it, and they still fail when traffic genuinely dries up (quiet for 160ms with 2 published). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>


Closes both consumer-side gaps that blocked lana's DW relay from adopting
Ordering::Commitagainst the released 0.11.1 crate. Working from obix-dev/handoff-commit-lane-consumer-gaps.md, rev 1 (2026-09-16).Base: rebased onto
origin/main@365e6d1(0.11.2-dev). PR #154 has landed — it is in 0.11.1 (6a0b2dd), so theOutboxPayload/as_eventtrait bound is already the shipped shape here; nothing in this PR touches it.FOR THE LANA CONSUMER — the API you adopt against
This is the whole Rust-side surface. Note the fence differs from §10 as written (see CORRECTION below) — a consumer following the original spec text builds the wrong thing.
Reading your commit position (Gap A — this did not exist at all before):
FlushOp::commit_position()is the last fully handled event's position, notmaxover the collected rows. Those differ whenever a group's trailing members were skipped, and the batch one is the correct watermark — use it rather than folding over your own rows. Both accessors areNoneon the insert lane.Fencing (Gap B / §10):
await_caught_uppicks the right fence from stored state — no new parameter, nothing to pass. This is whatverify_party_pii_erased.rs:78already calls; it keeps working and is now actually sound on the commit lane (before this PR, moving the relay toOrdering::Commitsilently converted that GDPR-erasure check into a race).What
StreamPositionrequires you to match on — this is the breaking part:SubscriptionSnapshot::checkpoint()EventSequenceStreamPositionSubscriptionSnapshot::frontier()EventSequenceStreamPositionSubscriptionError::CaughtUpTimeout { checkpoint, target }EventSequenceStreamPositionSubscriptionSnapshot::ordering()Ordering, if you only need the lanelag(),is_caught_up(),stream_status()await_caught_up(),await_sequence()Call sites break only where they compare or assert on a raw position; the fix is to wrap (
StreamPosition::Insert(seq)) or match.StreamPositionderivesOrd, socheckpoint() >= StreamPosition::Insert(target)works — but comparing across variants is meaningless, so only compare positions read from the same subscription. The in-repo migration oftests/registered_handler.rs+tests/keyed_subscriber.rsin this PR is a worked example of the full downstream change.await_sequence(target: EventSequence, ..)keeps its signature and stays insert-lane: it comparesstate.sequence, which is not monotone under commit ordering. A commit-lane subscription fences withawait_caught_up.Gap A — the commit position was unreachable
It lived on
CommitOrderedEnvelope, which only the rawlisten_commit_orderedlistener yields;singleton.rsunwrapped it intoLaneItemand kept it.handle_persistentgets&Arc<PersistentOutboxEvent<P>>, which carries no lane position by design, andEventCtx/FlushOpexposed nothing. The relay's entire job is to write that number into the warehouse, so it could not be written. The runner already heldcommitin scope at the ctx construction site; this carries it through.Gap B — the fence was not lane-aware (§10, deferred at #151)
singleton.rsassignsstate.sequence = event.sequenceon the commit lane too. Commit order is a permutation of insert order, so that field goes down as well as up.await_sequencepolledcheckpoint >= target, so a single delivery whose insert sequence happened to reach the sampled frontier satisfied the barrier while events at lower insert sequences were still undelivered — it returned early, violating the documented semantics-1 guarantee rather than merely reporting a confusing number.Now:
StreamPosition, lane-aware snapshot accessors, commit-lane frontier fromcommit_log_state().last_commit_seq, and a commit-lane fence.CORRECTION — §10's step 2 as written would HANG
The spec fences on
commit_log_state().logged_through_sequence >= H. That is true of what the watermark means but not of how it moves: it is written only byappend_commit_group, andSequencer::foldreturns early — touching no row — for a placeholder, alogged_aheadseed, or a member of an already-seen group. It is an append watermark, not the fold's stream position, so an aborted transaction at the head (exactly what inflatesH, which counts allocations including aborted ones) parks it belowHforever. Quiet periods have the same shape. A hang is the worst failure shape to debug, because the logs say nothing.Implemented option 1 of §4, as recommended: the sequencer publishes its insert-lane fold position on an
AtomicU64alongside the commit head, advanced for every delivery it examines — placeholders included — and the fence waits on that. No extra writes, no schema change. Per-process is acceptable and already implied: §10 documents that this fence requires a live sequencer, and one is guaranteed while the subscription's own job is alive. The wait is bounded by the sequencer listener's own gap-fill grace, the same wait the insert-lane fence implies today.Recorded in
obix-dev/handoff-commit-ordered-delivery.mdas CORRECTION 3, fixed at both points the defective recipe appeared (§10's prose and Appendix B's "Fence" pseudo-code), re-fetched and verified afterwards — the phrase survives only inside the correction blocks that quote it to explain why it is wrong.Deliberate decisions NOT touched
sequencer.rsis publishing the fold position; the placement algorithm andappend_commit_groupare byte-identical in behaviour.SubscriptionDef::wake_keysstays a plain reference — not touched.&Arc<..>— not touched.Regulatory note (FFIEC-051)
This changes no delivery granularity, ordering, checkpoint timing, or at-least-once/at-most-once property of either lane. Gap A is a pure read of a value the runner already tracked. Gap B makes a barrier that could return early wait correctly — strictly more conservative, in the direction of not letting a consumer act on a half-applied prefix. The insert lane is behaviourally untouched.
Migration / deployment
No migration. No schema change, no
job_setup.sqlchange, no checksum change — so nomake clean-depsand no consumer hand-copy of SQL is needed. The!is for the Rust API break only (StreamPosition), which is a compile error downstream, not a silent one.Verification (run separately, as stated)
nix run .#nextest— 156/156 passed, stable across three consecutive runs. This is what runs the tests;nix flake checkdoes not.nix flake checkcomponents —cargo fmt --checkclean,cargo clippy --all-targets --all-features -- -D warningsclean.cargo doc --no-deps --all-features— fails with exactly the 3 pre-existing broken intra-doc links onsrc/config.rs,src/out/ctx.rs,src/out/partition/mod.rs, all three confirmed byte-identical toorigin/main(those files are either untouched or unchanged in that region). One new warning I introduced (a doc link to a private method) was found this way and fixed, so the count is back to the baseline 3.Revert-to-red
Every test proving a mechanism was confirmed RED against unfixed code, deterministically and for the right reason:
EventCtx::commit_position()returnsNone(the pre-change state: runner keepscommitto itself)commit lane must report a positionawait_caught_uprestored to its lane-blind form (insert frontier vs insert cursor)await_caught_up returned while commit positions 3 and 4 were still undelivered; test 5 FAILS — wedged,checkpoint insert:2 behind target insert:3logged_through_sequence >= H(the defective §10 text)CaughtUpTimeout: checkpoint insert:1 behind target insert:3 after 15.0s, i.e. the hang, reproducedTwo tests were vacuous when first written and were fixed, not accepted. Both are worth recording, because the same trap will catch the lana adoption:
PersistEvents::pre_commit, not at publish time. So an interleaving built by holding ops open and publishing into them produces no interleaving at all (each transaction's sequences come out contiguous), and a dropped op burns no sequence at all. The tests now seed rows directly — the techniquetests/commit_ordered.rsalready uses and documents for exactly this reason — and burn the aborted tail with a raw INSERT that is rolled back. Explicit-sequence inserts do not move the generator, so the seeds alsosetvalit; without that the frontier reads 0 and anything fencing on it passes trivially.handle_persistentcan never persist one. The fence test now commits one event in its own op first, and asserts its preconditions (ordering() == Commit, commit cursor 2 of 4, insert frontier == the insert cursor) before fencing — the last of those being precisely the state an insert-cursor fence wrongly accepts as caught up.No sleep-based synchronisation: conditions are polled, and the "must not return" assertion uses a bounded
timeoutwindow that resolves the instant the barrier completes. Neither fragile test was touched or re-thresholded —tests/keyed_subscriber.rs:1974andslow_consumer_does_not_inflate_the_rows_readboth pass, and this change adds no new reader ofpersistent_outbox_events(the fence reads the sequence generator and the commit-log state table).Comment diff
src/gains exactly one new inline comment — anINVARIANT:note on the fold-position store, matching the form already insingleton.rs, guarding the one thing a later reader could silently regress (it must advance on placeholders too, or the fence wedges). One pre-existing comment moved with the poll loop into the extracted helper. Everything else is rustdoc on new public API. Rationale lives here and in the commit message.Note
Medium Risk
Breaking public API (
StreamPosition) and changes commit-laneawait_caught_upsemantics in the outbox consumer path; incorrect fencing would let callers act on partially applied streams, though the change is strictly more conservative than the prior commit-lane behavior.Overview
Closes commit-lane adoption blockers by exposing commit positions to handlers and making subscription fencing lane-aware, with a breaking
StreamPositiontype for checkpoints and frontiers.Gap A:
EventCtx::commit_position()andFlushOp::commit_position()surface commit-lane sequence and boundary info (bothNoneon the insert lane). Batch flush passes the runner’s commit checkpoint intoFlushOpso warehouse-style subscribers can persist watermarks without using the raw commit listener.Gap B:
SubscriptionSnapshotandCaughtUpTimeoutnow useStreamPosition(InsertvsCommit) instead of bareEventSequence.await_caught_upbranches on lane: insert behavior is unchanged; commit-lane subscriptions use a two-step fence that first waits on the local sequencer’sfold_position(insert sequences the fold has examined, including placeholders), then polls the commit cursor—fixing early return when insert cursor hits the frontier before all commit deliveries, and hangs when fencing onlogged_through_sequencebehind an aborted tail.The sequencer publishes
SequencerPositionsinto residentSubscriptionhandles; singleton registration wires this fromOutbox::sequencer_positions().Reviewed by Cursor Bugbot for commit fadcc9b. Bugbot is set up for automated code reviews on this repo. Configure here.