Skip to content

fix(out): catch up at DB speed when the broadcast cursor falls behind committed rows - #157

Merged
bodymindarts merged 10 commits into
mainfrom
task/obix-cache-cursor-catch-up-01a0aba1
Sep 18, 2026
Merged

bodymindarts merged 10 commits into
mainfrom
task/obix-cache-cursor-catch-up-01a0aba1

Conversation

@bodymindarts

@bodymindarts bodymindarts commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

A single transaction committing more outbox events than the cache-fill broadcast can hold (event_buffer_size) dropped most of them to Lagged. The only thing that then moved the central cursor (last_broadcast_sequence) was the GapFiller's grace-gated stall episode — designed for genuinely lost sequences, not a backlog of already-committed ones — so drain throughput was pinned at backfill_page_size / gap_fill_grace (500 events/s in lana's config) regardless of what Postgres could do. Every listener is contiguity-gated behind that cursor. Measured on sb-max15: a 596,371-event single-commit block took the cursor ~20 minutes to cross.

The fix distinguishes "the cursor is behind committed rows" from "the cursor is at a hole" (an in-flight or abandoned writer — unchanged grace-gated path), and serves the first case from a long-lived feeder task that prefers memory over the database.

Implements obix-dev/handoff-cache-cursor-catch-up.md.

How it works

The commit hook does one thing. PersistEvents::post_commit is three lines: it hands its committed batch to CacheFeeder::accept and reports the batch's (min, max) to the debounced notifier. It holds no opinion about cache capacity or broadcast width. accept advances the head watermark and the feeder's published front synchronously, then queues the batch — still owned, never re-read from Postgres.

One executor, two sources. The feeder task (feeder.rs) prefers memory and falls back to the database:

  • Memory, unrequested: whenever the smallest pending in-memory sequence is exactly cursor + 1, it feeds a page and waits for the cursor to consume it. next_after scans every pending batch rather than the oldest, so concurrent commits whose sequences interleave cannot deadlock behind each other.
  • Database, only on request, and bounded: ReadBound::{Memory, Head} stops the read at the first pending in-memory sequence rather than the (memory-inflated) head — reading past it would pull rows out of Postgres that memory is about to feed for free. Bound and cursor are re-derived every iteration, so a batch arriving mid-read lowers the bound immediately.

