Skip to content

protocol,broker,node,cli,chaos: a leader seals its tail on demand — repair reaches its whole position (#306) - #341

Merged
allamiro merged 3 commits into
mainfrom
feat/306-seal-tail
Aug 11, 2026
Merged

protocol,broker,node,cli,chaos: a leader seals its tail on demand — repair reaches its whole position (#306)#341
allamiro merged 3 commits into
mainfrom
feat/306-seal-tail

Conversation

@allamiro

@allamiro allamiro commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Closes #306.

The arithmetic, closed

Only sealed segments transfer — that contract stays. The fix is not to transfer a moving tail but to stop it moving, deliberately: a new fenced replica-plane RPC, SealTail (wire kinds 81/82), asks the leader to roll now. The tail seals, a successor opens at its end, and the freshly sealed segment is transferable the moment the call returns. The 8 GiB-tail-vs-8 MiB-retransmission-buffer gap the issue quantifies closes because the transferred prefix now reaches the leader's position at the seal, not wherever the range last happened to roll.

The issue's four open questions, answered

  • Who asks: the RPC, driven by vtopctl node repair --seal-tail (sealing happens before the transfer, so the listing that drives it already includes the sealed tail — sealing after would close the gap for the next repair). Gated by the same transfer_peers allowlist: sealing exists for the transfer, so a peer that may not pull the bytes may not reshape the leader's segments either.
  • Fencing: checked before the seal with the transfer plane's own one-snapshot check_fencing (the log,broker: a repaired replica has no epoch history, so the first leader transition truncates it back to zero #315/broker,cli: the repair carries the epoch history, so it survives the next leader (#315) #320 discipline), and the broker-level seal runs under the append path's own lock — rolling belongs to the append critical section. A stale-epoch seal request is refused before anything rolls, pinned by test.
  • Refusals: an empty tail over a sealed prefix is an idempotent no-oprecords_sealed: 0, same sealed_end — so a retrying repair distinguishes it from progress without parsing errors. A never-written range refuses with the reason ("produce to the range before repairing from it"): a degenerate sealed segment would cost a file to say nothing, and adoption would still refuse the empty prefix it decorated.
  • Cost: one shorter segment per repair, taken only on demand; retention runs after the seal exactly as after an ordinary roll.

LocalBroker::seal_tail is the first production caller of SegmentSet::roll (via a new roll_minting, minting from the set's own env like every other minting site). The ReplicaPeerHandler default refuses, in the fence camp: a peer that silently "succeeded" without sealing would report a tail transferable that is still moving.

The inverted pin fires positive

Scenario 12 spent two releases stating the tail gap out loud ("…remain out of its reach until #306 gives the gap a road back"). That statement is now an assertion: the repair must exit 0 (gap fully closed), and the replacement's committed offset must reach the full acknowledged floor — the deadline-poll bound moves from $SEG_NEXT to $ACKED, and a run that stops at the old sealed-prefix boundary fails the scenario instead of being tolerated with a caveat. The byte-for-byte check now matches segments by name, because with the sealed tail in the transfer, "the last .segment" in the two directories are different segments.

Verification

  • Protocol round-trip tests extended (12 pass); kinds registered in the header gate.
  • Broker: sealed_segment_transfer.rs grows three assertions — a stale-epoch seal is Fenced and mutates nothing; sealing makes the leader's position transferable end-to-end over the wire (seal → idempotent re-seal → transfer → adopt lands exactly at the leader's position → leader still serves appends); a never-written range refuses with the stated reason. 436 tests pass across protocol/log/broker/node, all CLI suites green, clippy -D warnings clean.
  • Scenario 12 live, twice: first run caught a stale-binary fixture issue and the positional-glob assumption (both composition-proof lessons, both fixed); final run PASScommitted through 1500 >= 1500 with 8 sealed segment(s) across the leader transition.

Summary by cubic

Adds a SealTail RPC and --seal-tail repair option to seal the leader’s tail before transfer so the sealed prefix reaches the leader’s position and the tail gap is closed. Repair now also reports if retention reclaimed sealed segments between the seal and the listing.

  • New Features

    • Protocol: SealTail request/response added to vtop-protocol (wire kinds 81/82).
    • Broker/Log: LocalBroker::seal_tail rolls under the append lock and returns (sealed_end, records_sealed); uses SegmentSet::roll_minting.
    • Network/Node: ReplicaPeerHandler::seal_tail added; LeaderSegmentTransferHandler implements it; vtop-node gates sealing by the transfer allowlist.
    • Client/CLI: transfer client exposes seal_tail; vtopctl node repair --seal-tail seals first so the listing includes the sealed tail; output clarifies that a second repair over an adopted dir is refused and suggests a fresh --seal-tail repair.
    • Chaos: scenario 12 now requires repair to reach the full acknowledged floor and compares transferred segments by name.
  • Bug Fixes

    • Fencing: LocalBroker::seal_tail validates range/epoch inside the broker under the metadata + state locks; stale-epoch requests return Fenced without mutation.
    • Retention: no retention pass runs inside sealing; a produce between the seal and the listing may still reclaim sealed segments. The CLI compares the seal’s promised end with what was listed and names retention as the cause if shorter; mid-fetch reclamations are clean, resumable refusals.
    • Idempotency/validation: sealing an empty tail reports records_sealed: 0; a never-written range is refused with a clear reason.

Written for commit 0a84b1b. Summary will update on new commits.

Review in cubic

…epair reaches its whole position (#306)

Only sealed segments transfer, and that stays true: the fix is not to
transfer a moving tail but to stop it moving, deliberately. SealTail
(wire kinds 81/82 — nothing below 81 is free; 70 is the live legacy
cursor alias) asks the leader to roll now: the tail seals, a successor
opens at its end, and the freshly sealed segment is transferable the
moment the call returns. The arithmetic the issue states — a repaired
replica up to 8 GiB behind a leader whose retransmission buffer holds
8 MiB — closes because the prefix now reaches the leader's position at
the seal, not wherever the range last happened to roll.

The issue's four questions, answered: WHO ASKS — the replica-plane RPC,
driven by vtopctl node repair --seal-tail, gated by the same transfer
allowlist (sealing exists FOR the transfer; a peer that may not pull
the bytes has no business reshaping the leader's segments). FENCING —
checked before the seal with the transfer plane's own one-snapshot
check; a deposed leader refuses rather than sealing a tail the cluster
moved past, and the broker-level seal runs under the append path's own
lock because rolling belongs to the append critical section. REFUSALS —
an empty tail over a sealed prefix is an idempotent no-op reported as
zero records sealed, distinguishable from progress by a retrying
repair; a never-written range is refused with the reason. COST — one
shorter segment per repair, taken only on demand, and retention runs
after the seal exactly as it runs after an ordinary roll.

LocalBroker::seal_tail is the first production caller of
SegmentSet::roll. The handler default refuses, in the fence camp: a
peer that silently succeeded without sealing would report a tail
transferable that is still moving.

Scenario 12's inverted pin fires positive: the replacement must now
reach the FULL acknowledged floor — the bound moves from the sealed-
prefix end to , the repair must exit 0, and a run that stops at
the old boundary fails the scenario instead of being tolerated with a
caveat. Proven live: committed through 1500 >= 1500, eight sealed
segments, across the leader transition. The byte-for-byte check now
matches segments BY NAME — with the sealed tail in the transfer, 'the
last .segment' in the two directories are different segments.
@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@allamiro allamiro self-assigned this Aug 11, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d9cc016a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +248 to +251
self.check_fencing(fencing_epoch)?;
let (sealed_end, records_sealed) = self
.broker
.seal_tail()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Hold the fencing snapshot through the tail seal

If the leader loses its lease after check_fencing returns but before broker.seal_tail() acquires the broker-state lock, the metadata publisher can clear or advance the lease in that gap and this deposed leader still performs the roll. Unlike the produce path, which holds the metadata and broker locks together from authorization through the durable write, this newly introduced write is therefore not fenced atomically; hold the lease snapshot until the seal completes or revalidate it while holding the state lock.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Real, and the sharpest finding on this PR — fixed in 462730d. Verified against the produce path, which documents exactly the discipline the seal was missing: metadata lease view first, then broker state, both held through the write, 'so a concurrent grant/release cannot revoke between the fencing check and fsync'. seal_tail now takes range and epoch and validates them INSIDE, under both locks, using the same check_range the produce path uses; the handler's one-shot check_fencing no longer participates. The stale-epoch broker test still pins refusal-without-mutation.

Comment thread crates/vtop-broker/src/lib.rs Outdated
Comment on lines +1171 to +1174
state.segment.roll_minting().map_err(BrokerError::from)?;
// A seal adds a sealed segment, and retention reasons in sealed
// units — the same follow-through the produce path's roll performs.
self.run_retention(&mut state.segment);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the newly sealed tail available for the requested transfer

When retention is enabled with max_total_bytes below the former tail's content size and the committed floor reaches its end, this retention pass can immediately delete every sealed segment, including the one just created. The RPC then reports sealed_end == tail_next, but the listing used by --seal-tail cannot transfer that prefix, so repair remains behind or has no segment to adopt; retention configuration accepts any nonzero bound, so this is a valid deployment rather than an impossible state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Real, and fixed in 462730d: retention no longer runs in the seal. A bytes-bound pass could reclaim the very segment the call just sealed — the committed floor covers it by construction — and the RPC would then report a sealed_end the listing cannot reach. The seal exists to make the tail transferable; housekeeping that could unmake it waits for the next ordinary roll, and the doc states the decision.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 9 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/vtop-broker/src/replication/transfer.rs Outdated
Comment thread crates/vtop-broker/src/lib.rs Outdated
Comment thread crates/vtop-cli/src/node_tools.rs Outdated
Comment thread crates/vtop-broker/src/replication/transfer.rs
Comment thread crates/vtop-broker/src/lib.rs Outdated
… retention waits

Review round one, both P1s verified real and fixed.

The seal's fencing check ran through the transfer handler's one-shot
check_fencing, then took the broker state lock separately — leaving a
window where a grant or release lands between the check and the roll,
and a deposed leader still mutates its layout. The produce path already
documents the discipline that closes it: metadata lease view first,
then broker state, BOTH held through the write. seal_tail now takes the
range and epoch and validates them inside, under both locks, with the
same check_range the produce path uses — the handler's job reduces to
authorization and transport.

And retention no longer runs in the seal: a bytes-bound pass could
reclaim the very segment the call just sealed — the committed floor
covers it by construction — and the RPC would then report a sealed_end
the transfer listing cannot reach. The seal exists to make the tail
transferable; housekeeping that could unmake it waits for the next
ordinary roll.

Also from review: the operator hint after a post-seal gap now says a
SECOND repair over the adopted directory is refused and names the
fresh-directory remedy; the client method no longer splits
transfer_sealed_prefix's doc block; and the never-written refusal's
string uses proper continuations instead of embedding an indent run.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 462730d3e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1192 to +1196
state
.segment
.roll_minting()
.map_err(|problem| (ErrorCode::Storage, problem.to_string()))?;
Ok((tail_next, records_sealed))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pin the sealed tail through the transfer listing

When retention is enabled and the committed former tail exceeds max_total_bytes, an append arriving after this method returns but before the repair's separate listing request can delete the segment that was just sealed: flush_produce_group calls run_retention after every successful append at lines 1673-1677, not only after an ordinary roll. The fresh evidence relative to the earlier comment is that this fix suppresses retention only inside seal_tail, so the cross-RPC window remains; the seal can report success while the following transfer cannot reach sealed_end. Keep the segment pinned until the transfer obtains its snapshot, or combine sealing with that snapshot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verified — you're right that the in-call suppression closes only half the window: run_retention fires after every successful append (confirmed at the flush_produce_group call site), so the cross-RPC slice remains. Where I land differently is on the consequence, and 0a84b1b encodes that reasoning rather than a pin: neither end of the window can produce a FALSE success — a shorter listing becomes a measured, reported gap (the #303 exit-1 contract), and a segment reclaimed mid-fetch is a clean resumable refusal, which is the same window every listed segment already lives in until its chunks land. A cross-RPC pin was considered and rejected with the reason now in the doc: the leader cannot know when a repairer is done, and a pin that outlives a crashed repairer is a retention bound that silently stopped being one. What the fix adds instead is DETECTION: the repair compares the seal's promised end with what the fenced listing actually held, and when retention won the race it names the cause to the operator instead of presenting a mysteriously shorter prefix. If a durable pin becomes worth its lifetime management, it deserves its own design slice against #290's retention semantics.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/vtop-broker/src/lib.rs Outdated
Review round two. Codex is right that suppressing retention inside
seal_tail closes only the in-call window: retention runs after every
successful append, so a produce landing between the seal RPC and the
repair's listing can still reclaim the freshly sealed segment under a
bytes bound smaller than it. What does NOT follow is a lie — a shorter
listing becomes a measured, reported gap (exit 1), and a segment
reclaimed mid-fetch is a clean resumable refusal, the same window every
listed segment already lives in until its chunks land. A cross-RPC pin
was considered and rejected, with the reason in the doc: the leader
cannot know when a repairer is done, and a pin that outlives a crashed
repairer is a retention bound that silently stopped being one.

What the operator gets instead is the CAUSE, named: the repair compares
the seal's promised end with what the fenced listing actually held, and
when retention won the race it says so — 'the leader's retention
reclaimed sealed segments between the two' — instead of presenting a
mysteriously shorter prefix. cubic's doc precision applied: the comment
no longer claims the segment persists until a later roll. Two more
embedded-whitespace string literals fixed in the same pass.
@allamiro
allamiro merged commit 5f802f8 into main Aug 11, 2026
20 checks passed
@allamiro
allamiro deleted the feat/306-seal-tail branch August 11, 2026 12:48
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.

log,broker: a leader cannot seal its tail on demand, so repair can only ever close part of the gap

1 participant