Skip to content

broker,node,protocol: record and expose which fencing epoch wrote each stretch of the log (#240) - #258

Merged
allamiro merged 3 commits into
mainfrom
feat/240-leader-epoch-cache
Aug 5, 2026
Merged

broker,node,protocol: record and expose which fencing epoch wrote each stretch of the log (#240)#258
allamiro merged 3 commits into
mainfrom
feat/240-leader-epoch-cache

Conversation

@allamiro

@allamiro allamiro commented Aug 5, 2026

Copy link
Copy Markdown
Owner

First step of #240. Does not close it — the remaining items build on this.

Promotion takes the offset a majority of replicas can vouch for. That is sound arithmetic over unsound inputs: an offset is a bare integer, and two replicas both reporting 90 need not hold the same record at 90. One may have taken 90 from a leader whose writes never reached a quorum before it was deposed; the other from the leader that replaced it. Comparing them by number cannot tell those apart.

Kafka shipped exactly this bug and fixed it in KIP-101 with a leader-epoch → start-offset vector. This is that vector, durable per replica.

Why this lands before truncation

#240's second item is "truncate followers to the boundary". Doing that first would have been actively dangerous: truncating against a high-water mark without epoch qualification can discard acknowledged records. That is the failure KIP-101 exists to prevent, and I would have been writing it fresh. divergence_point — walk two vectors, stop at the first differing epoch, take the smaller of the two start offsets — is the fact truncation needs and a bare offset cannot supply.

One design decision worth reviewing

A start that cannot be recorded marks the history broken, and a broken history reports empty rather than partial.

Absence and breakage are deliberately the same answer because they license the same decision: this replica's offsets are bare numbers and must not be reconciled by epoch. A partial vector is worse than none — it reads as authoritative and yields a truncation target computed from a hole.

vtop-broker carries no tracing dependency and this does not add one. Making the unsafe state structurally unusable beats a log line someone has to notice.

Verification

10 unit tests on the journal, covering the properties that actually matter rather than round-trips: re-recording the same epoch is a no-op (a polling watcher does it constantly), the same epoch at a different offset is refused rather than merged, a stale observation is ignored rather than treated as a rewind, a torn tail recovers the whole entries, and a non-advancing file is refused outright.

Live, from scenario 09 — real journals on real disk:

leader             (epoch 1, start 0)
promoted follower  (epoch 2, start 300)
other follower     empty

The promoted follower's entry says epoch 2 begins at 300 — the boundary its verified promotion established — so a replica claiming records at 300+ under epoch 1 is now provably diverged rather than merely differently numbered. The third replica has no lease block in that scenario, never adopts, and correctly reports "unknown".

Workspace tests, clippy, and fmt clean; scenario 09 passes.

Scope

Nothing reads the vector across the wire yet. Exposing it on the replica-status plane and truncating against it are the next steps of #240, and they need this to exist first.


Summary by cubic

Records per-replica fencing-epoch starts and exposes them over the replication protocol to make log comparison epoch-aware and safe. Adds a durable journal and hardens mixed-version handling and bounds; prepares safe truncation for #240.

  • New Features

    • Added FencingEpochJournal (durable epoch→start-offset) with entries(), latest(), end_of_epoch(), and divergence_point(); appends are fsynced before serving.
    • Leader and follower record the start before exposing the new epoch; node wires the journal at data_dir/fencing-epochs; seed only when both journal and log are empty; skip epoch 0; epoch_starts() returns empty when unknown/broken.
    • New wire API to read a replica’s epoch history: protocol kinds 67/68 with bounded length and monotonicity checks; ReplicaStatusClient::epoch_history(); handlers on leader/follower; defaults to empty for mixed versions.
  • Bug Fixes

    • Torn-tail recovery now truncates and fsyncs; failed write/fsync poisons and blocks further appends until reopen; open bounds are checked before allocation; directory fsync on create; two empty histories now yield None from divergence_point().
    • Wire hardening: mixed-version peers that drop the connection (clean EOF) now yield an empty history instead of error; enforce MAX_REPLICA_EPOCH_STARTS before reserving and use the same bound in the journal (via vtop_protocol); fix directory sync for bare relative paths; tests assert the local and wire bounds stay equal.

Written for commit 04b3bf1. Summary will update on new commits.

Review in cubic

…#240)

Promotion establishes a committed boundary by asking replicas where their
disks are and taking the offset a majority can vouch for. That is sound
arithmetic over unsound inputs: an offset is a bare integer, and two replicas
both reporting 90 need not hold the same record at 90. One may have taken 90
from a leader whose writes never reached a quorum before it was deposed, the
other from the leader that replaced it. Comparing them by number cannot tell
those apart.

Kafka shipped exactly this bug and fixed it in KIP-101 with a leader-epoch →
start-offset vector, reconciling against the epoch rather than the high-water
mark. This is that vector, durable per replica.

`(epoch, start_offset)` records that a replica's first record under `epoch`
sits at `start_offset`, so the entries partition the log. Two replicas agree
on a prefix exactly as far as their vectors agree, and the first differing
epoch bounds divergence: below the smaller of the two start offsets, both
wrote under identical leadership and hold identical records.
`divergence_point` returns that offset. It is the fact truncation needs and a
bare offset cannot supply — which is why this lands before the truncation step
of #240 rather than after. Truncating against a high-water mark without epoch
qualification can discard ACKNOWLEDGED records, which is the failure KIP-101
exists to prevent.

Recorded on epoch adoption, on both a leader and a follower, and fsynced
before the replica serves under that epoch: a vector that loses its newest
entry in a crash is back to comparing bare offsets at exactly the moment the
answer matters.

An unrecordable start marks the history broken, and a broken history reports
EMPTY rather than partial. Absence and breakage are deliberately the same
answer, because they license the same decision: this replica's offsets are
bare numbers and must not be reconciled by epoch. A partial vector would be
worse than none — it reads as authoritative and yields a truncation target
computed from a hole. vtop-broker carries no `tracing` dependency and this
does not add one; making the unsafe state structurally unusable beats a log
line someone has to notice.

Verified live: scenario 09 passes and writes real journals —

  leader             (epoch 1, start 0)
  promoted follower  (epoch 2, start 300)
  other follower     empty

The promoted follower's entry says epoch 2 begins at 300, the boundary its
verified promotion established, so any replica claiming records at 300 or
above under epoch 1 is now provably diverged rather than merely differently
numbered. The third replica has no lease block in that scenario, never adopts,
and correctly reports "unknown".

Nothing reads the vector across the wire yet; exposing it on the replica-status
plane and truncating against it are the next steps of #240.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@cursor

cursor Bot commented Aug 5, 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.

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

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/vtop-node/src/data_node.rs">

<violation number="1" location="crates/vtop-node/src/data_node.rs:292">
P1: Static leaders and followers never record the epoch that writes their initial log, so future promotion sees an empty/unknown vector for the entire preexisting history. Initializing the journal at the current next offset for the initial held epoch is needed in both role paths before serving.</violation>
</file>

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

Re-trigger cubic

// stretch of this replica's log. Promotion cannot compare two replicas'
// offsets without it — a bare offset says where a replica is, not whose
// writes put it there.
follower.set_fencing_epoch_journal(

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: Static leaders and followers never record the epoch that writes their initial log, so future promotion sees an empty/unknown vector for the entire preexisting history. Initializing the journal at the current next offset for the initial held epoch is needed in both role paths before serving.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/vtop-node/src/data_node.rs, line 292:

<comment>Static leaders and followers never record the epoch that writes their initial log, so future promotion sees an empty/unknown vector for the entire preexisting history. Initializing the journal at the current next offset for the initial held epoch is needed in both role paths before serving.</comment>

<file context>
@@ -285,6 +285,16 @@ async fn run_follower(
+    // stretch of this replica's log. Promotion cannot compare two replicas'
+    // offsets without it — a bare offset says where a replica is, not whose
+    // writes put it there.
+    follower.set_fencing_epoch_journal(
+        vtop_broker::fencing_epochs::FencingEpochJournal::open(
+            config.data_dir.join("fencing-epochs"),
</file context>

Comment thread crates/vtop-broker/src/replication/mod.rs
Comment thread crates/vtop-broker/src/lib.rs Outdated
Comment thread crates/vtop-broker/src/lib.rs
Comment thread crates/vtop-broker/src/fencing_epochs.rs Outdated
Comment thread crates/vtop-broker/src/fencing_epochs.rs Outdated
Comment thread crates/vtop-broker/src/fencing_epochs.rs
Comment thread crates/vtop-broker/src/fencing_epochs.rs
Comment thread crates/vtop-broker/src/fencing_epochs.rs
All nine were real. The two that mattered most:

TORN-TAIL RECOVERY NEVER TRUNCATED THE FILE. The fragment was dropped from
memory and left on disk, so the next append landed after it and every entry
from then on was misaligned. The failure surfaced on the restart AFTER the
recovery — reading (epoch, start) pairs straddling entry boundaries, which
either refuses to open (losing a history this code had already recovered once)
or yields wrong offsets. Now truncates to the last whole entry and fsyncs,
which is what the comment always claimed it did. A test appends a fragment,
recovers, records, and reopens — the reopen is where a survivor would show.

THE EPOCH BECAME VISIBLE BEFORE ITS START WAS DURABLE. `fetch_max` is what
admits writes under a new epoch, and the start was recorded after it. A
concurrent write in that window is a record written under an epoch whose start
offset is not yet known, so the journal would name a start ABOVE the first
record that epoch actually wrote — and a later divergence comparison would
attribute that record to the previous epoch, misreading the exact boundary
this vector exists to fix. Recording now happens before the epoch is exposed,
reading the tail as of before it is servable.

The rest:

* `epoch_starts` read the broken flag before taking the lock, so a record that
  failed while the call was waiting could return a now-partial vector as
  authoritative. Flag is read under the lock.
* A failed write or fsync left the file possibly holding a partial record and
  allowed further appends onto it. The journal poisons and refuses to append
  until reopened and revalidated.
* `divergence_point` reported two EMPTY vectors as a proven common prefix at
  0. Empty means "unknown" everywhere else in this API; returning agreement
  let a caller truncate on mutual ignorance. Now `None`.
* `open` allocated on the file's length before checking the entry bound, so a
  malformed journal could exhaust memory during recovery — at the moment the
  broker is trying to come back. Bounded first.
* Creating the journal fsynced the file but not its directory, so a power loss
  could take the directory entry and the whole history with it.
* A replica with a STATIC epoch never calls `adopt_fencing_epoch`, so it
  reported "unknown" for its entire history and could never be reconciled.
  Seeded on install — but only when the log is also empty, which the review's
  suggested fix did not require. For a replica that already holds records this
  process cannot know where its held epoch began; claiming it started at the
  current tail is a fabricated boundary, and a truncation computed from it
  could discard acknowledged records. "Unknown" is the honest answer there.
* Seeding also skips epoch 0: that is the "no grant yet" sentinel a
  lease-driven replica starts at, not an epoch that ever wrote anything. It
  showed up live as a leader recording (0,0) before its first real grant.

13 journal tests, three of them new and aimed at the fixes above. Verified
live — scenario 09 now produces complete lineage where the first version
recorded almost none:

  leader             (1,0)
  promoted follower  (1,0) (2,400)
  other follower     (1,0)

The promoted follower carries the whole range's lineage, not just its own
tenure, which is what makes it comparable against a peer.
@allamiro

allamiro commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

All nine findings were real and are fixed in 2e5d8fb. Two were serious enough to have shipped a journal that corrupts itself.

The two that mattered

Torn-tail recovery never truncated the file. The fragment was dropped from memory and left on disk, so the next append landed after it and every entry from then on was misaligned. The failure surfaces on the restart after the recovery — reading (epoch, start) pairs straddling entry boundaries, which either refuses to open (losing a history this code had already recovered once) or yields wrong offsets. The comment claimed it followed the segment recovery path; it did not. Now truncates to the last whole entry and fsyncs. The new test appends a fragment, recovers, records, and reopens — the reopen is where a survivor shows up, and the original code passed my first test precisely because that step was missing.

The epoch became visible before its start was durable. Exactly as described: fetch_max admits writes under the new epoch, and recording happened after it. A write in that window is a record under an epoch whose start is not yet known, so the journal names a start above the first record that epoch wrote — and a later comparison attributes that record to the previous epoch, misreading the very boundary this vector exists to fix. Recording now precedes exposure, reading the tail as of before the epoch is servable.

The rest, all as reported

  • epoch_starts read the broken flag before the lock → flag now read under it.
  • A failed write/fsync allowed appends onto possibly-partial bytes → the journal poisons and refuses until reopened.
  • divergence_point reported two empty vectors as agreement at 0 → now None. You were right that this contradicted the API's own unknown-history contract.
  • open allocated on the file's length before the entry bound → bounded first.
  • No parent-directory sync on create → added.

One place I diverged from the suggested fix

Static replicas never recording their initial epoch — correct, and fixed. But I seed only when the log is also empty, which the suggestion did not require.

For a replica that already holds records, this process cannot know where its held epoch began; those records may have been written under an older one. Claiming it started at the current tail is a fabricated boundary, and a truncation computed from it could discard acknowledged records — the failure this whole vector exists to prevent. "Unknown" is the honest answer there, and the API already handles it.

Seeding also skips epoch 0, the "no grant yet" sentinel a lease-driven replica starts at. That one showed up live: the first version of the fix recorded (0,0) on the leader before its first real grant, claiming epoch 0 owned records it never wrote.

Verification

13 journal tests (3 new, aimed at the fixes above); workspace tests, clippy and fmt clean. Live, scenario 09 now produces complete lineage where the first version recorded almost none:

leader             (1,0)
promoted follower  (1,0) (2,400)
other follower     (1,0)

The promoted follower carries the whole range's lineage rather than just its own tenure — which is what makes it comparable against a peer at all, and the thing the original version silently failed to do.

@allamiro allamiro changed the title broker,node: record which fencing epoch wrote each stretch of the log (#240) broker,node,protocol: record and expose which fencing epoch wrote each stretch of the log (#240) Aug 5, 2026
@allamiro

allamiro commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Scope note — this PR grew since the review, and the description was stale. Flagging rather than letting it pass unnoticed.

2e5d8fb carried the nine review fixes and a second piece of work I had built alongside them: exposing the epoch vector on the replica-status plane. My git add -A swept it in. That is +162 lines of new wire protocol which the description claimed was absent ("Nothing reads the vector across the wire yet"). Title and body are now corrected.

Nothing is wrong with the code, but it deserves review attention it was not flagged for, so here is what was added:

New wire messages (kinds 67/68)

ReplicaEpochHistoryRequest / ReplicaEpochHistoryResponse, carrying Vec<ReplicaEpochStart>.

A new kind rather than widening kind 66. The decoder rejects trailing bytes, so growing ReplicaStatusResponse would make a new leader and an old replica unable to talk at all — not degrade, fail. As its own kind, a replica that does not know the message refuses it, and the client reads refusal as "history unknown", which is a state promotion must handle anyway. The degraded path is one that has to work regardless.

Three things worth checking

The decoder enforces advancement, not just the writer. A peer's history is a claim; a non-advancing one would hand this replica a truncation target that discards acknowledged records. Rejecting it only on write would leave the receiving side trusting whatever arrives.

Bounded before allocating (MAX_REPLICA_EPOCH_STARTS), for the same reason as the journal: the length is a peer's number.

ReplicaPeerHandler::epoch_history defaults to empty. A handler that does not track history is then honest by construction rather than failing to compile — and empty means "unknown", never "no leadership changes". The leader implements it too, since a promotion that could only read followers would reconcile against a strict subset of the range's lineage.

Found on the way in

There is a kind allowlist gating the frame header before dispatch, separate from the decode match. I missed it initially and the round-trip test failed with UnknownKind(68) despite a correct decode arm — worth knowing about for the next protocol addition.

Still true

Nothing consumes the vector yet. Promotion reading it and truncating against it is the next step of #240, and it needs both halves of this to exist.

Protocol round-trip tests cover a populated history, an empty one (the "unknown" case an older replica produces), and rejection of a non-advancing one. Workspace tests, clippy, fmt clean; scenarios 00 and 09 pass.

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

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/vtop-protocol/src/lib.rs">

<violation number="1" location="crates/vtop-protocol/src/lib.rs:353">
P3: The wire bound (4096) is inconsistent with the local journal bound (MAX_ENTRIES = 1<<20) in crates/vtop-broker/src/fencing_epochs.rs. A replica with a valid, locally recoverable journal holding more than 4096 (epoch, start) pairs — which `open()` accepts because it is within MAX_ENTRIES — will fail `encode_message` with a Limit error the moment its `epoch_history` is encoded, so it can never serve its intact vector and silently reports "unknown" on the wire. If 4096 is meant to be a hard product limit, the local writer should refuse to grow past it; if not, the wire cap should match the local MAX_ENTRIES so a valid vector can always be transmitted.</violation>
</file>

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

Re-trigger cubic

Comment thread crates/vtop-broker/src/fencing_epochs.rs Outdated
Comment thread crates/vtop-protocol/src/lib.rs
Comment thread crates/vtop-broker/src/replication/network.rs
/// A range needs this many leadership changes to reach it; past that the
/// cluster has a flapping problem an operator must see, and the reader must
/// not allocate on a peer's say-so regardless.
pub const MAX_REPLICA_EPOCH_STARTS: usize = 4096;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The wire bound (4096) is inconsistent with the local journal bound (MAX_ENTRIES = 1<<20) in crates/vtop-broker/src/fencing_epochs.rs. A replica with a valid, locally recoverable journal holding more than 4096 (epoch, start) pairs — which open() accepts because it is within MAX_ENTRIES — will fail encode_message with a Limit error the moment its epoch_history is encoded, so it can never serve its intact vector and silently reports "unknown" on the wire. If 4096 is meant to be a hard product limit, the local writer should refuse to grow past it; if not, the wire cap should match the local MAX_ENTRIES so a valid vector can always be transmitted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/vtop-protocol/src/lib.rs, line 353:

<comment>The wire bound (4096) is inconsistent with the local journal bound (MAX_ENTRIES = 1<<20) in crates/vtop-broker/src/fencing_epochs.rs. A replica with a valid, locally recoverable journal holding more than 4096 (epoch, start) pairs — which `open()` accepts because it is within MAX_ENTRIES — will fail `encode_message` with a Limit error the moment its `epoch_history` is encoded, so it can never serve its intact vector and silently reports "unknown" on the wire. If 4096 is meant to be a hard product limit, the local writer should refuse to grow past it; if not, the wire cap should match the local MAX_ENTRIES so a valid vector can always be transmitted.</comment>

<file context>
@@ -317,6 +317,41 @@ pub struct ReplicaStatusResponse {
+/// A range needs this many leadership changes to reach it; past that the
+/// cluster has a flapping problem an operator must see, and the reader must
+/// not allocate on a peer's say-so regardless.
+pub const MAX_REPLICA_EPOCH_STARTS: usize = 4096;
+
 /// Lineage-aware durable consumer progress. Bound to topic epoch, range
</file context>

…240)

All four were real.

THE MIXED-VERSION DEGRADED PATH DID NOT WORK. The doc promised that an older
peer which does not know kind 67 yields an empty history rather than an
error, and the code only mapped an Error frame to empty. But such a peer
fails in its own read_frame, before any handler runs, and drops the
connection without writing a reply — so the case arrives as a clean EOF,
which was a hard error. The one path the comment existed to guarantee was
the one it did not cover. Now handled, which means conflating "too old to
answer" with "died mid-request": safe here because telling those apart is
not this call's job. Liveness comes from the status probe, which still fails
loudly, and both mean the same thing for the value returned — we do not know
this replica's history. Unknown only ever disables epoch reconciliation; it
never authorises a truncation. A malformed reply or bad peer identity is
still an error, because those are faults rather than answers.

THE WIRE BOUND AND THE LOCAL BOUND DISAGREED — 4096 against 1<<20. A replica
could hold a journal this code accepts on open and be structurally unable to
transmit it, reporting "unknown" to every peer forever while its vector sat
intact on disk. The bounds are now one constant, taken from the wire, so
"locally recoverable" implies "transmittable" by construction. The wire is
the right source: a vector exists to be compared against a peer's, so one too
large to send cannot do the only job it has.

The size branch reserved before enforcing the bound, so an over-long history
allocated on the way to being refused. Bounded in the branch encode_frame
actually reserves from.

sync_parent used the empty path from Path::parent on a bare filename, so a
journal opened by relative name failed on create for a reason unrelated to
the journal. Normalised to ".".

Tests: a journal opens at a bare relative path, and the two bounds are
asserted equal so they cannot drift apart again.
@allamiro

allamiro commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

All four fixed in 04b3bf1. All were real; one was a promise the code did not keep.

The mixed-version degraded path did not work

You are right, and this is the finding that mattered. The doc comment guaranteed that an older peer yields an empty history rather than an error, and the code only mapped Message::Error to empty. But such a peer fails in its own read_frame, before any handler runs, and drops the connection without writing a reply — so the case arrives as a clean EOF, which was a hard error. The single path the comment existed to guarantee was the one path it did not cover.

Now handled at the read_frame returning None site. That does conflate "too old to answer" with "died mid-request", which I want to be explicit about rather than quiet: it is safe because telling those apart is not this call's job. Liveness is established by the status probe, which still fails loudly. Both cases mean the same thing for the value being returned — we do not know this replica's history — and unknown only ever disables epoch reconciliation, never authorises a truncation. A malformed reply, TLS failure, or wrong peer identity is still an error, because those are faults rather than answers. The doc now says all of that instead of implying the transport does it for us.

The two bounds disagreed

4096 on the wire against 1<<20 locally, exactly as you describe: a replica could hold a journal open() accepts and be structurally unable to transmit it, reporting "unknown" to every peer forever while its vector sat intact on disk.

I took your first option — one constant — and sourced it from the wire, so "locally recoverable" implies "transmittable" by construction. The wire is the right authority here: this vector exists to be compared against a peer's, so one too large to send cannot do the only job it has. Raising the wire cap to 1<<20 would instead have made a 16MB status response legal, which is not a thing a promotion should be waiting on. There is a test asserting the two are equal so they cannot drift apart again.

Worth noting the local bound was never a cliff: hitting it makes record fail, which marks the history broken and reports "unknown" — the same degradation, just reached sooner now.

Allocating before the bound

Correct, and the fix is yours. The check lived in the writer, which runs after encode_frame has already reserved from the size branch. Bounded in the branch that actually reserves.

Bare relative path

Also correct — Path::parent of a bare filename is Some(""), not None, so the guard fell through to syncing a path that is not a directory. Normalised to ".", with a test that opens a journal by relative name.

Workspace tests, clippy (-D warnings) and fmt clean.

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

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/vtop-broker/src/fencing_epochs.rs">

<violation number="1" location="crates/vtop-broker/src/fencing_epochs.rs:627">
P2: The new test mutates the process-wide current directory without synchronization, so a concurrent cwd-sensitive test can resolve relative paths inside this temporary directory and become flaky. Running this check in a subprocess or using a lock shared by all cwd-sensitive tests would isolate the global state.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

let previous = std::env::current_dir().unwrap();
// Serialised against other tests only by being the single one that
// changes the process directory; it restores it before returning.
std::env::set_current_dir(dir.path()).unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The new test mutates the process-wide current directory without synchronization, so a concurrent cwd-sensitive test can resolve relative paths inside this temporary directory and become flaky. Running this check in a subprocess or using a lock shared by all cwd-sensitive tests would isolate the global state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/vtop-broker/src/fencing_epochs.rs, line 627:

<comment>The new test mutates the process-wide current directory without synchronization, so a concurrent cwd-sensitive test can resolve relative paths inside this temporary directory and become flaky. Running this check in a subprocess or using a lock shared by all cwd-sensitive tests would isolate the global state.</comment>

<file context>
@@ -602,4 +615,30 @@ mod tests {
+        let previous = std::env::current_dir().unwrap();
+        // Serialised against other tests only by being the single one that
+        // changes the process directory; it restores it before returning.
+        std::env::set_current_dir(dir.path()).unwrap();
+        let opened = FencingEpochJournal::open("fencing-epochs");
+        std::env::set_current_dir(previous).unwrap();
</file context>

@allamiro
allamiro merged commit f04c839 into main Aug 5, 2026
17 checks passed
@allamiro
allamiro deleted the feat/240-leader-epoch-cache branch August 5, 2026 22:50
allamiro added a commit that referenced this pull request Aug 5, 2026
…260)

v0.1.0's release body was entirely boilerplate — install commands, cosign invocations, a maturity banner — and not one word about what the release contained. Someone deciding whether to upgrade learned nothing from it.

The cause: generate_release_notes: true was set, but an explicit body takes precedence over it, so the generated notes were never rendered. The option looked like it was doing its job and was doing nothing.

The changelog is now generated from the tag range: one entry per merged pull request, grouped by the component prefix the commit subjects already carry, followed by the issues those pull requests closed — each with a title and a link.

Built from the API rather than the commit text. Squash subjects look like 'broker: subject (#240) (#258)', where the trailing number is the pull request and an earlier one may be the issue — but only sometimes: a change with no issue has one number, a change closing two has three. Parsing positionally guesses, and a changelog that mislabels an issue as a pull request is worse than none. A trailing reference that IS closed is dropped because the closes link says it; one that is NOT closed is a different fact and becomes a link rather than being discarded.

Install and verification instructions move to docs/RELEASE_VERIFICATION.md, linked from the body. They are reference material: unchanged between releases, wrong to re-read every time, and long enough that they buried the part that varies. The doc now also states what SHA256SUMS and the Sigstore bundle each establish, since checking one and skipping the other is the easy mistake.

The generator is linted in CI, path-filtered onto itself. It only ever executes during a tag build, where a failure means a published release with a broken body and no chance to fix it before anyone sees it.
allamiro added a commit that referenced this pull request Aug 6, 2026
Bumps the workspace to 0.2.0. The release workflow cross-checks the tag against this value, so it lands before the tag is pushed.

v0.1.0 shipped verified promotion as a quorum-proven floor. Everything since closes the ways that floor could be computed from unsound inputs, or acted on in ways that lost acknowledged data:

  #258 replicas record which fencing epoch wrote each stretch of their log and can be asked for it, so two replicas reporting offset 90 are no longer indistinguishable when only one holds the same record there.
  #259 a diverged replica is truncated instead of stranded, bounded so it can never discard acknowledged records.
  #262 replicas are fenced and read in one round trip, and a replica that could not be fenced does not count toward the quorum: an offset now either comes from a log that has been stopped, or it does not come at all.
  #263 a replica reconciles against the candidate while fenced, so it agrees before it answers. Closed #261, where a diverged replica acked a new leader's writes as duplicates and could be counted toward a quorum for bytes it did not hold.
  #266 committed high-water marks stop being droppable. They rode a try_send whose result was discarded, so a loaded follower silently stopped learning what had been acknowledged, and that mark is the bound that stops truncation from discarding acknowledged records.

Release notes now carry a real changelog with linked issues and pull requests instead of install boilerplate, which moved to docs/RELEASE_VERIFICATION.md (#260). The generator's linkifier is anchored to standalone references so it cannot rewrite a URL fragment or nest a link a title already carried, and its trailer cleanup only runs on a trailer it actually edited.

Not closed, and stated rather than left to be discovered: #240 stays open for the Raft 5.4.1/5.4.2 question of whether the fence plus the acknowledged-records bound substitute for an election restriction, given metadata grants the lease with no log-completeness condition on the candidate. An attempt at a new-epoch marker (#265) was withdrawn: it could not be encoded, and it did not close the hazard it was written for. The signed leadership-transition record is also outstanding and wants #255's segment transfer first.
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