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

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


The following commit(s) were added to refs/heads/rewind_wal_parent_fix by this 
push:
     new c90b04fbb test(cluster): cover the repair frontier rewind in the 
simulator
c90b04fbb is described below

commit c90b04fbbd1477afb9037f929fce4eb8da43f553
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Thu Aug 27 16:46:11 2026 +0200

    test(cluster): cover the repair frontier rewind in the simulator
    
    The fix for the rewound WAL parent shipped without a test that
    fails without it: reverting either call site leaves every case
    green, because they exercise the helper rather than the ingest
    that calls it.
    
    The simulator builds a real IggyShard, so a crashed-then-restarted
    backup drives the actual repair ingest deterministically. The
    assertion is the contract itself - no entry landing below the
    sequencer may become last_prepare_checksum - which catches the
    rewind at the backfill instead of waiting for a projected prepare
    and the journal rewrite that finally makes the broken chain
    adjacent on disk.
    
    A black-box cluster test cannot reach the boot failure without
    also choreographing a promotion-free window and that rewrite. This
    runs in 0.04s with neither.
---
 core/simulator/src/lib.rs | 138 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 138 insertions(+)

diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 8e971c708..d7a82b3ad 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -3530,3 +3530,141 @@ mod view_change_data_loss_tests {
         );
     }
 }
+
+#[cfg(test)]
+mod repair_frontier_tests {
+    //! Journal repair moves durable coverage, not the head of the hash chain.
+    //!
+    //! A replica that rejoins behind the group adopts the primary's head and
+    //! then backfills the ops it missed. Those land BELOW that head, so the
+    //! pair `(sequencer, last_prepare_checksum)` has to keep describing one
+    //! and the same entry: the pair is exactly what the next projected prepare
+    //! stamps as `(op, parent)`. Carrying the repaired frame's own checksum
+    //! instead rewinds the parent, and the next prepare then chains past the
+    //! entry that actually precedes it -- every entry individually well
+    //! sealed, the chain broken, and a WAL that refuses to boot as soon as a
+    //! rewrite (checkpoint drain or uncommitted-suffix truncation) puts the
+    //! two entries side by side.
+
+    use super::*;
+    use consensus::Sequencer;
+    use journal::Journal;
+    use std::collections::BTreeMap;
+
+    /// Replica 0 is primary for view 0, so this one stays a backup for the
+    /// whole run: only the repair ingest is under test, not an election.
+    const LAGGING: u8 = 1;
+
+    /// Committed ops the lagging replica misses and has to repair back.
+    const OPS_MISSED: usize = 20;
+
+    /// Steps allowed for the rejoin, the adoption, and the repair stream.
+    const REPAIR_STEPS: usize = 4000;
+
+    /// Highest op the journal probe walks. The workload stays far below it.
+    const OP_PROBE_CEILING: u64 = 512;
+
+    /// `(sequencer, last_prepare_checksum)` of a replica's metadata consensus:
+    /// the `(op, parent)` its next projected prepare would stamp.
+    fn metadata_chain_head(sim: &Simulator, replica: u8) -> (u64, u128) {
+        let consensus = sim.replicas[replica as usize].shards[0]
+            .plane
+            .metadata()
+            .consensus
+            .as_ref()
+            .expect("shard 0 owns metadata consensus");
+        (
+            consensus.sequencer().current_sequence(),
+            consensus.last_prepare_checksum(),
+        )
+    }
+
+    /// Every op a replica's metadata journal holds, with its checksum.
+    fn metadata_journal_checksums(sim: &Simulator, replica: u8) -> 
BTreeMap<u64, u128> {
+        let journal = sim.replicas[replica as usize].shards[0]
+            .plane
+            .metadata()
+            .journal
+            .as_ref()
+            .expect("shard 0 owns the metadata journal");
+        (1..=OP_PROBE_CEILING)
+            .filter_map(|op| {
+                let slot = usize::try_from(op).expect("op fits usize");
+                Journal::header(journal.as_ref(), slot).map(|header| (op, 
header.checksum))
+            })
+            .collect()
+    }
+
+    #[test]
+    fn 
given_repair_below_the_head_when_backfilling_should_not_rewind_the_parent() {
+        
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
+            enabled: false,
+            size: iggy_common::IggyByteSize::from(0u64),
+            bucket_capacity: 1,
+        });
+
+        let replica_count: u8 = 3;
+        let client_id: u128 = 1;
+        let network_opts = packet::PacketSimulatorOptions {
+            node_count: replica_count,
+            client_count: 1,
+            ..packet::PacketSimulatorOptions::default()
+        };
+        let mut sim = Simulator::new(
+            replica_count as usize,
+            std::iter::once(client_id),
+            network_opts,
+        );
+        let client = SimClient::new(client_id);
+
+        sim.register_client_with_primary(&client);
+        for _ in 0..50 {
+            sim.step();
+        }
+
+        sim.replica_crash(LAGGING);
+        for index in 0..OPS_MISSED {
+            let msg = client.create_stream(&format!("gap-{index}"));
+            sim.submit_request(client_id, 0, msg.into_generic());
+            for _ in 0..30 {
+                sim.step();
+            }
+        }
+
+        sim.replica_restart(LAGGING);
+
+        // Everything the restart recovered locally is the baseline; anything
+        // that appears from here arrived over the wire.
+        let mut journaled = metadata_journal_checksums(&sim, LAGGING);
+        let mut backfills_below_head = 0usize;
+        for _ in 0..REPAIR_STEPS {
+            sim.step();
+            let (head, parent) = metadata_chain_head(&sim, LAGGING);
+            let current = metadata_journal_checksums(&sim, LAGGING);
+            for (&op, &checksum) in &current {
+                // A live replicated op IS the head, and the pair moves with 
it.
+                // Only entries that landed below the head are repair backfill.
+                if journaled.contains_key(&op) || op >= head {
+                    continue;
+                }
+                backfills_below_head += 1;
+                assert_ne!(
+                    parent,
+                    checksum,
+                    "repairing op {op} rewound the parent of the next prepare: 
the \
+                     sequencer sits at op {head} but last_prepare_checksum now 
\
+                     describes op {op}, so the next projected prepare would be 
op \
+                     {} parented past op {head}",
+                    head + 1
+                );
+            }
+            journaled = current;
+        }
+
+        assert!(
+            backfills_below_head > 0,
+            "the rejoined replica never repaired an op below its own head, so \
+             nothing about the repair frontier was exercised"
+        );
+    }
+}

Reply via email to