This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new fa8070494 fix(cluster): stop partition repair asking for an inverted 
op range (#4246)
fa8070494 is described below

commit fa8070494a94a1e78f0b598fd1d394ad34ae7679
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Tue Sep 22 10:02:20 2026 +0200

    fix(cluster): stop partition repair asking for an inverted op range (#4246)
---
 core/partitions/src/iggy_partition.rs   | 80 +++++++++++++++++++++++++++++++--
 core/server/src/partition_reconciler.rs | 67 +++++++++++++++++++++++++++
 core/shard/src/lib.rs                   | 30 +++++++++++--
 3 files changed, 171 insertions(+), 6 deletions(-)

diff --git a/core/partitions/src/iggy_partition.rs 
b/core/partitions/src/iggy_partition.rs
index 8f7f4f100..d3ae0e6c7 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -8202,7 +8202,16 @@ where
             self.repair = None;
             return RepairConclusion::Done;
         }
-        if let Some(floor) = session.floor {
+        // A floor at or below the live commit point is moot: it cannot move
+        // `commit_min`, so nothing below it is skipped and the connection
+        // check has no jump to guard. Verifying it could only refuse: below
+        // `commit_to_op` the window never proved complete, because the ops
+        // between the floor and `commit_min` are committed with their headers
+        // evicted, and at `commit_to_op` the empty window refused outright.
+        if let Some(floor) = session
+            .floor
+            .filter(|&floor| floor > self.consensus().commit_min())
+        {
             // A peer may have evicted past this replica's commit frontier;
             // an unclamped floor would drive commit_min above commit_max and
             // panic the next advance.
@@ -8276,8 +8285,7 @@ where
                 }
                 return RepairConclusion::InProgress;
             }
-            let commit_min = self.consensus().commit_min();
-            if floor > commit_min {
+            if floor > self.consensus().commit_min() {
                 self.consensus().set_commit_floor(floor);
             }
         }
@@ -15663,6 +15671,72 @@ mod tests {
         assert_eq!(partition.consensus().commit_min(), 0);
         assert!(partition.repair.is_some());
     }
+
+    /// A `RangeEvicted` floor arrives above `commit_min`, and the walk passes
+    /// it before `RepairDone` lands: ops resident just above the commit point
+    /// committed and their headers were evicted. Verifying that moot floor
+    /// refused every round (evicted headers never complete the window) while
+    /// the walk inside the refusal ran `commit_min` to the fetch ceiling, and
+    /// the reply handler asked the peer for `fetch_to_op + 1 ..= fetch_to_op`.
+    #[compio::test]
+    async fn 
given_floor_below_commit_min_when_completing_repair_should_ignore_the_floor() {
+        let mut partition = test_partition();
+        partition.consensus().restore_commit_state(7, 8);
+        // Disconnected on its face: the served window starts at offset 20 and
+        // the boot-recovered segments end at 10. Meaningless below commit_min.
+        partition.recovered_durable_offset = Some(10);
+        journal_prepare(&partition, 8, Operation::CreateStream).await;
+        partition.repair = Some(armed_session(8, 5, Some(20)));
+
+        let conclusion = partition.complete_repair(&repair_config()).await;
+
+        assert_eq!(conclusion, RepairConclusion::Done);
+        assert_eq!(partition.consensus().commit_min(), 8);
+        assert!(
+            partition.repair.is_none(),
+            "nothing is left to fetch, so the session must close instead of \
+             re-requesting past its ceiling"
+        );
+    }
+
+    #[compio::test]
+    async fn 
given_floor_clamped_to_commit_min_when_completing_repair_should_still_refuse() {
+        let mut partition = test_partition();
+        partition.consensus().restore_commit_state(5, 5);
+        // The peer retains nothing below op 10, so it cannot serve the suffix
+        // either. Only the raw floor tells this apart from a moot one.
+        partition.repair = Some(armed_fetch_session(5, 9, 9, None));
+
+        let conclusion = partition.complete_repair(&repair_config()).await;
+
+        assert_eq!(
+            conclusion,
+            RepairConclusion::FloorRefused { floor: 5, to_op: 5 },
+            "a floor the clamp pulls down to commit_min still names an evicted 
\
+             range and must escape to state transfer"
+        );
+        assert!(partition.repair.is_none());
+    }
+
+    #[compio::test]
+    async fn 
given_moot_floor_with_unfetched_suffix_when_completing_repair_should_keep_repairing()
 {
+        let mut partition = test_partition();
+        partition.consensus().restore_commit_state(5, 5);
+        // The peer retains from op 6, everything this replica still needs, so
+        // the suffix fetch must go on. Escaping to state transfer here copied
+        // segments for a window the peer can serve.
+        partition.repair = Some(armed_fetch_session(5, 9, 5, None));
+
+        let conclusion = partition.complete_repair(&repair_config()).await;
+
+        assert_eq!(conclusion, RepairConclusion::InProgress);
+        let session = partition.repair.expect("the suffix fetch stays armed");
+        assert!(
+            partition.consensus().commit_min() < session.fetch_to_op,
+            "the stall retry must still have a range to ask for"
+        );
+    }
+
     /// Temp partition directory for the state-transfer fence specs below.
     async fn transfer_fence_dir(label: &str) -> String {
         let dir = std::env::temp_dir().join(format!(
diff --git a/core/server/src/partition_reconciler.rs 
b/core/server/src/partition_reconciler.rs
index 2e1ce4c3e..85894ccb9 100644
--- a/core/server/src/partition_reconciler.rs
+++ b/core/server/src/partition_reconciler.rs
@@ -1488,6 +1488,7 @@ mod tests {
         Command, Operation, PrepareHeader, RepairRangeReplyHeader, ReplyHeader,
         RequestPreparesHeader, RoutedRequestHeader, WireIdentifier, 
WireOptions,
     };
+    use journal::Journal;
     use message_bus::IggyMessageBus;
     use metadata::IggyMetadata;
     use metadata::MuxStateMachine;
@@ -3084,6 +3085,72 @@ mod tests {
         );
     }
 
+    /// Receive half of the inverted-range fix: a `RepairDone` landing on a
+    /// session whose floor the commit walk already passed must close the
+    /// session. Verifying the moot floor kept it armed while the walk ran to
+    /// the fetch ceiling, and the next chunk request was `9..=8`.
+    #[compio::test]
+    async fn repair_done_over_a_passed_floor_closes_the_session() {
+        const NONCE: u128 = 13;
+        let tmp = TempDir::new().expect("tempdir for system path");
+        let config = test_config(&tmp);
+        let mux = TestMux::default();
+        seed_stream(&mux, 1, "stream-repair-ceiling");
+        seed_topic(&mux, 2, 0, "topic-repair-ceiling", vec![assignment(0, 1)]);
+
+        let shard = build_test_shard(0, &config, mux);
+        let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+        reconcile_pass(&ctx).await;
+
+        let ns = IggyNamespace::new(0, 0, 0);
+        let served = CreateStreamRequest {
+            name: WireName::new("served-op").expect("test stream name fits 
WireName"),
+            options: WireOptions::empty(),
+        };
+        {
+            let partitions = shard.plane.partitions();
+            let partition = partitions
+                .get_mut_by_ns(&ns)
+                .expect("partition is materialised");
+            // Ops 5..=7 committed and evicted after the request went out, op 8
+            // is the served remainder, and the window's first batch sits above
+            // the boot-recovered durable end.
+            partition.consensus().restore_commit_state(7, 8);
+            partition.recovered_durable_offset = Some(10);
+            partition
+                .log
+                .journal()
+                .inner
+                .append(build_prepare(8, Operation::CreateStream, 
&served).into_frozen())
+                .await
+                .expect("journal the served op");
+            partition.repair = Some(RepairSession {
+                nonce: NONCE,
+                view: 0,
+                commit_to_op: 8,
+                fetch_to_op: 8,
+                floor: Some(5),
+                peer: 1,
+                first_batch_offset: Some(20),
+                idle_ticks: 0,
+            });
+        }
+
+        shard
+            .on_message(build_repair_range_reply(ns, Command::RepairDone, 
NONCE, 8))
+            .await;
+
+        let partitions = shard.plane.partitions();
+        let partition = partitions
+            .get_mut_by_ns(&ns)
+            .expect("partition survives the reply");
+        assert_eq!(partition.consensus().commit_min(), 8);
+        assert!(
+            partition.repair.is_none(),
+            "a walk that reached the fetch ceiling leaves nothing to request"
+        );
+    }
+
     /// Serve half of the purge gate in `on_request_prepares`: while a
     /// committed purge has not applied locally, the journal still holds
     /// pre-purge entries with no floor to fence them, so serving a rejoiner
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 287b747b8..dd188fc0d 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -5701,17 +5701,18 @@ where
                 } else {
                     let commit_min = partition.consensus().commit_min();
                     let next = partition.repair.as_ref().and_then(|live| {
-                        (commit_min > before).then_some((live.peer, 
live.nonce, live.fetch_to_op))
+                        partition_repair_next_chunk(before, commit_min, 
live.fetch_to_op)
+                            .map(|from_op| (live.peer, live.nonce, from_op, 
live.fetch_to_op))
                     });
                     let cluster = partition.consensus().cluster();
                     let self_id = partition.consensus().replica();
-                    if let Some((peer, nonce, to_op)) = next {
+                    if let Some((peer, nonce, from_op, to_op)) = next {
                         self.send_request_prepares(
                             cluster,
                             self_id,
                             peer,
                             nonce,
-                            commit_min + 1,
+                            from_op,
                             to_op,
                             header.group,
                         )
@@ -11061,6 +11062,18 @@ fn partition_repair_fetch_to_op(
         .then(|| missing_suffix.unwrap_or(commit_max))
 }
 
+/// Start of the next chunk to pull after a `RepairDone`, or `None` when the
+/// walk made no progress (the stall retry owns the remainder) or already
+/// stands at the session's fetch ceiling. The sweep closes such a session,
+/// because `fetch_to_op` never sits below `commit_to_op`.
+///
+/// A session can outlive its last fetchable op until that close, and
+/// `from_op > to_op` fails `RequestPreparesHeader::validate` on the serving
+/// peer, which drops the frame as unparsable.
+fn partition_repair_next_chunk(before: u64, commit_min: u64, fetch_to_op: u64) 
-> Option<u64> {
+    (commit_min > before && commit_min < fetch_to_op).then_some(commit_min + 1)
+}
+
 /// Highest adopted suffix op whose bodies are not all present above 
`commit_max`.
 ///
 /// The shape `maybe_request_partition_repair` widens its window for, read here
@@ -12067,6 +12080,17 @@ mod repair_scope_tests {
         assert_eq!(adopted_suffix_head(&missing, 98, 101), None);
     }
 
+    #[test]
+    fn 
given_a_walk_at_the_fetch_ceiling_when_repair_done_lands_should_not_request_a_chunk()
 {
+        assert_eq!(super::partition_repair_next_chunk(4, 7, 8), Some(8));
+        // No progress leaves the remainder to the stall retry.
+        assert_eq!(super::partition_repair_next_chunk(7, 7, 8), None);
+        // Progress that reached the ceiling has nothing left to ask for: the
+        // sweep closes the session, and `9..=8` is not a range.
+        assert_eq!(super::partition_repair_next_chunk(7, 8, 8), None);
+        assert_eq!(super::partition_repair_next_chunk(7, 9, 8), None);
+    }
+
     #[test]
     fn 
given_a_parked_view_when_fetching_above_commit_should_require_dense_canonical_suffix()
 {
         let pending = parked();

Reply via email to