Skip to content

feat(out)!: commit-lane consumer gaps — readable commit position, lane-aware fence - #155

Closed
bodymindarts wants to merge 2 commits into
mainfrom
task/obix-commit-lane-consumer-gaps-01a0a9eb
Closed

bodymindarts wants to merge 2 commits into
mainfrom
task/obix-commit-lane-consumer-gaps-01a0a9eb

Conversation

@bodymindarts

@bodymindarts bodymindarts commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes both consumer-side gaps that blocked lana's DW relay from adopting Ordering::Commit against 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 the OutboxPayload/as_event trait 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):

// registration — unchanged from the prior handoff
OutboxEventJobConfig::new(DW_OUTBOX_RELAY_JOB).ordering(Ordering::Commit)

// in handle_persistent — read BEFORE resolving the ctx (the verbs consume it)
let (commit_sequence, is_boundary) = ctx.commit_position().expect("commit lane");
ctx.collect(LanaDwRow { commit_sequence: i64::from(commit_sequence), .. })

// in flush — the position the batch actually landed at
let watermark = op.commit_position();   // Option<CommitSequence>

FlushOp::commit_position() is the last fully handled event's position, not max over 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 are None on the insert lane.

Fencing (Gap B / §10):

lana_relay_fence.await_caught_up(Duration::from_secs(60)).await?;   // signature UNCHANGED

await_caught_up picks the right fence from stored state — no new parameter, nothing to pass. This is what verify_party_pii_erased.rs:78 already calls; it keeps working and is now actually sound on the commit lane (before this PR, moving the relay to Ordering::Commit silently converted that GDPR-erasure check into a race).

What StreamPosition requires you to match on — this is the breaking part:

pub enum StreamPosition { Insert(EventSequence), Commit(CommitSequence) }
API before after
SubscriptionSnapshot::checkpoint() EventSequence StreamPosition
SubscriptionSnapshot::frontier() EventSequence StreamPosition
SubscriptionError::CaughtUpTimeout { checkpoint, target } EventSequence StreamPosition
SubscriptionSnapshot::ordering() new: Ordering, if you only need the lane
lag(), is_caught_up(), stream_status() unchanged signatures
await_caught_up(), await_sequence() unchanged signatures

Call sites break only where they compare or assert on a raw position; the fix is to wrap (StreamPosition::Insert(seq)) or match. StreamPosition derives Ord, so checkpoint() >= StreamPosition::Insert(target) works — but comparing across variants is meaningless, so only compare positions read from the same subscription. The in-repo migration of tests/registered_handler.rs + tests/keyed_subscriber.rs in this PR is a worked example of the full downstream change.

await_sequence(target: EventSequence, ..) keeps its signature and stays insert-lane: it compares state.sequence, which is not monotone under commit ordering. A commit-lane subscription fences with await_caught_up.


Gap A — the commit position was unreachable

It lived on CommitOrderedEnvelope, which only the raw listen_commit_ordered listener yields; singleton.rs unwrapped it into LaneItem and kept it. handle_persistent gets &Arc<PersistentOutboxEvent<P>>, which carries no lane position by design, and EventCtx/FlushOp exposed nothing. The relay's entire job is to write that number into the warehouse, so it could not be written. The runner already held commit in scope at the ctx construction site; this carries it through.