The cache loop decides, the feeder executes. StallAction::determine(&CursorLag, &StallTracker, threshold) is pure and unit-tested: it returns Nothing, ClearStall, RequestCatchUp or ReportStall. CursorLag carries what is recomputed each iteration (cursor, whether behind, distance_to_unfed, the feeder's memory_next); StallTracker carries the loop's own bookkeeping behind named methods (rearm, rearm_exhaustion, catch_up_finished). All fill policy — grace, abandonment proof, batch caps, cluster dedup — stays in the GapFiller.

FeederHandle is the loop's whole view of the feeder. feeder::spawn builds every channel and returns both sides, so the loop calls cursor_reached / request_catch_up / memory_next / next_report instead of holding four raw channel ends. It follows the existing SequencerHandle and GapFiller { tx, _handle } shape.

Notified ranges stay bounded. A notified-but-uncached range wider than one page is never fetched in a single unbounded SELECT; the catch-up reads it contiguously once the cursor arrives.

Sequencing vs #156

PR #156 ("lane-typed delivery") touches the same files (cache.rs, persist_events_hook.rs, out/mod.rs, gap_fill.rs). I read #156's actual diff on cache.rs before implementing: every change there is a mechanical type substitution (PersistentDelivery<P>InsertTransport<P>, CacheHandle → a LaneHandle alias) — no logic in the region this PR touches (cursor advance, the select! loop, stall reporting, notification handling) is restructured. Basing decision, confirmed with the PM: implement against current main; #156 merges first; this branch then does a normal mechanical rebase, not a redesign.

Invariants

Cursor-advance-before-send is untouched on the existing path: highest_known_sequence.fetch_max(...) still runs before the broadcast loop in insert_into_cache_and_maybe_broadcast. tokio's broadcast send only errors on zero receivers (a full buffer surfaces as Lagged instead), so an advance-after-send design pins the cursor at zero in any process with no receiver on the stream — a defect that presented as a silent hang in a prior incident, and is not reintroduced. CacheFeeder::accept holds the same property by construction: the head advances before the batch is even enqueued.

Ordering and exactly-once hold through the memory path, and it is asserted, not inherited. bulk_commit_larger_than_buffer_drains_at_page_speed checks positional payload match plus strictly increasing sequences across all 200,000 events — a duplicate or reorder anywhere fails it — paired with zero catch_up spans and exactly one memory_feed span, so it is provably the memory path under test rather than something else quietly draining. Same combination across 40,000 events over 20 commits in local_commits_are_fed_from_memory_one_span_per_batch.

Memory and the database cannot disagree on what to deliver. A memory-fed batch holds the same rows a read would return — from the RETURNING clause instead of a later SELECT. There is one source of truth. The only question is who delivers a sequence first, and three layers make double delivery structurally impossible: (1) the read bound is recomputed every iteration; (2) every read is additionally capped at catch_up_page_size, so even a maximally stale bound overshoots by at most one page; (3) the layer that actually matters, unchanged since before this PR: the broadcast walk only ever visits sequences strictly above last_broadcast_sequence, monotonically, and the OrdMap upsert (existing.or(...)) does not overwrite. This is the same mechanism that has always deduplicated notification-fetch against post-commit-broadcast overlap; this PR adds a source into that funnel, not a second funnel.

Nothing accumulates. The database catch-up is a single loop-local flag (StallTracker::catch_up_active), checked before RequestCatchUp is ever returned; the feeder task is spawned once at init, not per episode. Pending in-memory batches are bounded by commits concurrently in flight, not by event volume — one PendingBatch entry per accepted batch regardless of how many rows it holds. Checked by counted observation against a standing trigger (20 separate commits, each re-arming the feeder's re-evaluation loop): exactly 20 memory_feed spans, never one per page or per iteration, and exactly 0 catch_up spans.

Review findings, addressed

Catch-up resetting an unresolved hole's gap-fill grace (Bugbot). Starting a catch-up sent GapFillRequest::StallCleared unconditionally — including when a catch-up merely re-armed and landed back on the hole it already found. That dropped the GapFiller's in-progress episode and forced a fresh grace period; under sustained write load it could reset indefinitely, so an abandoned sequence never becomes fillable. Fixed by moving StallCleared off catch-up start and onto catch-up outcome (StallTracker::catch_up_finished returns whether the episode should clear: only on AtHead/ReachedMemory, or a hole at a genuinely different position), and by scoping the wide-notification re-arm to ranges reaching back to or before the cursor. repeated_unrelated_notifications_do_not_delay_an_unresolved_holes_grace is the timing proof.

A stale front could hide a stall indefinitely (Bugbot). The staleness clamp in memory_next is only correct when the loop's own cursor advance made the front stale. If the cursor instead moved via another mechanism (a GapFiller fill, a bounded catch-up page, fetch_notified_range) while a pending batch's front was stale-low, the clamp read that as "memory is handling this" — and nothing revisited the decision until idle resync (10s default) or an unrelated notification. Confirmed by hand-tracing rather than taken on faith. Fixed by making the loop react to the feeder republishing its front, now FeederReport::FrontMoved. Additive, not a replacement: the clamp remains independently necessary for the case it was built for.

Raw channels and values in cache.rs (review). EventSequence gained prev and distance_to, so cursor arithmetic no longer round-trips through u64::from (Ord was already derived, so comparisons just work) and the watch channels carry EventSequence. Four channel ends became one FeederHandle; nine feeder parameters became struct fields; (u64, bool) became ReadBound; eight positional arguments became StallAction::determine; three loose mutable locals became StallTracker, whose method names are what the deleted comment blocks used to explain.

Two behavioural findings from that refactor

A busy-spin, introduced and then removed. Collapsing the catch-up-outcome and front-moved arms into FeederHandle::next_report fixed a defect introduced earlier in this PR: under biased, a dead feeder made the outcome recv() return None immediately and forever, starving the later arm that would have broken the loop. A correctness test cannot see this — it burns a core while staying green. Now either half closing reports Gone and the loop breaks, and that contract is pinned by three unit tests with confirmed revert-to-red (mapping a dead feeder to a no-op report fails 2 of 3). Swept the rest of the crate for the same shape: 9 biased select! sites, and every arm reading a closable source already exits on close (ephemeral/cache.rs, singleton.rs ×2, keyed/runner.rs, partition/job.rs, both feeder.rs sites, cache.rs); the two unbiased sites (gap_fill.rs, sequencer.rs) also return on close and are structurally immune to starvation anyway. This was an isolated instance, not a pattern in the file.

One watch guard kept, one deleted — both measured. Moving the cursor publish into the decision block raised a wake-up ping-pong concern, so I measured all four combinations by user CPU over the catch_up suite instead of reasoning about it: both guards 12.3s; cursor guard removed only 12.3s (identical — not load-bearing); both removed 20.6s. Only publish_front's guard earns its place (~40% CPU); the cursor-side one was deleted rather than kept on a rationale the measurement did not support. Worth stating plainly: no configuration hung or failed, so this is efficiency, not correctness.

Known gaps

Stated rather than implied, given the sensitivity on this file.

  • Memory-vs-database adversarial interleaving: no test deliberately races a memory-batch arrival against an in-flight bounded read to force layer 1's overshoot window and confirm layer 3 catches it. remote_gap_below_a_parked_batch_is_read_bounded_then_memory_resumes proves the bound in the common case (batch already pending before the read starts), not the adversarial ordering. The three-layer argument above is structural, and two of the three layers predate this PR.
  • The stale-front window: no dedicated adversarial test forces "an external mechanism overtakes a pending batch's front while the feeder is parked". Negative evidence only — the full suite green and unchanged, plus targeted stress on every test whose timing this could disturb.
  • CPU burn as a class: the unit tests pin that a closed channel is reported, and the sweep shows every current arm breaks on it. What is not guarded is the cache loop spinning for some future reason — there is no CPU or iteration-count assertion. A generic spin detector would need either a new dependency (getrusage) or per-iteration instrumentation on a hot path, and an idle-CPU threshold would not have caught this particular defect anyway, since the spin only exists once the feeder is dead, which idle operation never produces. Flagging it so the next person changing that loop knows it is unguarded.
  • Missing or archived prefix: one contiguous-page read comes back empty, the feeder reports HoleAfter and moves on rather than spinning. The position falls to the pre-existing GapFiller stall path, which re-reads at a fixed 1s cadence indefinitely if the gap can never resolve — a known, unchanged limitation, not introduced or worsened here.

What's unchanged (deliberately)

GapFiller semantics (grace, abandonment proofs, batch caps, cluster lock), load_next_page / load_next_contiguous_page SQL, the listener and backfill paths, sequencer, commit lane, keyed subscribers, config surface (no new knobs), schema. No public API change: PersistEvents::new's parameter list changed shape but the module is private, and Outbox's public surface is unchanged.

Regulatory note

Ordering, checkpoint semantics, and exactly-once/at-least-once guarantees are unchanged from main: every persisted row is still delivered exactly once, in sequence order, to every listener — same cursor walk, same duplicate-safe OrdMap insert, same GapFiller as the only placeholder writer. What changes is where committed rows come from for a local commit (process memory instead of a database re-read) and that such a commit now drains at CPU speed rather than DB speed, so listeners lag the broadcast sooner and fall back to per-listener backfill more often under sustained load — a load-shift, not a delivery-semantics change. Flagging per the FFIEC-051 / commit-lane-adoption sensitivity on this file, for reviewer awareness rather than deciding it is fine unilaterally.

Verification

  • nix flake check (fmt, clippy with -D warnings, audit, deny, alejandra): all checks passed.
  • nix run .#nextest (full workspace including doc tests and a cargo doc build), run separately: 180/180 passed. cargo doc shows exactly the 3 broken links that are already on main (config.rs, ctx.rs, partition/mod.rs) — no new ones.
  • tests/catch_up.rs — 9 integration tests; plus 10 unit tests for StallAction::determine / distance_to_unfed / StallTracker in cache.rs and 6 for next_after / next_report in feeder.rs:
    • bulk_commit_larger_than_buffer_drains_at_page_speed — 200,000 events in one commit, ~6s via memory. Zero catch_up spans, exactly one memory_feed span, full ordering and exactly-once.
    • local_commits_are_fed_from_memory_one_span_per_batch — the counted accumulation test above.
    • interleaved_local_commit_below_a_parked_batch_is_fed_from_memory — concurrent commits whose sequences interleave.
    • remote_gap_below_a_parked_batch_is_read_bounded_then_memory_resumes — the bounded database read.
    • repeated_unrelated_notifications_do_not_delay_an_unresolved_holes_grace — the grace-clock regression.
    • remote_bulk_commit_is_caught_up_without_whole_range_fetch — the cross-instance notified path.
    • small_buffer_large_local_commit_is_fed_from_memory — small buffer, large local commit.
    • catch_up_page_survives_a_cache_smaller_than_the_page — trim safety, healed from memory rather than the database.
    • in_flight_bulk_insert_costs_one_probe_then_waits — a live writer holding the gap is a hole, not a backlog; no placeholder while it is in flight.
    • The existing suite passes unmodified, including the two fragile tests flagged in the handoff (keyed_subscriber.rs's exact ping-count assertion and slow_consumer_does_not_inflate_the_rows_read's per-table idx_tup_fetch sample). Thresholds untouched.
  • Revert-to-red, each confirmed by breaking the code and restoring it:
    • Disabling the memory-feed branch reproduces the original failure mode — a 60s timeout on the headline test, via a genuine deadlock (the loop believes memory owns progress and never falls back to a database request). Restored: 6s, green.
    • Removing the read bound makes the exact-1500 assertion fail with 21500, confirming the bound is load-bearing rather than incidental.
    • Mapping a dead feeder to a no-op report (the busy-spin shape) fails 2 of the 3 next_report tests.
    • Race-fix isolation (4 conditions × 20 runs of interleaved_local_commit_...): neither fix 7/20; accept()'s front hint alone 8/20; the cursor-side clamp alone 20/20; both, as shipped, 20/20. The clamp is necessary and sufficient for this test. The accept() hint makes no measurable difference at this sample size — it is kept on structural reasoning about a real window, and is explicitly not test-backed. Said here rather than letting it ride on the clamp's evidence.
  • Stress, not a single green run (this PR changed wake-up timing): the two race-sensitive tests 12/12, the full catch_up suite 5/5 with stable ~20s timings.

Dependency bump (84ab85e)

Carried here because it was blocked and is now unblocked, not because it belongs to the catch-up work. es-entity 0.12.140.13.0 and job 0.14.00.15.1, which must move together: job 0.14.0 was built against es-entity 0.12, so pinning 0.13.0 alone made the lock resolve both 0.12.21 and 0.13.0 and the crate would not compile across the obix/job boundary. job's 0.15 line requires es-entity ^0.13.0, so the lock resolves a single es-entity again.

Neither breaking change reaches this crate's source, which compiles unmodified: es-entity 0.13.0 gates PaginatedQueryRet behind constructor and accessor methods; job 0.15.0 adds job_waiters and wake-by-id (JobSpec::waiter), an API obix never calls. 0.15.1 over 0.15.0 for "keep pool-based handle getters spawn-awaitable".

Migrations

One, and it requires recreating an existing database. obix vendors job's 20250904065521_job_setup.sql; the copy was last refreshed at the 0.14.0 bump, and is re-vendored byte-identical to job 0.15.1's. It adds job_executions.woken_at (the mark left when a wake finds a row it cannot move), the job_waiters (job_id, waiter_job_id) table, and idx_job_waiters_waiter.

Existing databases need make clean-deps && make start-deps, or sqlx reports migration 20250904065521 was previously applied but has been modified. CI builds from scratch and is unaffected. Verified against a recreated database: both migrations apply clean and the new column, table and index are present.

The test wipeout helpers are deliberately unchanged — obix registers no waits, confirmed empirically rather than assumed by running the job-heavy suites and finding 13 jobs rows with job_waiters empty and no woken_at set. .sqlx regenerates byte-identical: no obix query touches the new schema.

No obix schema change of its own.

🤖 Generated with Claude Code


Note

High Risk
Changes core persistent outbox delivery, cursor advancement, stall/catch-up vs gap-fill coordination, and ordering guarantees under concurrency—areas flagged as regulatory-sensitive despite preserved semantics.

Overview
When the broadcast cursor falls behind committed rows (especially after large local commits overflow the cache-fill channel), delivery no longer depends on gap-fill grace pacing. CacheFeeder takes full committed batches from PersistEvents::post_commit via accept, pages them into the cache-fill broadcast from memory, and only hits Postgres when the cache loop explicitly requests catch-up.

The persistent cache loop gains StallTracker / StallAction logic: it measures lag with distance_to_unfed and the feeder's memory_next, requests DB catch-up when the gap is page-sized, and still routes true holes through the existing GapFiller stall path. Wide pg_notify ranges skip unbounded fetch_notified_range when larger than a page; EventSequence adds prev and distance_to helpers.

Truncation-and-re-read from the commit hook is removed; integration is persistent_cache.feeder() instead of cache_fill_sender(). New integration and unit tests in tests/catch_up.rs cover bulk memory drain, bounded remote catch-up, and grace-clock behavior.

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

… committed rows

A single commit larger than the cache-fill broadcast (event_buffer_size)
dropped most of its events to `Lagged`, leaving the GapFiller's grace-gated
stall episode as the only thing moving the cursor forward — at
backfill_page_size / gap_fill_grace (500 events/s in lana's config),
regardless of how fast Postgres is. Every listener on the outbox is
contiguity-gated behind that cursor, so a 596k-event bulk commit measured a
20-minute stall in production (sb-max15, 2026-09-16).

The cache loop now tells "the cursor is behind committed rows" apart from
"the cursor is at a hole": a pure decision rule (`decide_stall_action`)
starts a catch-up task that pages `load_next_contiguous_page` from the
cursor at DB speed once the distance to the head reaches one
backfill_page_size, instead of reporting a stall immediately. The catch-up
never writes anything — a page that comes back empty (or non-contiguous)
hands the position to the existing grace-gated stall path unchanged. A
`watch` channel exposes the cursor so the task can pace one page in flight
against how far the loop has actually consumed.

Two supporting changes close the gaps a fast catch-up alone would leave
open: a notified-but-uncached range wider than one page is no longer
fetched in a single unbounded SELECT (`fetch_notified_range`) — the
catch-up reads it once the cursor arrives instead; and `post_commit` caps
what it pushes into the cache-fill broadcast at `event_buffer_size / 2`
once a batch would exceed that plus the catch-up threshold, advancing the
cache's head watermark before the truncated send so the loop's next wake
already sees the batch's tail.

Cursor-advance-before-send is untouched (still the first thing
`insert_into_cache_and_maybe_broadcast` does): tokio's broadcast `send`
only errors on zero receivers, never on a full buffer, so an
advance-after-send design pins the cursor at zero in any process with no
receiver on the stream. Also untouched: GapFiller semantics (grace,
abandonment proofs, batch caps, cluster lock), the listener/backfill path,
the commit lane, and the schema. No public API change.

No public API change; internal only (PersistEvents::new gained three
positional params, but the module is private).
Two gaps in the original test suite, both raised in review:

1. Nothing distinguished "the batch drained" from "the batch drained
   through the catch-up path specifically" — the headline test could pass
   vacuously if some other mechanism quietly handled the backlog.
2. Nothing proved the catch-up task is singular under a *standing* trigger
   condition (repeated truncated commits keep re-arming "behind by at
   least a page" on every cache-fill drain) rather than a one-off — the
   same shape as a prior job-crate defect where a per-poll-armed waiter
   against a standing condition spawned unbounded concurrent chains.

Add a minimal `tracing::Subscriber` (no new dependency — tracing-subscriber
isn't in the workspace) that counts `obix.persistent_cache.catch_up` span
creations, installed as the thread-local default for a test's duration
(sound because #[tokio::test] defaults to the current-thread runtime, so
every task the outbox spawns runs on the same thread as the guard).

- The headline test now asserts exactly one catch-up span for one
  uninterrupted 200k-event backlog — proving genuine engagement, not
  inference from timing alone.
- A new test publishes 20 separate truncated commits (a standing re-arm of
  the trigger condition, not one big batch) and asserts the catch-up span
  count stays in [1, 5] while asserting strict, gap-free, duplicate-free
  ordering across the full 40,000-event run — the counted observation that
  `catch_up: Option<OwnedTaskHandle>` (a single loop-local gate) coalesces
  repeated triggers instead of accumulating one task per commit (would be
  20) or one per re-evaluation (would be in the hundreds).
@bodymindarts
bodymindarts marked this pull request as ready for review September 16, 2026 20:08

@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.

Stale Bugbot comment from a previous run.

Comment thread src/out/persistent/cache.rs Outdated
bodymindarts added a commit that referenced this pull request Sep 16, 2026
Stacking on #157. Its catch-up task and truncated post-commit broadcast both
push into the cache-fill channel, which now carries
`Transport<InsertOrder, P>` rather than a bare `PersistentDelivery<P>` — the
positioned form every lane shares. Both sites wrap via `Transport::insert`,
which reads the position off the delivery it already has, so neither the
truncation budget nor the one-page-in-flight pacing changes.

`cache.rs` no longer imports the stream wrappers directly: `LaneHandle` owns
that boundary now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… grace

Bugbot on this PR (src/out/persistent/cache.rs#L988-L1004): `StartCatchUp`
sent `GapFillRequest::StallCleared` unconditionally whenever it fired,
including when a catch-up is re-armed for a position it already proved a
hole. `catch_up_exhausted_at`'s re-arm conditions are heuristics (a
notification touching the cursor, a debounced echo of this process's own
earlier commits) that can fire without the hole actually resolving, so a
resulting no-progress catch-up would clear the GapFiller's in-progress
grace-gated episode and force it to restart from a fresh grace period.
Under sustained concurrent write load — repeated unrelated wide commits —
this can keep resetting indefinitely: "an abandoned sequence never becomes
fillable, and every listener stays blocked on that cursor."

Two changes:

- `StartCatchUp` no longer clears `reported_stall` / notifies the
  GapFiller when it fires. That only happens now on the catch-up's
  *outcome*: `AtHead` (fully resolved), or `HoleAfter(pos)` where `pos`
  differs from whatever is currently reported (genuine progress to a new
  position). A catch-up that re-probes and lands back on the exact
  position an episode is already working leaves that episode untouched.
- The wide-notification re-arm of `catch_up_exhausted_at`
  (`fetch_notified_range`'s else-branch) is now scoped to ranges that
  reach back to or before the cursor, rather than firing for any wide
  range anywhere in the sequence space. This does not by itself fix the
  regression (the first change is load-bearing — the min_sequence re-arm
  a few lines above is a second, harder-to-scope source of the same
  no-progress re-probe, via the debounced notifier coalescing a process's
  own unrelated commits into one range), but it cuts the redundant
  catch-up spawns this causes under concurrent load.

New test `tests/catch_up.rs::repeated_unrelated_notifications_do_not_delay_an_unresolved_holes_grace`
is a timing proof, not a span count: a genuine hole is reported once, then
real rows well past it are inserted and their notification manually
replayed every 150ms for most of the grace window (raw SQL + a manual
`pg_notify`, not a second `Outbox` — a second instance's own
auto-registered sequencer independently discovers and no-grace
Historical-fills the same shared-table hole, racing the very episode this
test observes, which is how the first two drafts of this test gave false
greens). Reverting both changes reproduces the regression concretely:
placeholder delivery at 3.78s against a 2s grace, i.e. reset onto the last
interference rather than delivered on the original episode. Fixed: 2.03s,
matching grace.
Move the per-episode `run_catch_up` DB-read loop out of the cache loop's
select! arm and into a new `feeder.rs`, spawned once per cache in `init`
instead of once per stall episode. The cache loop now sends a
`FeedRequest::CatchUp` and tracks a `catch_up_active: bool` instead of
holding an `Option<OwnedTaskHandle>` for a task it used to spawn fresh
each time.

Behaviour is unchanged: `decide_stall_action`'s `catch_up_active` gate
still allows only one request in flight, and a request still results in
one continuous `obix.persistent_cache.catch_up` span from the cursor to
the head. This is a pure structural move (REV 2 step 1 of
obix-dev/handoff-cache-cursor-catch-up.md) to isolate it from the
memory-feed behaviour change that follows: the full suite passes
unmodified, including the headline bulk-commit test's exact
catch_up-span-count assertion.

Sets up the executor that the next commit extends to also feed
already-in-memory committed batches without a DB round trip.
…reading them

REV 2 of obix-dev/handoff-cache-cursor-catch-up.md, addressing two review
critiques on this branch: (1) PersistEvents::post_commit was not dumb — it
held five collaborators and encoded cache truncation policy that only made
sense with knowledge of the cache loop's decision rule; (2) a committed
batch was held in memory through pre_commit -> post_commit (post_commit_events),
then dropped and re-read from Postgres by the catch-up task.

Adds CacheFeeder<P> (feeder.rs): the hook's entire cache-facing surface is
now one call, `feeder.accept(batch)`, which advances the head watermark
and hands the batch, still owned, to the feeder task. The feeder task
(the former per-episode `run_catch_up`, now long-lived since 7b658f1) is
extended with a memory source: it feeds a pending batch unrequested
whenever the smallest pending sequence is exactly cursor+1, and only reads
from the database on the cache loop's request, bounded to stop at the
first pending in-memory sequence rather than the (memory-inflated) head.

Policy split preserved exactly as designed: decide_stall_action (cache.rs)
still owns every re-arm point, reported_stall, catch_up_exhausted_at, and
the outcome-gated StallCleared from b520e8f; it gains one input
(memory_next, the feeder's front) and one computation (catch_up_distance,
gap-aware so a large pending local batch can't make an unrelated small
frontier gap look like a full page behind). The feeder decides nothing
about whether to read from the database, only how and how far.

Deletes the truncation path entirely: PersistEvents::post_commit is three
lines (accept + notify); PersistEvents::new drops from 10 params to 7;
Outbox loses the backfill_page_size field that existed only to feed the
truncation budget.

Fixed two real bugs found while building the counted tests (§4 answers
below), both races between CacheFeeder::accept's synchronous
highest_known advance and the feeder task's next scheduled iteration:

- The very first batch on an idle cache: accept() advanced highest_known
  before the feeder had run even once, so the loop could see a full-page
  "distance" that was actually just this batch sitting in the channel.
  Fixed by having accept() also narrow (never widen) front_tx itself,
  synchronously, in the same call — always safe since it can only ever
  report a real pending sequence.
- Every subsequent page within an ongoing batch feed: the feeder
  publishes its front once per iteration, before sending that iteration's
  page, so for the whole span of the send (and the cursor-consume wait
  after it) the published value trails the cursor the loop just advanced
  to. Filtering that stale-but-meaningful value down to None ("nothing
  pending") is wrong — it was pending a moment ago and the feeder that
  fed it is still working. Fixed by clamping a stale-or-equal front up to
  cursor+1 in the loop's own read of it, which reads as "memory is
  actively feeding here" instead. Caught this one as flakiness in a new
  test (interleaved_local_commit_below_a_parked_batch_is_fed_from_memory,
  ~50% failure rate before the fix, 0/30+ after) rather than by
  inspection — recorded because it is exactly the kind of thing a
  less-adversarial test run would have shipped.

Tests (tests/catch_up.rs): T1/T2/T4/T5 updated to assert zero
`obix.persistent_cache.catch_up` spans and the expected count of
`obix.persistent_cache.memory_feed` spans (one per batch, never per
page); two new tests answer the two hazards this design introduces that
the single-batch REV 1 design never faced:

- interleaved_local_commit_below_a_parked_batch_is_fed_from_memory: two
  concurrent local commits whose sequences interleave (a PostPersistHook
  holds a one-event commit's transaction open for 3s so its sequence
  lands inside a 20,000-event batch's range that already committed).
  Proves the feeder scans every pending batch for the smallest sequence
  above the cursor rather than only the oldest one (an "arrival order"
  bug that would park the large batch behind the small one's hole
  forever, resolved only by the 10s grace set for this test) — delivery
  completes a small multiple of the hold, and the database catch-up path
  never engages.
- remote_gap_below_a_parked_batch_is_read_bounded_then_memory_resumes:
  a raw, uncommitted transaction burns 1,500 sequences below a 20,000-event
  local batch. Proves the database read is bounded to exactly what memory
  cannot supply — asserts the sum of `catch_up`'s `rows` field across
  every span equals 1,500 exactly, not 21,500 (which is what reading to
  the memory-inflated head instead of the pending batch's front would
  produce; verified by reverting the bound and observing exactly that).

Revert-to-red: disabling the memory-feed branch reproduces REV 1's
unfixed failure mode exactly (60s timeout on the 200k-event headline
test, via a genuine deadlock — the loop believes memory owns progress
and never falls back). Removing the database bound makes the exact-1500
assertion fail with 21500. Both reverts confirmed, then restored.

Verification: `nix flake check` (fmt, clippy, audit, deny, alejandra) —
all checks passed, separately from `nix run .#nextest` — 175/175 tests
passed (up from 165: 2 new integration tests, 8 new unit tests), doc
tests passed, docs build shows the same 3 pre-existing broken links as
main (config.rs, ctx.rs, partition/mod.rs), no new ones.

Regulatory note (unchanged from REV 1, extended): ordering, exactly-once
delivery and checkpoint semantics are unchanged — same cursor walk, same
duplicate-safe OrdMap insert, same GapFiller as the only placeholder
writer. What changes is *where* committed rows come from for a local
commit (process memory instead of a database re-read) and that such a
commit now drains at CPU speed rather than DB speed, so listeners lag the
listener broadcast sooner and fall back to per-listener backfill more
often under sustained load — a load-shift, flagged here for human
awareness rather than decided unilaterally, per the sensitivity already
called out on this file for lana's FFIEC-051 reporting chain.

@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.

Stale Bugbot comment from a previous run.

Comment thread src/out/persistent/cache.rs Outdated
bodymindarts and others added 5 commits September 17, 2026 15:26
crate-ci/typos flagged "INSERTs" in a comment as a misspelling of
"INSERT" (tests/catch_up.rs:868). Reworded to lowercase "inserts" —
no behavior change.
… something else

Bugbot (REV 2, commit 1bd2942): the stale-clamp fix for the per-page
staleness race reads a stale-low `front_rx` as "memory is still feeding
here" — correct when the cache loop's own cursor advance is what made it
stale (the feeder's own page-send just hasn't republished yet), but wrong
if the cursor instead moved via a *different* mechanism (a GapFiller
fill, a bounded catch-up page landing, fetch_notified_range) while the
feeder sat parked on a now-irrelevant front. In that case the clamp
projects the stale value forward as if it still means something, and
since the cache loop never subscribes to `front_rx` changing on its own,
a wrongly-suppressed stall report isn't revisited until idle resync
(10s default) or an unrelated notification happens to fire first.

Verified the mechanism by hand-tracing it (not just taking the report at
face value): `pending.retain`/`next_after` only get re-evaluated when the
feeder's own loop runs, which happens on its own schedule
(`cursor_rx.changed()`, its own batch/request channels) — nothing forces
the cache loop's decision block to re-run just because the feeder
corrected itself.

Fixed by adding a `front_rx.changed()` arm to the cache loop's `select!`:
any value the feeder publishes — during active feeding (harmless no-op,
already `cursor + 1`) or after correcting a stale front post-external-jump
(the actual fix) — now reruns the decision block immediately instead of
waiting on an unrelated wake. Channel closure (feeder task gone) is
handled the same way every other core channel in this loop is: recorded
and the loop exits, rather than busy-spinning on a permanently-erroring
`changed()`.

This is additive, not a replacement for the stale-clamp fix from 1bd2942:
the clamp is still independently confirmed necessary for the per-page
staleness case (20/20 across two isolation runs; see the PR discussion),
and this arm addresses a different window that the clamp's own
correctness argument doesn't cover. Removing the clamp again to rely
solely on this arm would reintroduce the ~60% flakiness the clamp fixed
directly (a single wrong decision on the iteration where the loop's own
cursor advance happens is still wrong once, even if a later reactive
wake would have corrected it).

Verification: `nix flake check` and `nix run .#nextest` both green,
reported separately — 175/175 tests unchanged. Stress-ran the
timing-sensitive tests this could plausibly affect: T7 (the b520e8f grace
regression) 15/15, T8 (the interleaved-batch race) 20/20, T1 and T9
10/10 each — no reintroduced flakiness.

Not yet done: I have not written a dedicated adversarial test that
forces the specific "external mechanism overtakes a pending batch's
front while the feeder is parked" window this fixes — the fix rests on
hand-derived reasoning and the negative evidence above (nothing broke),
not a red-then-green regression test for this exact race. Flagging
rather than overstating, per the same standard applied to the
`accept()` front_tx hint in 1bd2942.
Review feedback on #157: the cache loop passed four raw channel ends
around and juggled loose u64s and bools, with the reasoning carried in
long comments rather than in names.

Channels: `feeder::spawn` now builds every channel and returns the two
sides, so `init` loses ~25 lines of noisy setup and `spawn_cache_loop`
takes one `FeederHandle` instead of four ends. The loop calls
`cursor_reached`, `request_catch_up`, `memory_next` and `next_report`
rather than touching senders directly, and the feeder task owns its own
ends as struct fields instead of nine parameters.

Values: `EventSequence` gains `prev` and `distance_to`, so the cursor
arithmetic stops round-tripping through `u64::from`. The watch channels
now carry `EventSequence` rather than raw u64.

Decisions: three loose mutable locals become `StallTracker`, whose named
methods (`rearm`, `rearm_exhaustion`, `catch_up_finished`) replace the
comment blocks that explained each assignment. `decide_stall_action`'s
eight positional arguments become `StallAction::determine(&CursorLag,
&StallTracker, threshold)`, and the unit tests read as named struct
literals. `(u64, bool)` becomes `ReadBound`.

Two behavioural changes fall out. Merging the catch-up-outcome and
front-moved arms into one `next_report` fixes a latent busy-spin: with
`biased`, a dead feeder made `catch_up_done_rx.recv()` return `None`
immediately and forever, starving the arm that would have broken the
loop. Both closures now report `Gone` and break. Second, `publish_front`
only notifies on an actual change; measured, that is ~40% less CPU
across the catch_up suite (user 12.3s vs 20.6s). The same guard on the
cursor was measured to do nothing (12.3s either way), so it is not
there — either guard alone breaks the wake-up cycle.

No regression test covers the busy-spin (it needs the feeder task to
die). Verified instead by stressing the two timing-sensitive tests 12x
and the catch_up suite 5x, all green, with stable timings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The busy-spin fixed in the previous commit was invisible to the suite: a
starved `biased` arm burns a core while every correctness test stays
green. Three unit tests on `FeederHandle::next_report` now pin the
contract instead of relying on having noticed it — either half of the
channel pair closing must report `Gone`, repeatedly, even while the
other half still lives, and a queued outcome must still be drained
before `Gone` is reported.

Revert-to-red: mapping a dead feeder to a no-op report (the exact shape
of the original defect) fails 2 of the 3.

Swept the rest of the crate for the same shape while here. Nine `biased`
select! sites; every arm reading a closable source already exits on
close, and the two unbiased sites return on close as well. The defect
was an isolated instance, not a pattern in the file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Unblocks the es-entity 0.13.0 bump that was parked on the obix/job
boundary: job 0.14.0 was built against es-entity 0.12, so pinning 0.13.0
made the lock resolve both 0.12.21 and 0.13.0 and the crate would not
compile. job's 0.15 line requires es-entity ^0.13.0, so the two pins now
move together — the lock resolves a single es-entity again.

Neither breaking change reaches this crate's source, which compiles
unmodified: es-entity 0.13.0 gates `PaginatedQueryRet` behind constructor
and accessor methods, and job 0.15.0 adds the `job_waiters` table with
wake-by-id (`JobSpec::waiter`), an API obix never calls. 0.15.1 over
0.15.0 for "keep pool-based handle getters spawn-awaitable".

Re-vendors job's `20250904065521_job_setup.sql`, which obix carries as a
copy and which was last refreshed at the 0.14.0 bump. Byte-identical to
job 0.15.1's again, adding:

- `job_executions.woken_at`, the mark left when a wake finds a row it
  cannot move.
- `job_waiters (job_id, waiter_job_id)` and `idx_job_waiters_waiter`.

BREAKING: an existing database must be recreated (`make clean-deps &&
make start-deps`), or sqlx reports `migration 20250904065521 was
previously applied but has been modified`.

Verified against a recreated database: both migrations apply clean, and
the new column, table and index are present. The test wipeout helpers are
deliberately left alone — obix registers no waits, confirmed by running
the job-heavy suites and finding 13 `jobs` rows with `job_waiters` empty
and no `woken_at` set, so there is nothing for them to clear. `.sqlx`
regenerates byte-identical: no obix query touches the new schema.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@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 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 84ab85e. Configure here.

);

-- Terminal cleanup (`delete_waits_of_in_op`): every wait a job registered.
CREATE INDEX idx_job_waiters_waiter ON job_waiters (waiter_job_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Existing migration edited in place

Medium Severity

Editing already-applied 20250904065521_job_setup.sql adds woken_at and job_waiters only on fresh databases. Environments that already ran this migration never get those objects, while job 0.15.1 reads and writes them, so an upgrade either fails the sqlx checksum check or errors at runtime on the missing column and table.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 84ab85e. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Mechanism confirmed, not dismissed — and one part of it verified: job 0.15.1 touches these objects in finalizer.rs, waiters.rs and execution_hooks/promote.rs, and the finalizer runs on every job completion, so a stale database would error at runtime even though obix itself registers no waits (job_waiters stays empty, woken_at unset — checked against a live database).

Three things bound the impact, and one of them makes the suggested fix worse:

  1. The failure is loud, not silent. sqlx refuses to run with a modified applied migration (migration 20250904065521 was previously applied but has been modified), so the checksum error fires before the runtime error can. You cannot silently land on a stale schema.

  2. This file is job's, not obix's. job ships exactly one migration, 20250904065521_job_setup.sql, and revises it in place across releases — same filename in 0.14.0 and 0.15.1, and two further revisions inside 0.13.x. obix vendors a byte-identical copy so its own tests run against the real schema; that invariant is deliberate (commit 5436945 exists precisely because the copy had drifted four revisions behind). Adding an obix-authored ALTER migration would make obix's vendored copy diverge from what job actually ships, so anyone cross-referencing the two would get different schemas. The in-place upgrade path is job's decision to own upstream.

  3. No obix documentation routes consumers to this file. The README's copy-the-migration instructions name 20251204130225_obix_setup.sql only; consumers who use job get its migration from job. obix's copy backs its dev/test database, which CI recreates from scratch every run.

So: handled by the documented practice rather than fixed here — the commit marks itself BREAKING and states the make clean-deps && make start-deps requirement, and the PR body now carries it under Migrations. Whether obix should diverge from job's vendored schema to hand downstream an in-place upgrade path — or whether that belongs as an issue against job — is a policy call I've flagged for the maintainer rather than decided unilaterally.

@bodymindarts
bodymindarts merged commit 6cbaf3d into main Sep 18, 2026
5 checks passed
bodymindarts added a commit that referenced this pull request Sep 18, 2026
…159)

`insert_lane_position_is_the_sequence` fails 10/10 on main (Concourse
`tests` #177 and #178, both on 8f6cda8; #176 on 6cbaf3d was green):

    assertion `left == right` failed: the batch ended on two skipped
    events, so its watermark is 3
      left: EventSequence(1)
     right: EventSequence(3)

That assertion is the test's own precondition, not the invariant. The
invariant — `FlushOp::position()` is the last fully handled event, skips
included — is checked on every flush a few lines above and passes; the
failing line only says the batch never ended on skips, because it closed
after the first event instead of spanning all three.

It closed there legitimately. The runner holds a batch open only over
events already buffered ("a pending stream is itself the flush trigger");
the one exception, `None if in_group => persistent.next().await`, is the
commit lane's group atomicity, and `Transport::insert` hardcodes
`boundary: true`, so `in_group` is never set on the insert lane. Spanning
two separate commits was therefore always a race, never a promise.

#157 made that race deterministic rather than merely likely.
`PersistEvents::post_commit` used to `sender.send` each committed event
inline; it now calls `CacheFeeder::accept`, and the feeder task feeds one
batch per iteration with `await_consumed(last).await` between them. Two
commits are two batches, so the second cannot reach the broadcast until
the runner has consumed the first — and the first is exactly what closes
the batch. The insert lane's delivery contract is unchanged.

Publishing all three from one transaction gives the feeder one page,
which `feed_page` sends without an await, so the runner finds events 2
and 3 buffered when event 1's handler returns. The gate that stood in for
this is now redundant and removed. Discriminating power is unchanged: the
flush carrying only row 1 must still land at position 3, which a `max`
over the collected rows would report as 1.

Verified: 20/20 on the fixed test, 192/192 on the full suite with
`--no-fail-fast` (`cargo nextest run --workspace`), and `nix flake check`
all green. Test-only change; no source file is touched.

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