Skip to content

broker: make the committed high-water mark a value, not a droppable event (#264) - #266

Merged
allamiro merged 1 commit into
mainfrom
fix/264-hwm-latest-wins
Aug 6, 2026
Merged

broker: make the committed high-water mark a value, not a droppable event (#264)#266
allamiro merged 1 commit into
mainfrom
fix/264-hwm-latest-wins

Conversation

@allamiro

@allamiro allamiro commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Closes #264.

propagate_committed_hwm rode the bounded per-follower command queue on a try_send whose result was discarded:

let _ = follower.cmd_tx.try_send(FollowerCmd::PropagateHwm { .. });

A follower under load — precisely the case where its command channel backs up — silently stopped learning what had been acknowledged, and stayed stopped, because a dropped update was never retried.

Why this is a safety fix, not a reporting one

A follower's cluster_committed is a safety input. It is the bound that stops truncation from discarding acknowledged records:

if offset < high_watermark {
    return Err(BrokerError::TruncationBelowAcknowledged { .. });
}

A follower stuck on a stale mark permits a truncation it should refuse. That is the guard protecting this path: a leader acknowledges a record on a quorum of two and dies; the surviving pair yields a promotion boundary below that record; the replica still holding it reconciles it away (#263). The bound is what makes that replica refuse and drop out of the quorum instead — so promotion fails rather than acknowledged data being lost.

I originally attributed that protection to a new-epoch marker in #265. That was wrong — the marker sits at the boundary and both replicas hold through it, so it acks and changes nothing. This is the fix.

The change

The mark is a monotonic scalar and a follower only ever needs the newest one, so the queue was modelling a value as a stream of events. It is now a watch cell: send_replace cannot fail on a full channel because there is no queue to fill, and a burst collapses to the newest value instead of overflowing. The failure mode is removed by construction rather than made observable.

Also re-sends the current mark on every fresh session. changed() only fires on new values, so a follower that was down when the last update was published would otherwise sit on a stale mark until the next one happened to arrive — and on a quiet range that may be never.

No new test, deliberately

I wrote one, then ran it against the old code: it passed there too. The stall it used blocks the follower's server-side apply, which does not back up the leader's command queue at all — the condition the bug actually needs. A green test that cannot fail on the broken code is worse than no test, because it reads as coverage.

I could not construct an end-to-end test that discriminates without reaching into private driver internals. Reporting that rather than shipping the misleading one.

The existing replication suite passes unchanged, which is what this change should do: it alters delivery, not behaviour, everywhere the queue was not already full.

Workspace tests, clippy -D warnings, fmt clean.


Summary by cubic

Switch committed high-water mark propagation to a latest-wins value using tokio::sync::watch, eliminating dropped updates under load. Fixes a safety bug where a stale follower mark could allow truncation below acknowledged records.

  • Bug Fixes
    • Replace queued PropagateHwm over mpsc with a watch::channel and send_replace, so bursts collapse to the newest value and updates can’t be dropped.
    • Re-send the current mark on new sessions so followers that were down don’t stay stale if no new updates arrive (changed() only fires on new values).

Written for commit d953af1. Summary will update on new commits.

Review in cubic

…vent (#264)

`propagate_committed_hwm` rode the bounded per-follower command queue on a
`try_send` whose result was discarded. A follower under load — the case where
its command channel backs up — silently stopped learning what had been
acknowledged, and stayed stopped, because a dropped update was never retried.

That is not a reporting nuisance. A follower's `cluster_committed` is a SAFETY
input: it is the bound that stops truncation from discarding acknowledged
records. A follower stuck on a stale mark permits a truncation it should
refuse, which is what made the Raft §5.4.2 failure path concrete rather than
theoretical.

The mark is a monotonic scalar and a follower only ever needs the newest one,
so the queue was modelling a VALUE as a stream of events. It is now a `watch`
cell: `send_replace` cannot fail on a full channel because there is no queue to
fill, and a burst collapses to the newest value instead of overflowing. The
failure mode is removed by construction rather than made observable.

Also re-sends the current mark on every fresh session. `changed()` only fires
on new values, so a follower that was down when the last update was published
would otherwise sit on a stale mark until the next one happened to arrive —
and on a quiet range that may be never.

NO NEW TEST, deliberately. I wrote one, and then ran it against the old code:
it passed there too, so it discriminated nothing. The stall it used blocks the
FOLLOWER's server-side apply, which does not back up the leader's command
queue at all — the condition the bug needs. A green test that cannot fail on
the broken code is worse than no test, because it reads as coverage. The
existing replication suite passes unchanged, which is what this change should
do: it alters delivery, not behaviour, everywhere the queue was not full.
@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 6, 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.

2 issues found across 1 file

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/replication/network.rs">

<violation number="1" location="crates/vtop-broker/src/replication/network.rs:870">
P1: A reconnecting follower can remain permanently below the committed HWM: this block sends the mark before replaying pending appends, and follower-side `observe_hwm` clamps it to current local durability while no HWM is sent after replay completes. Reapply the current mark after catch-up (or otherwise trigger HWM observation after the replay) so a quiet range cannot retain a stale truncation guard.</violation>

<violation number="2" location="crates/vtop-broker/src/replication/network.rs:874">
P3: The new high-water-mark send path is built twice with essentially identical logic: the fresh-session re-send block and the `changed()` arm both clone the current value via a scoped `borrow_and_update()` and then construct/write the same `CommittedHwmUpdate` frame (request_id 0, stream_id 0), differing only in error handling. Extracting a small helper (e.g. `write_hwm(&mut stream, &CommittedHwmUpdate) -> Result<(), SessionOutcome>`) and a `current_hwm()` accessor would remove the duplication and keep the two paths from drifting if the frame layout or error handling changes later. This is a low-severity maintainability nit; the safety logic itself is correct.</violation>
</file>

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

Re-trigger cubic

let guard = self.hwm_rx.borrow_and_update();
guard.clone()
};
if let Some(update) = current_hwm {

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: A reconnecting follower can remain permanently below the committed HWM: this block sends the mark before replaying pending appends, and follower-side observe_hwm clamps it to current local durability while no HWM is sent after replay completes. Reapply the current mark after catch-up (or otherwise trigger HWM observation after the replay) so a quiet range cannot retain a stale truncation guard.

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

<comment>A reconnecting follower can remain permanently below the committed HWM: this block sends the mark before replaying pending appends, and follower-side `observe_hwm` clamps it to current local durability while no HWM is sent after replay completes. Reapply the current mark after catch-up (or otherwise trigger HWM observation after the replay) so a quiet range cannot retain a stale truncation guard.</comment>

<file context>
@@ -841,6 +855,32 @@ impl FollowerDriver {
+            let guard = self.hwm_rx.borrow_and_update();
+            guard.clone()
+        };
+        if let Some(update) = current_hwm {
+            let frame = WireFrame {
+                request_id: 0,
</file context>

let frame = WireFrame {
request_id: 0,
stream_id: 0,
message: Message::CommittedHwmUpdate(update),

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 new high-water-mark send path is built twice with essentially identical logic: the fresh-session re-send block and the changed() arm both clone the current value via a scoped borrow_and_update() and then construct/write the same CommittedHwmUpdate frame (request_id 0, stream_id 0), differing only in error handling. Extracting a small helper (e.g. write_hwm(&mut stream, &CommittedHwmUpdate) -> Result<(), SessionOutcome>) and a current_hwm() accessor would remove the duplication and keep the two paths from drifting if the frame layout or error handling changes later. This is a low-severity maintainability nit; the safety logic itself is correct.

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

<comment>The new high-water-mark send path is built twice with essentially identical logic: the fresh-session re-send block and the `changed()` arm both clone the current value via a scoped `borrow_and_update()` and then construct/write the same `CommittedHwmUpdate` frame (request_id 0, stream_id 0), differing only in error handling. Extracting a small helper (e.g. `write_hwm(&mut stream, &CommittedHwmUpdate) -> Result<(), SessionOutcome>`) and a `current_hwm()` accessor would remove the duplication and keep the two paths from drifting if the frame layout or error handling changes later. This is a low-severity maintainability nit; the safety logic itself is correct.</comment>

<file context>
@@ -841,6 +855,32 @@ impl FollowerDriver {
+            let frame = WireFrame {
+                request_id: 0,
+                stream_id: 0,
+                message: Message::CommittedHwmUpdate(update),
+            };
+            if write_frame(&mut stream, &frame, REPLICA_LIMITS)
</file context>

@allamiro
allamiro merged commit 15d2a9a into main Aug 6, 2026
17 checks passed
@allamiro allamiro mentioned this pull request Aug 6, 2026
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.
allamiro added a commit that referenced this pull request Aug 7, 2026
…#275)

Fixes the flake recorded in #273 — and the issue's own diagnosis was exactly right on both counts:

1. **The fixed sleep.** `networked_quorum_acks_and_propagates_hwm` was the only assertion gate in the file still using a fixed settling sleep (50ms) instead of the deadline-poll pattern the rest of the suite already uses. Under a full-workspace parallel run that pause is a race. The follower-HWM/durable-offset assertions now poll behind a 5s deadline via a small `await_within` helper, and the failure message reports the observed state so a real regression is still loud and diagnosable.
2. **The default timeouts.** The produce itself rode `FlowControlConfig`'s 2s `ack_timeout`; a quorum ack that lands in milliseconds when the file runs alone can graze that under contention. This test asserts the *propagation property*, not timing, so it now owns a 10s ack timeout explicitly. Deliberately not changed globally: `slow_non_quorum_follower…` and `quorum_loss…` assert timing behaviour and keep their own budgets.

On the #266 connection the issue raised: the watch-cell change makes delivery latest-wins rather than queue-bounded, which is the right direction — the flake was the *test's* fixed clock assumption, not the mechanism.

Verified: suite passes 5/5 repeat runs locally; fmt + clippy clean.

Closes #273

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes the flaky `networked_quorum_acks_and_propagates_hwm` test (#273) by replacing a fixed sleep with deadline-based polling and setting a longer ack timeout to stabilize under load.

- **Bug Fixes**
  - Added `await_within` to poll follower HWM and durable offsets with a 5s deadline, with clear failure output.
  - Switched to `harness_with` and set `FlowControlConfig.ack_timeout` to 10s for this test (it asserts propagation, not timing).
  - Removed the 50ms settling sleep; assertions now wait for actual propagation.

<sup>Written for commit fe70076. Summary will update on new commits.</sup>

<a href="https://cubic.dev/pr/allamiro/vtop-engine/pull/275?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
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.

broker: committed high-water mark updates are silently dropped when a follower's channel is full

1 participant