Gap B — the fence was not lane-aware (§10, deferred at #151)

singleton.rs assigns state.sequence = event.sequence on the commit lane too. Commit order is a permutation of insert order, so that field goes down as well as up. await_sequence polled checkpoint >= 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 from commit_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 by append_commit_group, and Sequencer::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. 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 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. 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.md as 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

  • Decision 3 (rev 3) — groups placed at first sight, by group MIN. Untouched. The only change in sequencer.rs is publishing the fold position; the placement algorithm and append_commit_group are byte-identical in behaviour.
  • SubscriptionDef::wake_keys stays a plain reference — not touched.
  • Subscribers still receive &Arc<..> — not touched.
  • Publication delivery stays singleton-only — §15 (keyed on the commit lane) remains out of scope, per the handoff's own non-goals.

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.sql change, no checksum change — so no make clean-deps and 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 check does not.
  • nix flake check components — cargo fmt --check clean, cargo clippy --all-targets --all-features -- -D warnings clean.
  • cargo doc --no-deps --all-features — fails with exactly the 3 pre-existing broken intra-doc links on src/config.rs, src/out/ctx.rs, src/out/partition/mod.rs, all three confirmed byte-identical to origin/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:

Probe Result
EventCtx::commit_position() returns None (the pre-change state: runner keeps commit to itself) test 1 + test 3 FAIL — commit lane must report a position
await_caught_up restored to its lane-blind form (insert frontier vs insert cursor) test 4 FAILS — await_caught_up returned while commit positions 3 and 4 were still undelivered; test 5 FAILS — wedged, checkpoint insert:2 behind target insert:3
fence step 2 restored to logged_through_sequence >= H (the defective §10 text) test 5 FAILS — CaughtUpTimeout: checkpoint insert:1 behind target insert:3 after 15.0s, i.e. the hang, reproduced

Two tests were vacuous when first written and were fixed, not accepted. Both are worth recording, because the same trap will catch the lana adoption:

  1. Insert sequences are allocated in 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 technique tests/commit_ordered.rs already 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 also setval it; without that the frontier reads 0 and anything fencing on it passes trivially.
  2. The commit-lane fence is only taken once a commit cursor has been persisted, and a handler blocked inside handle_persistent can 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 timeout window that resolves the instant the barrier completes. Neither fragile test was touched or re-thresholded — tests/keyed_subscriber.rs:1974 and slow_consumer_does_not_inflate_the_rows_read both pass, and this change adds no new reader of persistent_outbox_events (the fence reads the sequence generator and the commit-log state table).

Comment diff

src/ gains exactly one new inline comment — an INVARIANT: note on the fold-position store, matching the form already in singleton.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-lane await_caught_up semantics 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 StreamPosition type for checkpoints and frontiers.

Gap A: EventCtx::commit_position() and FlushOp::commit_position() surface commit-lane sequence and boundary info (both None on the insert lane). Batch flush passes the runner’s commit checkpoint into FlushOp so warehouse-style subscribers can persist watermarks without using the raw commit listener.

Gap B: SubscriptionSnapshot and CaughtUpTimeout now use StreamPosition (Insert vs Commit) instead of bare EventSequence. await_caught_up branches on lane: insert behavior is unchanged; commit-lane subscriptions use a two-step fence that first waits on the local sequencer’s fold_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 on logged_through_sequence behind an aborted tail.

The sequencer publishes SequencerPositions into resident Subscription handles; singleton registration wires this from Outbox::sequencer_positions().

Reviewed by Cursor Bugbot for commit fadcc9b. Bugbot is set up for automated code reviews on this repo. Configure here.

bodymindarts and others added 2 commits September 16, 2026 14:06
…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>
@bodymindarts
bodymindarts marked this pull request as ready for review September 16, 2026 12:12

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fadcc9b. Configure here.

Some(commit_sequence) => StreamPosition::Commit(commit_sequence),
None => StreamPosition::Insert(state.sequence),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fadcc9b. Configure here.

@bodymindarts

Copy link
Copy Markdown
Member Author

Superseded by a lane-typed redesign (handoff-lane-typed-delivery.md). The EventCtx::commit_position() shape here is withdrawn by design decision: a position is a property of a delivery on a lane, not of the ctx or the event row. The replacement makes the lane a type parameter (Delivery<L, T>, SingletonSubscriber<P, L>, FlushOp<'_, L>, Subscription<P, Tables, L>) so one handler API serves both lanes.

The Gap B fence correction and the regression tests from this PR are carried into the replacement, which is implemented from origin/main.

bodymindarts added a commit that referenced this pull request Sep 16, 2026
…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>
bodymindarts added a commit that referenced this pull request Sep 16, 2026
…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>
bodymindarts added a commit that referenced this pull request Sep 17, 2026
…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>
bodymindarts added a commit that referenced this pull request Sep 18, 2026
…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>
bodymindarts added a commit that referenced this pull request Sep 18, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant