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

numinnex 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 89dea0ed3 fix(metadata): truncate the WAL suffix above the 
state-transfer floor (#3918)
89dea0ed3 is described below

commit 89dea0ed3c8853ba27b38b28dde5f7263779126b
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Thu Aug 20 14:15:08 2026 +0200

    fix(metadata): truncate the WAL suffix above the state-transfer floor 
(#3918)
---
 core/consensus/src/impls.rs         |  10 +--
 core/metadata/src/impls/metadata.rs | 150 +++++++++++++++++++++++++++++++-----
 core/shard/src/lib.rs               |  58 +++++++++-----
 3 files changed, 174 insertions(+), 44 deletions(-)

diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 33838ee63..3a83c0e2c 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -3194,11 +3194,11 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> 
VsrConsensus<B, P> {
         // outranks the real primary's and pushes ops that view already 
discarded back
         // over committed bodies.
         //
-        // TODO(suffix-truncation): re-adoption drops the head again, but the 
WAL
-        // still holds the discarded suffix, so the primary's next prepare 
lands on an
-        // op that suffix already occupies and fails `append`'s slot-collision 
check,
-        // poisoning the journal. Loud beats the silent divergence above; a 
durable
-        // truncate-from-op primitive is what actually closes it.
+        // Re-adoption drops the head again while the WAL still holds the 
discarded
+        // suffix. Consensus is sans-io and cannot truncate it; the plane 
sweeps it
+        // on every adoption (`reconcile_{metadata,partition}_view_divergence`,
+        // above-head branch) before the primary's next prepare can collide 
with a
+        // relic in `append`'s slot-collision check.
         if msg_view == self.log_view.get() && msg_op < self.commit_min() {
             return Vec::new();
         }
diff --git a/core/metadata/src/impls/metadata.rs 
b/core/metadata/src/impls/metadata.rs
index 86170553d..9856dd02a 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -1694,6 +1694,40 @@ where
         let local_applied = consensus.commit_min();
         let snapshot_ahead = snapshot_seq > local_applied;
 
+        if snapshot_ahead && let Some(journal) = &self.journal {
+            // Discard the WAL suffix above the incoming floor BEFORE anything
+            // installs: the commit walk matches entries by op number alone, so
+            // a pre-crash suffix a view change has since reassigned would be
+            // applied as committed once the floor jump below pulls the walk
+            // past it. Ahead of the snapshot restore so a failed truncate
+            // aborts a not-yet-started install (the transfer retries) instead
+            // of stranding a restored state machine without its floor jump,
+            // which would double-apply the snapshot's ops on the next walk.
+            // Committed ops the range covered come back through the
+            // post-install tail repair; uncommitted ones were decided away by
+            // the view change that made this replica a transfer receiver.
+            // Serialization against appends holds as in
+            // `reconcile_metadata_view_divergence`: the pump is
+            // single-threaded and `replicate_preflight` refuses prepares
+            // while `is_transferring`.
+            let removed = journal
+                .handle()
+                .truncate_from(snapshot_seq + 1)
+                .await
+                .map_err(SnapshotError::Io)?;
+            if removed > 0 {
+                // The DVC snapshot's `(op, commit)` tag does not move when
+                // entries are removed under it; left stale it would advertise
+                // headers this replica can no longer serve.
+                consensus.invalidate_local_dvc_suffix();
+                tracing::warn!(
+                    snapshot_seq,
+                    removed,
+                    "state transfer dropped {removed} journal entries above 
the incoming floor"
+                );
+            }
+        }
+
         if snapshot_ahead {
             if let Some(coordinator) = &self.coordinator {
                 // The transferred snapshot REPLACES the one the superblock's
@@ -1760,26 +1794,9 @@ where
             // The snapshot IS ops `..=snapshot_seq` applied: jump the applied
             // frontier (this is the op-jump the tail repair resumes from) and
             // let the announced commit point pull the walk target forward.
-            //
-            // TODO(suffix-truncation): this hands the commit walk a floor it 
never
-            // verified. `set_snapshot_op` above only marks entries at or 
below the
-            // floor EVICTABLE -- everything above it stays resident -- and 
the walk
-            // matches WAL entries by op number alone, with no view or 
hash-chain
-            // check. A node carrying a pre-crash prepared-but-uncommitted 
suffix that
-            // a view change has since reassigned cluster-side will therefore 
apply
-            // those stale bodies as committed. The client table is shielded 
(the
-            // `client_table_frontier` fence skips table effects at or below 
the
-            // transferred frontier); the state machine is not.
-            //
-            // Same root cause as the `TODO(suffix-truncation)` in
-            // `VsrConsensus::handle_start_view`, and the same missing piece 
closes
-            // both: a durable truncate-from-op primitive on the journal, so a 
floor
-            // jump can discard the suffix above it instead of leaving it to be
-            // matched by op number. The floor jump does not create the hole, 
but it
-            // widens exposure to it precisely on the nodes guaranteed to have 
a stale
-            // log -- every state-transfer receiver is one. Deliberately out 
of scope
-            // here (flagged in review as follow-up): the primitive is a 
journal
-            // durability change, not a state-transfer one.
+            // The walk matches WAL entries by op number alone, so this floor
+            // is only safe because the truncate at the top of this install
+            // already discarded every journal entry above it.
             consensus.set_commit_floor(snapshot_seq);
             if snapshot_seq > consensus.sequencer().current_sequence() {
                 consensus.sequencer().set_sequence(snapshot_seq);
@@ -5297,6 +5314,99 @@ mod tests {
     /// `resume_stranded_commits` (wired into the shard pump tick) is the
     /// backstop: it re-enters the commit path, applies the stranded prefix,
     /// and promotes the queued register, whose awaiter then resolves.
+    /// A state-transfer receiver's WAL can hold a pre-crash suffix above the
+    /// incoming floor. The commit walk matches entries by op number alone, so
+    /// installing without discarding that suffix would later apply its stale
+    /// bodies as committed. The install must truncate the WAL above
+    /// `snapshot_seq` and keep everything at or below it, which tail repair
+    /// resumes from.
+    #[compio::test]
+    async fn 
state_transfer_install_truncates_the_wal_above_the_incoming_floor() {
+        const CLIENT: u128 = 9;
+        const SESSION: u64 = 1;
+        const ACTING_USER: u32 = 7;
+        const SNAPSHOT_SEQ: u64 = 2;
+
+        let dir = tempfile::tempdir().unwrap();
+        let journal =
+            
journal::prepare_journal::PrepareJournal::open(&dir.path().join("journal.wal"), 
0)
+                .await
+                .unwrap();
+        let consensus = VsrConsensus::new(
+            1,
+            0,
+            1,
+            server_common::sharding::METADATA_GROUP,
+            NoopBus,
+            LocalPipeline::new(),
+        );
+        consensus.init();
+        let md: IggyMetadata<_, journal::prepare_journal::PrepareJournal, (), 
TestMux> =
+            IggyMetadata::new(
+                Some(consensus),
+                Some(journal),
+                None,
+                None,
+                TestMux::default(),
+                None,
+            );
+        let consensus = md.consensus.as_ref().unwrap();
+        md.client_table.borrow_mut().commit_register(
+            CLIENT,
+            ACTING_USER,
+            register_reply(CLIENT, SESSION),
+        );
+
+        // Journal ops 1..=3 directly (no acks, so `commit_min` stays 0 and the
+        // transfer is "ahead"): 1 and 2 sit at or below the incoming floor, 3 
is
+        // the relic suffix a view change has since reassigned.
+        for (request_id, name) in [(1, "s1"), (2, "s2"), (3, "s3")] {
+            let projected = md
+                .prepare_request(create_stream_request(CLIENT, request_id, 
name))
+                .expect("CreateStream is client-allowed");
+            consensus.pipeline_message(PlaneKind::Metadata, &projected);
+            md.journal
+                .as_ref()
+                .unwrap()
+                .handle()
+                .append(projected)
+                .await
+                .expect("seeding the WAL succeeds");
+        }
+        let journal_handle = md.journal.as_ref().unwrap().handle();
+        assert_eq!(journal_handle.last_op(), Some(3));
+
+        // A donor mux fills the snapshot the way a serving primary would, so
+        // the restore path sees populated sections rather than a bare 
envelope.
+        let snapshot_bytes =
+            <IggySnapshot as Snapshot>::create(&TestMux::default(), 
SNAPSHOT_SEQ, 1)
+                .expect("donor snapshot builds")
+                .encode()
+                .expect("donor snapshot encodes");
+        md.install_state_transfer(
+            &snapshot_bytes,
+            ClientTable::new(CLIENTS_TABLE_MAX),
+            0,
+            SNAPSHOT_SEQ,
+        )
+        .await
+        .expect("install succeeds");
+
+        assert_eq!(
+            journal_handle.last_op(),
+            Some(SNAPSHOT_SEQ),
+            "the relic above the incoming floor must be gone"
+        );
+        assert!(
+            journal_handle.header(3).is_none(),
+            "op 3 was above the floor; the commit walk must never see it again"
+        );
+        assert!(
+            journal_handle.header(1).is_some() && 
journal_handle.header(2).is_some(),
+            "ops at or below the floor stay for the walk and tail repair"
+        );
+    }
+
     #[compio::test]
     async fn tick_backstop_must_resume_stranded_commits_and_promotions() {
         use std::future::Future;
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 0725798a4..762c932f2 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -3882,10 +3882,13 @@ where
                 .consensus()
                 .handle_start_view(PlaneKind::Partitions, &header, 
suffix_body);
         let adopted = !actions.is_empty();
-        if adopted && let Some(pending) = 
partition.consensus().pending_view_log() {
+        if adopted {
             // Ahead of the local dispatch, which rebuilds the pipeline out of 
the
-            // journal this rewrites. Same position as the metadata arm's twin.
-            reconcile_partition_view_divergence(self.id, partition, 
&pending).await;
+            // journal this rewrites. Same position as the metadata arm's 
twin, and
+            // like it, pending-less adoptions (empty StartView suffix) still 
sweep
+            // the relics above the adopted head.
+            let pending = partition.consensus().pending_view_log();
+            reconcile_partition_view_divergence(self.id, partition, 
pending.as_ref()).await;
         }
         let consensus = partition.consensus();
         let (local_actions, wire_actions) = split_local_actions(actions);
@@ -4895,7 +4898,7 @@ where
             // already holds a header for, so the scan would report a gap 
nothing
             // fills. Backups reach this on StartView adoption; a 
primary-elect has no
             // adoption to hang it off.
-            reconcile_partition_view_divergence(self.id, partition, 
&pending).await;
+            reconcile_partition_view_divergence(self.id, partition, 
Some(&pending)).await;
             let consensus = partition.consensus();
             // Identity, not presence: see the metadata twin. The floor is the 
local
             // commit point, the partition twin of the metadata snapshot floor:
@@ -5030,6 +5033,13 @@ where
     /// `RebuildPipeline` reads the pipeline back out of it, and 
`CommitJournal`
     /// applies whatever sits at each op up to the merged commit point.
     ///
+    /// Runs on every adoption, parked suffix or not: an EMPTY `StartView` 
suffix
+    /// (`commit == op`, the steady case) parks no pending log, yet adoption 
still
+    /// drops the head under any journaled relics above it, and the primary's 
next
+    /// prepare would collide with them in `append` and poison the journal. 
With
+    /// no pending log the divergence scan has nothing to walk and only the
+    /// above-head sweep applies, with the head read off the adopted sequencer.
+    ///
     /// The split at the announced commit point is what matters. Above it a
     /// disagreement is ordinary, so the entry is dropped and the primary's
     /// retransmission refills the range. At or below it, this replica applied
@@ -5052,9 +5062,7 @@ where
         let Some(ref consensus) = metadata.consensus else {
             return;
         };
-        let Some(pending) = consensus.pending_view_log() else {
-            return;
-        };
+        let pending = consensus.pending_view_log();
         let Some(journal) = metadata.journal.as_ref() else {
             return;
         };
@@ -5063,10 +5071,11 @@ where
         // not the view's commit point: `pending.commit_max` is the new 
primary's
         // number and a backup can sit above it. Splitting on the view's number
         // would drop already-executed ops with no rollback, and silently.
-        let applied_floor = pending.commit_max.max(consensus.commit_min());
+        let announced_commit = pending.as_ref().map_or(0, |pending| 
pending.commit_max);
+        let applied_floor = announced_commit.max(consensus.commit_min());
 
         let mut repairable_from: Option<u64> = None;
-        for canonical in &pending.headers {
+        for canonical in pending.as_ref().map_or(&[][..], |pending| 
&pending.headers) {
             let Some(local) = usize::try_from(canonical.op)
                 .ok()
                 .and_then(|slot| journal.handle().header(slot))
@@ -5081,7 +5090,7 @@ where
                     shard = self.id,
                     op = canonical.op,
                     view = consensus.view(),
-                    commit_max = pending.commit_max,
+                    commit_max = announced_commit,
                     commit_min = consensus.commit_min(),
                     local_checksum = local.checksum,
                     canonical_checksum = canonical.checksum,
@@ -5100,7 +5109,12 @@ where
         // under it, and the next prepare at `op_head + 1` then collides in 
`append`,
         // which refuses the slot even when the ops match. Floored at the 
applied
         // point too: an executed op is not rollback-able whatever the head 
says.
-        let above_head = pending.op_head.max(applied_floor) + 1;
+        // With no parked suffix the adopted sequencer IS the announced head.
+        let op_head = pending.as_ref().map_or_else(
+            || consensus.sequencer().current_sequence(),
+            |pending| pending.op_head,
+        );
+        let above_head = op_head.max(applied_floor) + 1;
         if journal
             .handle()
             .last_op()
@@ -5128,7 +5142,7 @@ where
                     shard = self.id,
                     from_op,
                     removed,
-                    op_head = pending.op_head,
+                    op_head,
                     view = consensus.view(),
                     "dropped {removed} uncommitted entries from op {from_op} 
that disagreed with \
                      the view's log; the primary's retransmission refills the 
range"
@@ -8766,17 +8780,18 @@ fn build_dvc_suffix(
 async fn reconcile_partition_view_divergence<B, SB>(
     shard: u16,
     partition: &mut IggyPartition<B, SB>,
-    pending: &MergedLog,
+    pending: Option<&MergedLog>,
 ) where
     B: MessageBus,
     SB: journal::superblock::SuperblockStore,
 {
     // Truncation is safe only above what this replica has *applied*, which is 
not
     // the view's commit point: a backup can sit above it.
-    let applied_floor = 
pending.commit_max.max(partition.consensus().commit_min());
+    let announced_commit = pending.map_or(0, |pending| pending.commit_max);
+    let applied_floor = 
announced_commit.max(partition.consensus().commit_min());
 
     let mut repairable_from: Option<u64> = None;
-    for canonical in &pending.headers {
+    for canonical in pending.map_or(&[][..], |pending| &pending.headers) {
         let Some(local) = 
partition.log.journal().inner.header_by_op(canonical.op) else {
             continue;
         };
@@ -8789,7 +8804,7 @@ async fn reconcile_partition_view_divergence<B, SB>(
                 namespace_raw = partition.consensus().group(),
                 op = canonical.op,
                 view = partition.consensus().view(),
-                commit_max = pending.commit_max,
+                commit_max = announced_commit,
                 commit_min = partition.consensus().commit_min(),
                 local_checksum = local.checksum,
                 canonical_checksum = canonical.checksum,
@@ -8803,8 +8818,13 @@ async fn reconcile_partition_view_divergence<B, SB>(
     }
 
     // The suffix above the announced head, which no canonical header names. 
As on
-    // the metadata twin, except here `append` pushes a duplicate rather than 
erroring.
-    let above_head = pending.op_head.max(applied_floor) + 1;
+    // the metadata twin, except here `append` pushes a duplicate rather than
+    // erroring. With no parked suffix the adopted sequencer IS the announced 
head.
+    let op_head = pending.map_or_else(
+        || partition.consensus().sequencer().current_sequence(),
+        |pending| pending.op_head,
+    );
+    let above_head = op_head.max(applied_floor) + 1;
     if partition
         .log
         .journal()
@@ -8825,7 +8845,7 @@ async fn reconcile_partition_view_divergence<B, SB>(
                 namespace_raw = partition.consensus().group(),
                 from_op,
                 removed,
-                op_head = pending.op_head,
+                op_head,
                 view = partition.consensus().view(),
                 "dropped {removed} uncommitted partition entries from op 
{from_op} that \
                  disagreed with the view's log; the primary's retransmission 
refills the range"

Reply via email to