Skip to content

shuffle: discharge causal hints made unreachable by a byte gap - #3430

Open
williamhbaker wants to merge 6 commits into
masterfrom
wb/shuffle-unreachable-floor
Open

shuffle: discharge causal hints made unreachable by a byte gap#3430
williamhbaker wants to merge 6 commits into
masterfrom
wb/shuffle-unreachable-floor

Conversation

@williamhbaker

@williamhbaker williamhbaker commented Aug 27, 2026

Copy link
Copy Markdown
Member

Description:

A causal hint names an ACK that a session must read before its checkpoint resolves. A byte gap can remove that ACK: retention pruned it, begin_mod_time skipped it, or it was never written. No read can observe it. The session stalls until CAUSAL_HINT_RESOLUTION_TIMEOUT, restarts from the same checkpoint, and repeats.

This PR adds a binding gap floor: a clock below which a binding's causal hints are unreachable or irrelevant.

  • A read raises the floor when the broker resolves its start offset to a larger one.
  • The session discharges hints below the floor. Producer commits are not affected, because a byte gap says nothing about whether a producer committed.
  • Floors travel in Frontier.binding_gap_floors, ratchet in the shuffle session, and persist as GF:{state_key} rows in shard zero. Each leader actor seeds its floors from the recovered committed Frontier.

Of note are the cases intentionally NOT covered by this code:

  • Remote authoritative derivations, where the checkpoint commits but the subsequent recover log does not, and resumption from a crash follows the recovered checkpoint. This would fail to persist any read byte gap for the transaction. It would take quite a combination of events for this to happen, and long term we are planning to move away from the remote authoritative derivation model entirely.
  • Fragments pruned in the middle of a collection, rather than from the tail. Hints might still be unresolvable if this happens, but also the consistency of the collection is almost certainly compromised anyway.
  • Fragments pruned after a hinted materialization checkpoint is persisted, but before an idempotent replay of it. Similarly, a notBefore addition racing an idempotent replay of a hinted checkpoint. Both of these could leave hints unreachable. The pruned fragment case has a rationale similar to the above point. The notBefore addition seems quite obscure and easy to remedy through user action.

See individual commit messages for more detail.

Completed Q/A with a local stack: Drove the stack through various transitions and confirmed that the hint discharge and its floors are durable and operating as expected.

Closes #3414

Workflow steps:

(How does one use this feature, and how has it changed)

Documentation links affected:

(list any documentation links that you created, or existing ones that you've identified as needing updates, along with a brief description)

Notes for reviewers:

(anything that might help someone review this PR)

@williamhbaker
williamhbaker requested a review from a team August 27, 2026 21:28
Comment thread crates/shuffle/src/slice/read.rs Outdated
Comment on lines +142 to +144
if offset == 0 {
self.pending_gap = true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this an issue when the gap is a begin_mod_time cut rather than pruning? With a recent notBefore, the probe fast-forwards past every existing fragment, so offset == 0 sets pending_gap and sample_gap_floor then samples a clock of about now, putting the floor an hour ahead of the clocks the read is about to consume.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

More broadly, I don't think we want begin_mod_time cuts at all within this mechanism.

begin_mod_time is used to filter appends, but doesn't affect ACK propagation or causal hint resolution. It just suppresses the content of those transactions. I think we can totally ignore that begin_mod_time is a thing in this implementation, and focus just on offset jumps / gaps, and that it would still work because begin_mod_time sometimes causes such a gap (and sometimes doesn't, in which case no gap floor needs to be recorded).

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.

I had been trying (but not completely accomplishing, per Dave's comment) to avoid effectively turning off causal hints for the 1-hr of clock time following something like a collection truncation, where a begin_mod_time would regularly be applied. The additional fudge factor is needed to account for clocks not being guaranteed to be in ascending order in a journal, but that requires multiple producers and significantly different clocks amongst them.

Practically I think the extra buffer could be more like 10 minutes and be fine for actually doing what it needs to do, and then it would be less disruptive as well when applied to gaps caused by begin_mod_time. So I'll plan on doing that: Ignore begin_mod_time and tighten the extra padding from 1 hr to 10 minutes.

Comment thread crates/shuffle/src/lib.rs Outdated
/// below which its causal hints are discharged as unreachable
/// ([`frontier::Completed::is_gap_stale`]).
///
/// Such a read may have found the journal's head pruned, which `notBefore` says

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: perhaps do a de-duplication / don't repeat yourself pass over the comment prose?

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.

Done, made a few cleanups/improvements.

Comment thread crates/shuffle/src/slice/read.rs Outdated
Comment on lines +142 to +144
if offset == 0 {
self.pending_gap = true;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

More broadly, I don't think we want begin_mod_time cuts at all within this mechanism.

begin_mod_time is used to filter appends, but doesn't affect ACK propagation or causal hint resolution. It just suppresses the content of those transactions. I think we can totally ignore that begin_mod_time is a thing in this implementation, and focus just on offset jumps / gaps, and that it would still work because begin_mod_time sometimes causes such a gap (and sometimes doesn't, in which case no gap floor needs to be recorded).

Comment thread crates/shuffle/src/session/state.rs Outdated
// An idempotent-recovery session must read through its hinted frontier
// exactly, so it does not observe gap floors.
if !recovery_session {
completed.advance_gap_floors(&resume_checkpoint.binding_gap_floors);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure I'm following why this is required.

Also, what happens if idempotent replay content has been entirely pruned due to a gap?

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.

Agreed on this check not being required, and it would have prevented the ratchet from advancing when hints were discharged by a gap floor. I'll remove that.

Related to this and your question about idempotent replay content that has been entirely pruned...there's a similar check in on_progressed that I had over-applied here. I think that one is still necessary though, otherwise a recovery transaction could potentially discharge its own projected hints if the last_commits happen to fall near the gap.

This means that if idempotent replay content gets pruned between when the frontier is persisted and when it is replayed there is still a potential for a hint resolution stall. I've given that scenario quite a bit of thought but haven't come up with a solution I like very much.

A binding gap floor is a clock below which a binding's causal hints are
unreachable. A read raises one when the broker resolves its start offset
to a larger offset: a byte gap.

`Frontier.binding_gap_floors` holds one floor per binding. A Progressed
delta reports the floors its Slice raised. Checkpoint and Persist
frontiers carry them onward. Go and Rust code is regenerated.
Add `binding_gap_floors` to `crate::Frontier`. It is keyed by binding
index and handled like the backfill clocks: it reduces by per-binding
maximum (`merge_binding_clocks`, renamed from `merge_backfill_clocks`),
encodes and decodes with the proto field, rides the checkpoint peek, and
is carried through `project_unresolved_hints`.

Nothing raises or consumes a floor yet. Snapshots gain the empty field.
A read has a byte gap when the broker resolves its requested offset to a
larger one. The read cannot receive the skipped bytes, and the response
does not say why: retention removed them, `begin_mod_time` skipped them,
or they were never written.

Only a gap at read start raises a floor, when the start probe lands past
the requested offset. `ReadState::sample_gap_floor` samples the first
document the read then consumes, plus CAUSAL_HINT_GAP_MARGIN. Every ACK
the gap removed precedes that document in offset order, so the sample
bounds them all. The margin covers producer clock skew only, which is why
it can be far smaller than PRODUCER_STALENESS_HORIZON.

The cause of the gap does not matter, and `notBefore` is not consulted.
`notBefore` suppresses document append but not commits, so hints resolve
from any document the read receives. It bears on the floor only when its
`begin_mod_time` fast-forward opens a gap. A gap mid-read raises nothing.

`build_flush_frontier` drains each read's floor into the flush frontier,
folding a binding's journals to their maximum. A floor raised at read
start waits for the read's first document.
A causal hint names an ACK the session must read before its checkpoint
resolves. If a byte gap put that ACK out of reach, no read can observe
it. The session stalls until CAUSAL_HINT_RESOLUTION_TIMEOUT, restarts
from the same checkpoint, and repeats.

`Completed` gains the binding gap floor as a third authority. A hint
below the floor is discharged as unreachable. Producer commits are never
discharged: a byte gap says nothing about whether a producer committed,
so a commit past its ceiling still freezes the ratchet.

Floors ratchet from Progressed deltas ahead of promotion, because a byte
gap is a fact about what a read can reach, not about a transaction's
outcome. A delta carrying a floor re-prunes the pending checkpoint,
promoting it if that clears its last hint.

Every session seeds its floors from the resume checkpoint, so a durable
floor accounts an incoming hint and the ratchet advances instead of
freezing. But an idempotent-recovery session acts on no floor it raises
while running. A floor cannot distinguish an ACK the gap removed from one
just past the gap, and that session must replay its hinted frontier
exactly.

`prune_hints` returns a third count. The runtime-next tests are updated
for the wider tuple.
Shard zero holds one `GF:{state_key}` row per binding. Every Frontier of
a Persist carries the same floors, so `encode_persist` writes each row
once, from whichever Frontier is present. The startup scan restores the
rows onto the committed Frontier (`restore_binding_clocks`, renamed from
`restore_backfill_clocks`). Neither `delete_*_frontier` clears them: a
floor is a retention fact, not transaction state.

The scan round-trip test now carries backfill clocks and a gap floor, so
the positional accumulator arguments of `RocksDB::scan` are checked
through a real scan.
Each leader actor keeps a session-cumulative map of gap floors, seeded
from the recovered committed Frontier. It folds the floors of every Load
frontier into the map, and stamps the map onto every Persist it issues.
Unresolved peeks fold too, because a floor is a retention fact, not
transaction state. Materialize's backfill markers still fold only from a
resolved Load.

The seed matters. A Persist overwrites each `GF:` row, and a Session's
Load can report a floor below the durable one, so an unseeded session
would lower the row on its first Persist.

Materialize's hinted Persist precedes StartCommit, so no commit can carry
read progress past a gap ahead of its floor. Derive's only Persist
follows StartCommit, so a remote-authoritative derivation which crashes
in between loses that transaction's floors.
@williamhbaker
williamhbaker force-pushed the wb/shuffle-unreachable-floor branch from 3f98b52 to 8fa700a Compare August 31, 2026 21:09
@williamhbaker

Copy link
Copy Markdown
Member Author

Force-pushed changes based off review feedback, and to take care of a merge conflict.

@jgraettinger jgraettinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

contingent LGTM, though a concern below. I'd like to spend a bit more time thinking and talking about this, on the possibly-unfounded aspiration that there's a simpler approach

);

// A floor cannot tell an ACK the gap removed from one just past it, so
// the session which must replay its hinted frontier exactly acts on no

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Trying to think through this case makes my head hurt 馃珷.

Per your comment before:

This means that if idempotent replay content gets pruned between when the frontier is persisted and when it is replayed there is still a potential for a hint resolution stall. I've given that scenario quite a bit of thought but haven't come up with a solution I like very much.

That makes sense, if the producer itself is gone and doesn't re-appear -- and we're in "backfill the binding" territory, so that's fine.

What happens if the hinted commit producer is still active, but the hinted commit has been pruned? In that case we may silently fold it's next ACK into the idempotent txn ... I think? As that's the first ACK that clears the hinted commit.

I'm not sure what, if anything, we do about this (nor am I confident I have the scenario right). I suppose the biggest actual concern is that it's a silent rather than noisy failure.

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.

jagged reads at the pruned fragment horizon render hints unresolvable

3 participants