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

slbotbm 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 1e1278299 fix(partitions): fence install backup on writeback failure 
(#4278)
1e1278299 is described below

commit 1e1278299bfc26bbfa7ed826a4096bc7bbcdd74b
Author: Gunther Xing <[email protected]>
AuthorDate: Wed Oct 14 13:21:12 2026 +0800

    fix(partitions): fence install backup on writeback failure (#4278)
---
 core/partitions/Cargo.toml               |   2 +-
 core/partitions/src/iggy_index_writer.rs |  13 +-
 core/partitions/src/iggy_partition.rs    | 141 ++++++++++++++++++++-
 core/partitions/src/install_backup.rs    |  84 ++++++++++---
 core/partitions/src/io.rs                |   9 +-
 core/partitions/src/lib.rs               |   6 +-
 core/partitions/src/messages_writer.rs   |  13 +-
 core/partitions/src/persistence.rs       |  67 +++++++---
 core/partitions/src/state_transfer.rs    |  25 ++--
 core/simulator/src/storage/tests.rs      | 209 ++++++++++++++++++++++++++++---
 10 files changed, 499 insertions(+), 70 deletions(-)

diff --git a/core/partitions/Cargo.toml b/core/partitions/Cargo.toml
index bb4b42056..35ff7f38e 100644
--- a/core/partitions/Cargo.toml
+++ b/core/partitions/Cargo.toml
@@ -37,7 +37,7 @@ publish = false
 poll-diagnostics = []
 
 # Simulator-only hooks: the dispatch shell's borrow-across-await detector and
-# original-writer checkpoint barriers for deterministic storage tests. A
+# original-writer file sync barriers for deterministic storage tests. A
 # `-p iggy-server` build excludes them; `cargo build --workspace` unifies
 # features so the shared `partitions` unit compiles them in when the simulator
 # requests it. No production caller.
diff --git a/core/partitions/src/iggy_index_writer.rs 
b/core/partitions/src/iggy_index_writer.rs
index 7a86dc064..337238cb6 100644
--- a/core/partitions/src/iggy_index_writer.rs
+++ b/core/partitions/src/iggy_index_writer.rs
@@ -171,10 +171,15 @@ impl IggyIndexWriter {
     ///
     /// Returns an error if the file cannot be synchronized.
     pub async fn fsync(&self) -> Result<(), IggyError> {
-        self.file
-            .sync_data()
-            .await
-            .map_err(|_| IggyError::CannotWriteToFile)?;
+        self.file.sync_data().await.map_err(|err| {
+            error!(
+                target: "iggy.partitions.storage",
+                file = self.file_path.as_str(),
+                %err,
+                "failed to sync index file"
+            );
+            IggyError::CannotWriteToFile
+        })?;
         Ok(())
     }
 }
diff --git a/core/partitions/src/iggy_partition.rs 
b/core/partitions/src/iggy_partition.rs
index dd85ade26..5a7785788 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -24,7 +24,7 @@ use crate::journal::{
 use crate::log::JournalInfo;
 use crate::log::SegmentedLog;
 use crate::persistence::{
-    CheckpointBarrier, PartitionPersistence, PersistenceCompletion, 
PersistenceNotifier,
+    FileSyncBarrier, PartitionPersistence, PersistenceCompletion, 
PersistenceNotifier,
 };
 use crate::poll_plan::{
     DiskReadPlan, DiskSegment, PartitionDirResolution, PollContext, PollPlan, 
PollReadResult,
@@ -91,10 +91,11 @@ use server_common::{
     sharding::IggyNamespace,
 };
 use std::cell::{Cell, RefCell};
-use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
+use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
 use std::fmt;
 use std::hash::Hash;
 use std::num::{NonZeroU32, NonZeroU64};
+use std::path::PathBuf;
 use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -1760,7 +1761,10 @@ where
                 });
                 return;
             }
-            barriers.push(CheckpointBarrier::already_synced(writer.path()));
+            // [`IggyIndexWriter::fsync`] used the original descriptor above.
+            // The marker tells [`PartitionPersistence::checkpoint_files`] not
+            // to replace that proof with a fresh-handle sync.
+            barriers.push(FileSyncBarrier::already_synced(writer.path()));
         }
         if let Err(error) = self.persist_retry_checkpoint(through_op).await {
             error!(%error, namespace_raw = self.namespace().inner(), 
"partition receipt checkpoint failed");
@@ -1778,6 +1782,30 @@ where
         self.start_persistence();
     }
 
+    /// Queue original-writer syncs before the install backup is published.
+    /// This runs even when the checkpoint frontier cannot advance.
+    pub(crate) fn barrier_install_files_locked(&self) -> BTreeSet<PathBuf> {
+        let Some(persistence) = &self.persistence else {
+            return BTreeSet::new();
+        };
+        let mut barriers = Vec::with_capacity(2);
+        if let Some(writer) = 
self.log.messages_writers().last().and_then(Option::as_ref) {
+            let path = writer.path();
+            let writer = Rc::clone(writer);
+            barriers.push(FileSyncBarrier::from_future(path, async move {
+                writer.fsync().await.map_err(std::io::Error::other)
+            }));
+        }
+        if let Some(writer) = 
self.log.index_writers().last().and_then(Option::as_ref) {
+            let path = writer.path().to_owned();
+            let writer = Rc::clone(writer);
+            barriers.push(FileSyncBarrier::from_future(path, async move {
+                writer.fsync().await.map_err(std::io::Error::other)
+            }));
+        }
+        persistence.barrier_files(barriers)
+    }
+
     fn persistence_checkpoint_files(
         &self,
         config: &PartitionsConfig,
@@ -1892,7 +1920,7 @@ where
             PendingCheckpoint::Enqueue(through, ref synced_index) => {
                 let barriers = synced_index
                     .iter()
-                    .map(CheckpointBarrier::already_synced)
+                    .map(FileSyncBarrier::already_synced)
                     .collect();
                 let (files, directories) = 
self.persistence_checkpoint_files(config);
                 if let Some(persistence) = &self.persistence {
@@ -12968,6 +12996,77 @@ mod tests {
         }
     }
 
+    #[cfg(target_os = "linux")]
+    #[compio::test]
+    async fn 
given_an_index_sync_failure_when_installing_state_transfer_should_refuse_backup_publication()
+     {
+        let directory = tempfile::tempdir().unwrap();
+        let (mut partition, _) = recording_partition_at(0, 3);
+        let partition_dir = directory.path().to_string_lossy().into_owned();
+        partition.set_partition_dir(partition_dir.clone());
+        partition.runtime_options.durability = 
iggy_common::Durability::Persisted;
+        for kind in ["consumers", "groups", "external_groups"] {
+            
std::fs::create_dir_all(directory.path().join("offsets").join(kind)).unwrap();
+        }
+        partition.consumer_offset_dirs[ConsumerKind::Consumer.index()] =
+            Some(format!("{partition_dir}/offsets/consumers"));
+        partition.consumer_offset_dirs[ConsumerKind::ConsumerGroup.index()] =
+            Some(format!("{partition_dir}/offsets/groups"));
+        partition.consumer_offset_dirs[ConsumerKind::ExternalGroup.index()] =
+            Some(format!("{partition_dir}/offsets/external_groups"));
+        partition.open_persistence().await.unwrap();
+        let persistence = Rc::clone(partition.persistence.as_ref().unwrap());
+        let prepare = checksummed_segment_prepare(1, 0, 0, b"durable");
+        persistence
+            .append(prepare.clone().into_frozen(), true)
+            .unwrap();
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        partition.consensus.restore_commit_state(1, 1);
+        persistence.checkpoint(1);
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        assert_eq!(persistence.checkpoint_op(), 1);
+
+        let writer = IggyIndexWriter::new("/dev/null", 
Rc::new(AtomicU64::new(0)), true, false)
+            .await
+            .unwrap();
+        assert_eq!(
+            writer
+                .save_indexes_buffered_at(vec![1; 32], 0)
+                .await
+                .unwrap(),
+            32
+        );
+        let active = partition.log.index_writers().len() - 1;
+        partition.log.index_writers_mut()[active] = Some(Rc::new(writer));
+        let offsets = crate::state_transfer::ConsumerOffsetsWire {
+            dedup_capacity: partition.dedup().capacity(),
+            required_metadata_frontier: 0,
+            prepare_checksum: Some(prepare.header().checksum),
+            checkpoint_prepare: prepare.as_slice().to_vec(),
+            next_offset: 1,
+            consumers: Vec::new(),
+            groups: Vec::new(),
+            external_groups: Vec::new(),
+            dedup: Vec::new(),
+            owners: Vec::new(),
+            history_transition: None,
+            owner_retirement: None,
+        };
+
+        let result = partition
+            .install_state_transfer(&repair_config(), 1, Vec::new(), 
&offsets.encode())
+            .await;
+
+        assert!(matches!(
+            result,
+            Err(crate::state_transfer::PartitionInstallError::SwapIo { .. })
+        ));
+        assert!(partition.fatal().is_some());
+        assert!(!directory.path().join(".install-backup").exists());
+    }
+
     #[compio::test]
     async fn 
pending_wal_prefix_keeps_pipeline_replies_and_does_not_partially_flush() {
         for durability in [
@@ -26964,6 +27063,40 @@ mod tests {
     #[cfg(target_os = "linux")]
     const DEV_FULL: &str = "/dev/full";
 
+    #[cfg(target_os = "linux")]
+    #[compio::test]
+    async fn 
given_an_unsyncable_messages_writer_when_installing_should_refuse_the_barrier() 
{
+        let directory = tempfile::tempdir().unwrap();
+        let wal = directory.path().join("wal");
+        let (persistence, _) = PartitionPersistence::open(&wal, 42, 
7).await.unwrap();
+        let messages_writer =
+            MessagesWriter::new("/dev/null", Rc::new(AtomicU64::new(0)), 
false, false, None)
+                .await
+                .unwrap();
+        let index_path = directory.path().join("segment.index");
+        let index_writer = IggyIndexWriter::new(
+            index_path.to_str().unwrap(),
+            Rc::new(AtomicU64::new(0)),
+            false,
+            false,
+        )
+        .await
+        .unwrap();
+        let mut partition = test_partition();
+        partition.log.add_persisted_segment(
+            Segment::new(0, IggyByteSize::from(1024 * 1024_u64)),
+            SegmentStorage::default(),
+            Some(Rc::new(messages_writer)),
+            Some(Rc::new(index_writer)),
+        );
+        partition.persistence = Some(Rc::clone(&persistence));
+
+        partition.barrier_install_files_locked();
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        assert!(persistence.failure().is_some());
+    }
+
     const FIRST_PAYLOAD: &[u8] = b"first-chunk";
     const SECOND_PAYLOAD: &[u8] = b"second-chunk-is-longer";
 
diff --git a/core/partitions/src/install_backup.rs 
b/core/partitions/src/install_backup.rs
index 537682529..4c669b8ce 100644
--- a/core/partitions/src/install_backup.rs
+++ b/core/partitions/src/install_backup.rs
@@ -15,12 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::CREATED_REVISION_FILE;
+use std::collections::BTreeSet;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use futures::TryStreamExt;
 use journal::durable_storage::{DiskStorage, DurableFile, DurableStorage, 
OpenMode};
 use journal::partition_journal::FRONTIER_FILE_NAME;
 use server_common::fatal::NoteDescriptorExhaustion;
-use std::io;
-use std::path::Path;
+
+use crate::{CREATED_REVISION_FILE, FILE_SYNC_CONCURRENCY};
 
 const BACKUP: &str = ".install-backup";
 const BUILDING: &str = ".install-building";
@@ -58,26 +62,33 @@ pub async fn recover_with_storage<S: DurableStorage>(
         }
         storage.remove_tree(&directory.join(&entry.name)).await?;
     }
-    link_tree(&backup, directory, false, storage).await?;
+    link_tree(&backup, directory, false, &BTreeSet::new(), storage).await?;
     finish_with_storage(directory, storage).await
 }
 
 /// Freeze the old materialization and WAL together before a destructive 
install.
+///
 /// The caller drains persistence and holds the partition write lock until 
finish.
+/// Each path in `synced_files` must still name the inode synced by its 
original
+/// writer, with no further writes to that inode before `begin` returns. These
+/// paths skip a fresh sync of the backup hard link.
 ///
 /// # Errors
 /// Returns an error if the rollback state cannot be made durable.
 /// After any failure the caller must stop serving until recovery.
-pub async fn begin(directory: &Path) -> io::Result<()> {
-    begin_with_storage(directory, &DiskStorage)
+pub async fn begin(directory: &Path, synced_files: &BTreeSet<PathBuf>) -> 
io::Result<()> {
+    begin_with_storage(directory, synced_files, &DiskStorage)
         .await
         .note_descriptor_exhaustion(|| format!("starting an install in {}", 
directory.display()))
 }
 
+/// Storage-generic install entry point with the same `synced_files` contract 
as [`begin`].
+///
 /// # Errors
 /// Returns an error when the install transaction cannot complete durably.
 pub async fn begin_with_storage<S: DurableStorage>(
     directory: &Path,
+    synced_files: &BTreeSet<PathBuf>,
     storage: &S,
 ) -> io::Result<()> {
     if storage.exists(&directory.join(BACKUP)).await? {
@@ -87,7 +98,7 @@ pub async fn begin_with_storage<S: DurableStorage>(
     storage.remove_tree(&building).await?;
     storage.remove_tree(&directory.join(RETIRED)).await?;
     storage.create_directories(&building).await?;
-    link_tree(directory, &building, true, storage).await?;
+    link_tree(directory, &building, true, synced_files, storage).await?;
     storage.rename(&building, &directory.join(BACKUP)).await?;
     storage.sync_directory(directory).await
 }
@@ -122,10 +133,12 @@ async fn link_tree<S: DurableStorage>(
     source: &Path,
     target: &Path,
     skip_scratch: bool,
+    synced_files: &BTreeSet<PathBuf>,
     storage: &S,
 ) -> io::Result<()> {
     let mut pending = vec![(source.to_path_buf(), target.to_path_buf())];
     let mut directories = Vec::new();
+    let mut files_to_sync = Vec::new();
     while let Some((source, target)) = pending.pop() {
         directories.push(target.clone());
         for entry in storage.entries(&source).await? {
@@ -150,21 +163,36 @@ async fn link_tree<S: DurableStorage>(
             } else {
                 // Transfer unlinks or atomically replaces these frozen files.
                 // Hard links retain the old bytes without copying segment 
data.
-                storage.hard_link(&source.join(&name), &destination).await?;
-                storage
-                    .open(&destination, OpenMode::Read)
-                    .await?
-                    .sync()
-                    .await?;
+                let source_file = source.join(&name);
+                storage.hard_link(&source_file, &destination).await?;
+                if !synced_files.contains(&source_file) {
+                    files_to_sync.push(destination);
+                    if files_to_sync.len() == FILE_SYNC_CONCURRENCY {
+                        sync_files(&mut files_to_sync, storage).await?;
+                    }
+                }
             }
         }
     }
+    sync_files(&mut files_to_sync, storage).await?;
     for directory in directories.into_iter().rev() {
         storage.sync_directory(&directory).await?;
     }
     Ok(())
 }
 
+async fn sync_files<S: DurableStorage>(files: &mut Vec<PathBuf>, storage: &S) 
-> io::Result<()> {
+    futures::stream::iter(std::mem::take(files).into_iter().map(Ok::<_, 
io::Error>))
+        .try_for_each_concurrent(FILE_SYNC_CONCURRENCY, |destination| async 
move {
+            storage
+                .open(&destination, OpenMode::Read)
+                .await?
+                .sync()
+                .await
+        })
+        .await
+}
+
 async fn copy_file<S: DurableStorage>(
     source: &Path,
     destination: &Path,
@@ -207,7 +235,7 @@ mod tests {
         ] {
             std::fs::write(root.join(name), name.as_bytes()).unwrap();
         }
-        begin(root).await.unwrap();
+        begin(root, &BTreeSet::new()).await.unwrap();
         // Model the install's unlink and atomic replacement operations.
         for name in ["0.log", "offsets/1", "prepares-1/frontier"] {
             std::fs::remove_file(root.join(name)).unwrap();
@@ -229,12 +257,36 @@ mod tests {
         recover(root).await.unwrap();
     }
 
+    #[compio::test]
+    async fn 
given_more_files_than_one_sync_batch_when_recovering_should_restore_them_all() {
+        let directory = tempfile::tempdir().unwrap();
+        let root = directory.path();
+        for index in 0..=FILE_SYNC_CONCURRENCY {
+            std::fs::write(root.join(format!("{index}.log")), b"old").unwrap();
+        }
+
+        begin(root, &BTreeSet::new()).await.unwrap();
+        for index in 0..=FILE_SYNC_CONCURRENCY {
+            let path = root.join(format!("{index}.log"));
+            std::fs::remove_file(&path).unwrap();
+            std::fs::write(path, b"new").unwrap();
+        }
+        recover(root).await.unwrap();
+
+        for index in 0..=FILE_SYNC_CONCURRENCY {
+            assert_eq!(
+                std::fs::read(root.join(format!("{index}.log"))).unwrap(),
+                b"old"
+            );
+        }
+    }
+
     #[compio::test]
     async fn completed_install_never_restores_the_old_materialization() {
         let directory = tempfile::tempdir().unwrap();
         let root = directory.path();
         std::fs::write(root.join("0.log"), b"old").unwrap();
-        begin(root).await.unwrap();
+        begin(root, &BTreeSet::new()).await.unwrap();
         std::fs::remove_file(root.join("0.log")).unwrap();
         std::fs::write(root.join("0.log"), b"new").unwrap();
         File::open(root.join("0.log"))
@@ -259,7 +311,7 @@ mod tests {
         crate::write_created_revision(partition_dir, CREATED_REVISION)
             .await
             .unwrap();
-        begin(root).await.unwrap();
+        begin(root, &BTreeSet::new()).await.unwrap();
         // The delete walk removes the backup's files in no fixed order, the
         // marker last only at the top level.
         for entry in std::fs::read_dir(root.join(BACKUP)).unwrap() {
diff --git a/core/partitions/src/io.rs b/core/partitions/src/io.rs
index e2fa4eaca..df26976bd 100644
--- a/core/partitions/src/io.rs
+++ b/core/partitions/src/io.rs
@@ -386,6 +386,7 @@ pub enum TransferFileJob {
     Backup {
         directory: String,
         begin: bool,
+        synced_files: std::collections::BTreeSet<std::path::PathBuf>,
     },
     Sweep {
         directory: String,
@@ -876,10 +877,14 @@ impl TransferFileJob {
                 crate::state_transfer::discard_offset_writes(&writes).await;
                 TransferFileResult::Finished(Ok(()))
             }
-            Self::Backup { directory, begin } => {
+            Self::Backup {
+                directory,
+                begin,
+                synced_files,
+            } => {
                 let path = std::path::Path::new(&directory);
                 let outcome = if begin {
-                    crate::install_backup::begin(path).await
+                    crate::install_backup::begin(path, &synced_files).await
                 } else {
                     crate::install_backup::finish(path).await
                 };
diff --git a/core/partitions/src/lib.rs b/core/partitions/src/lib.rs
index 6ca10b735..2b2bc00bc 100644
--- a/core/partitions/src/lib.rs
+++ b/core/partitions/src/lib.rs
@@ -33,8 +33,7 @@ pub mod offset_storage;
 mod partition_storage;
 mod persistence;
 mod poll_plan;
-#[cfg(feature = "simulator")]
-pub use persistence::CheckpointBarrier;
+pub use persistence::FileSyncBarrier;
 pub(crate) use persistence::PersistenceDrain;
 pub use persistence::{
     PartitionPersistence, PersistenceCompletion, PersistenceMetrics, 
PersistenceNotifier,
@@ -78,6 +77,9 @@ pub const DEFAULT_OFFSET_RESERVATION_LEASE: u32 = 64 * 1024;
 
 /// Shipped per-kind durable consumer-offset limit for one partition.
 pub const DEFAULT_CONSUMER_OFFSETS_MAX: usize = 4096;
+
+pub(crate) const FILE_SYNC_CONCURRENCY: usize = 16;
+
 pub use iggy_partition::{PollCompletion, PollReplication};
 pub use messages_writer::MessagesWriter;
 pub use offset_storage::delete_persisted_offset;
diff --git a/core/partitions/src/messages_writer.rs 
b/core/partitions/src/messages_writer.rs
index 7b1db97cc..126778451 100644
--- a/core/partitions/src/messages_writer.rs
+++ b/core/partitions/src/messages_writer.rs
@@ -164,10 +164,15 @@ impl MessagesWriter {
     ///
     /// Returns an error if the file cannot be synchronized.
     pub async fn fsync(&self) -> Result<(), IggyError> {
-        self.file
-            .sync_data()
-            .await
-            .map_err(|_| IggyError::CannotWriteToFile)?;
+        self.file.sync_data().await.map_err(|err| {
+            error!(
+                target: "iggy.partitions.storage",
+                file = self.file_path.as_str(),
+                %err,
+                "failed to sync segment file"
+            );
+            IggyError::CannotWriteToFile
+        })?;
         Ok(())
     }
 }
diff --git a/core/partitions/src/persistence.rs 
b/core/partitions/src/persistence.rs
index e11f17eb6..ce8079a65 100644
--- a/core/partitions/src/persistence.rs
+++ b/core/partitions/src/persistence.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::FILE_SYNC_CONCURRENCY;
 use crate::offset_storage::{OffsetFilePermit, RetainedOffsetFile, 
RetainedOffsetFiles};
 use futures::TryStreamExt;
 use iggy_binary_protocol::{Operation, PrepareHeader};
@@ -91,19 +92,19 @@ pub type PersistenceNotifier = Rc<dyn 
Fn(PersistenceCompletion)>;
 
 /// A file durability barrier that keeps the original writer alive until sync.
 #[must_use]
-pub struct CheckpointBarrier {
+pub struct FileSyncBarrier {
     path: PathBuf,
     sync: Pin<Box<dyn Future<Output = io::Result<()>> + 'static>>,
 }
 
-impl CheckpointBarrier {
-    /// Retain a simulator file's original writer until checkpoint 
synchronizes it.
+impl FileSyncBarrier {
+    /// Retains a simulator file's original writer until its sync runs.
     #[cfg(feature = "simulator")]
     pub fn from_file<F: DurableFile + 'static>(path: impl Into<PathBuf>, file: 
F) -> Self {
         Self::from_future(path, async move { file.sync().await })
     }
 
-    fn from_future(
+    pub(crate) fn from_future(
         path: impl Into<PathBuf>,
         sync: impl Future<Output = io::Result<()>> + 'static,
     ) -> Self {
@@ -118,7 +119,9 @@ impl CheckpointBarrier {
     }
 
     async fn run(self) -> io::Result<PathBuf> {
-        self.sync.await?;
+        self.sync.await.map_err(|error| {
+            io::Error::new(error.kind(), format!("{}: {error}", 
self.path.display()))
+        })?;
         Ok(self.path)
     }
 }
@@ -430,12 +433,17 @@ enum Mutation<S: DurableStorage> {
         epoch: u64,
         from_op: u64,
     },
+    Barrier {
+        epoch: u64,
+        barriers: Vec<FileSyncBarrier>,
+        offset_files: Vec<RetainedOffsetFile<S::File>>,
+    },
     Checkpoint {
         epoch: u64,
         through_op: u64,
         files: Vec<PathBuf>,
         directories: Vec<PathBuf>,
-        barriers: Vec<CheckpointBarrier>,
+        barriers: Vec<FileSyncBarrier>,
         offset_files: Vec<RetainedOffsetFile<S::File>>,
         synced_files: BTreeSet<PathBuf>,
     },
@@ -882,6 +890,8 @@ impl<S: DurableStorage> PartitionPersistence<S> {
     }
 
     pub fn checkpoint(&self, through_op: u64) {
+        // This shorthand has no materialized paths, so
+        // [`FileSyncBarrier`] has no original writer to retain.
         self.checkpoint_files(through_op, Vec::new(), Vec::new(), Vec::new());
     }
 
@@ -890,7 +900,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
         through_op: u64,
         files: Vec<PathBuf>,
         directories: Vec<PathBuf>,
-        barriers: Vec<CheckpointBarrier>,
+        barriers: Vec<FileSyncBarrier>,
     ) {
         if through_op <= self.checkpoint_requested.get() {
             return;
@@ -909,6 +919,20 @@ impl<S: DurableStorage> PartitionPersistence<S> {
         });
     }
 
+    /// Queue a durability barrier through retained original writers without
+    /// advancing [`PartitionPersistence::checkpoint_op`]. The returned paths
+    /// count as synced only after the queued barrier succeeds.
+    pub fn barrier_files(&self, barriers: Vec<FileSyncBarrier>) -> 
BTreeSet<PathBuf> {
+        let (offset_files, mut synced_files) = 
self.offset_files.take_checkpoint();
+        synced_files.extend(barriers.iter().map(|barrier| 
barrier.path.clone()));
+        self.queue.borrow_mut().push_back(Mutation::Barrier {
+            epoch: self.epoch.get(),
+            barriers,
+            offset_files,
+        });
+        synced_files
+    }
+
     pub const fn checkpoint_pending(&self) -> bool {
         self.checkpoint_running.get() || self.checkpoint_requested.get() > 
self.checkpoint.get()
     }
@@ -958,6 +982,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                         .set(self.checkpoint_requested.get().max(*through_op));
                     true
                 }
+                Mutation::Barrier { .. } => true,
                 _ => false,
             });
         self.accepted.borrow_mut().truncate_from(from_op);
@@ -1248,6 +1273,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                 | Mutation::EnableSegments { epoch, .. }
                 | Mutation::ReanchorSegments { epoch, .. }
                 | Mutation::Truncate { epoch, .. }
+                | Mutation::Barrier { epoch, .. }
                 | Mutation::Checkpoint { epoch, .. }
                 | Mutation::Reset { epoch, .. } => (*epoch, 0),
             };
@@ -1359,6 +1385,14 @@ impl<S: DurableStorage> PartitionPersistence<S> {
             }
             Mutation::Sync { .. } => journal.sync().await,
             Mutation::Truncate { from_op, .. } => 
journal.truncate_from(from_op).await,
+            Mutation::Barrier {
+                barriers,
+                offset_files,
+                ..
+            } => {
+                Self::sync_files(offset_files, barriers).await?;
+                journal.sync().await
+            }
             Mutation::Checkpoint {
                 through_op,
                 files,
@@ -1370,14 +1404,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
             } => {
                 self.checkpoint_running.set(true);
                 let result = async {
-                    futures::stream::iter(offset_files.iter().map(Ok::<_, 
io::Error>))
-                        .try_for_each_concurrent(16, |retained| 
retained.file.sync())
-                        .await?;
-                    let barrier_paths = futures::future::try_join_all(
-                        barriers.into_iter().map(CheckpointBarrier::run),
-                    )
-                    .await?;
-                    synced_files.extend(barrier_paths);
+                    synced_files.extend(Self::sync_files(offset_files, 
barriers).await?);
                     journal
                         .checkpoint_files(through_op, &files, &directories, 
&synced_files)
                         .await
@@ -1410,6 +1437,16 @@ impl<S: DurableStorage> PartitionPersistence<S> {
         }
     }
 
+    async fn sync_files(
+        offset_files: Vec<RetainedOffsetFile<S::File>>,
+        barriers: Vec<FileSyncBarrier>,
+    ) -> io::Result<Vec<PathBuf>> {
+        futures::stream::iter(offset_files.iter().map(Ok::<_, io::Error>))
+            .try_for_each_concurrent(FILE_SYNC_CONCURRENCY, |retained| 
retained.file.sync())
+            .await?;
+        
futures::future::try_join_all(barriers.into_iter().map(FileSyncBarrier::run)).await
+    }
+
     async fn append_batch(
         &self,
         journal: &mut PartitionPrepareJournal<S>,
diff --git a/core/partitions/src/state_transfer.rs 
b/core/partitions/src/state_transfer.rs
index 00fcb39d5..097afc15b 100644
--- a/core/partitions/src/state_transfer.rs
+++ b/core/partitions/src/state_transfer.rs
@@ -58,7 +58,7 @@ use server_common::iobuf::Owned;
 use server_common::send_messages::{decode_batch_slice, decode_prepare_slice};
 use server_common::sharding::IggyNamespace;
 use server_common::yield_to_reactor;
-use std::collections::HashSet;
+use std::collections::{BTreeSet, HashSet};
 use std::fmt;
 use std::future::Future;
 use std::mem::size_of;
@@ -2040,11 +2040,13 @@ pub enum PartitionInstallError {
         previous_end: u64,
         next_start: u64,
     },
-    /// Filesystem failure at/after the swap; disk holds a contiguous prefix
-    /// of the new state and a crash-restart recovers it (see the module
-    /// crash-window notes). The IN-MEMORY partition is converged to an
-    /// empty, honestly-lagging state before this returns, so the live
-    /// process stays serviceable and the normal triggers re-transfer.
+    /// A writer sync or backup preparation failure returns before the swap;
+    /// the old in-memory partition remains in place and may be fenced.
+    /// At/after the swap, disk holds a contiguous prefix of the new state
+    /// and a crash-restart recovers it (see the module crash-window notes).
+    /// On the storeless post-swap failure path, the IN-MEMORY partition is
+    /// converged to an empty, honestly-lagging state before this returns,
+    /// so the live process stays serviceable and the normal triggers 
re-transfer.
     SwapIo {
         path: String,
         source: std::io::Error,
@@ -2411,6 +2413,7 @@ pub(crate) struct PendingInstall {
     next_offset: u64,
     phase: InstallPhase,
     drain: Option<crate::PersistenceDrain>,
+    synced_files: BTreeSet<PathBuf>,
     directory_handle: Option<compio::fs::File>,
     offset_dirs_changed: [bool; ConsumerKind::COUNT],
     /// Published with the offsets, replacing the local receipt table.
@@ -3331,13 +3334,18 @@ where
             }
             InstallPhase::Drain => {
                 if let Some(persistence) = &self.persistence {
+                    if install.drain.is_none() {
+                        // Checkpoint syncs may be skipped when the frontier 
is unchanged.
+                        // The backup still needs proof from each original 
writer.
+                        install.synced_files = 
self.barrier_install_files_locked();
+                        install.disposition = InstallDisposition::Backup;
+                    }
                     self.start_persistence();
                     let drain = install
                         .drain
                         .get_or_insert_with(|| persistence.begin_drain());
                     match persistence.observe_drain(drain) {
                         Ok(true) => {
-                            install.disposition = InstallDisposition::Backup;
                             install.phase = InstallPhase::Backup;
                         }
                         Ok(false) => step = crate::PartitionIoStep::Pending,
@@ -3615,6 +3623,7 @@ where
                 file_job(crate::io::TransferFileJob::Backup {
                     directory: install.partition_dir.clone(),
                     begin: true,
+                    synced_files: install.synced_files.clone(),
                 }),
                 InstallFilePhase::Backup,
             ),
@@ -3762,6 +3771,7 @@ where
                 file_job(crate::io::TransferFileJob::Backup {
                     directory: install.partition_dir.clone(),
                     begin: false,
+                    synced_files: BTreeSet::new(),
                 }),
                 InstallFilePhase::FinishBackup,
             ),
@@ -4320,6 +4330,7 @@ where
             next_offset,
             phase: InstallPhase::StageOffsets(0),
             drain: None,
+            synced_files: BTreeSet::new(),
             directory_handle: None,
             offset_dirs_changed: [false; ConsumerKind::COUNT],
             retry_table: Some(retry_table),
diff --git a/core/simulator/src/storage/tests.rs 
b/core/simulator/src/storage/tests.rs
index 625866083..958a0e950 100644
--- a/core/simulator/src/storage/tests.rs
+++ b/core/simulator/src/storage/tests.rs
@@ -27,7 +27,7 @@ use journal::partition_journal::{
     PARTITION_WAL_BLOCK_SIZE, SegmentPosition, SegmentReference, record_length,
 };
 use journal::{DurableAppend, PartitionPrepareJournal};
-use partitions::{CheckpointBarrier, PartitionPersistence, PersistenceMetrics, 
install_backup};
+use partitions::{FileSyncBarrier, PartitionPersistence, PersistenceMetrics, 
install_backup};
 use server_common::send_messages::{
     BATCH_MESSAGE_HEADER_SIZE, IggyMessage, IggyMessageHeader, IggyMessages, 
SendMessagesOwned,
 };
@@ -784,7 +784,7 @@ fn synchronized_corruption_in_any_record_block_is_refused() 
{
 
 async fn interrupted_install() -> SimStorage {
     let (storage, mut journal) = baseline().await;
-    install_backup::begin_with_storage(Path::new(DIRECTORY), &storage)
+    install_backup::begin_with_storage(Path::new(DIRECTORY), &BTreeSet::new(), 
&storage)
         .await
         .unwrap();
     replace(&storage, Path::new("/partition/state"), b"new")
@@ -796,36 +796,215 @@ async fn interrupted_install() -> SimStorage {
     storage
 }
 
+#[test]
+fn 
given_synced_install_file_when_power_loss_interrupts_install_should_restore_backup()
 {
+    block_on(async {
+        let storage = storage_for_partition().await;
+        let path = Path::new("/partition/materialized");
+        let mut writer = storage.open(path, OpenMode::Create).await.unwrap();
+        writer.write(0, b"old".to_vec()).await.unwrap();
+        writer.sync().await.unwrap();
+        storage.sync_directory(Path::new(DIRECTORY)).await.unwrap();
+
+        let synced_files = BTreeSet::from([path.to_path_buf()]);
+        storage.clear_trace();
+        install_backup::begin_with_storage(Path::new(DIRECTORY), 
&synced_files, &storage)
+            .await
+            .unwrap();
+        assert!(
+            !storage.trace().contains(&StorageOperation::FileSync),
+            "the backup synced a hard link whose original writer had passed 
its barrier"
+        );
+        replace(&storage, path, b"new").await.unwrap();
+
+        storage.crash(Crash::PowerLoss);
+        install_backup::recover_with_storage(Path::new(DIRECTORY), &storage)
+            .await
+            .unwrap();
+        let file = storage.open(path, OpenMode::Read).await.unwrap();
+        assert_eq!(file.read(0, 3).await.unwrap(), b"old");
+    });
+}
+
 /// A hard link preserves the inode, not the writer's error cursor. Opening the
-/// backup name after writeback failed must not authorize destructive install.
+/// backup name after writeback failed must not authorize destructive install;
+/// [`FileSyncBarrier::from_file`] must retain the original error cursor.
 #[test]
-#[ignore = "`install_backup::link_tree` synchronizes hard links through 
handles opened after the writeback failure"]
 fn 
given_a_failed_writeback_when_beginning_an_install_backup_should_refuse_publication()
 {
     block_on(async {
         let storage = storage_for_partition().await;
+        let (persistence, _) =
+            PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, 
storage.clone())
+                .await
+                .unwrap();
         let path = Path::new("/partition/materialized");
         let mut writer = storage.open(path, OpenMode::Create).await.unwrap();
         writer.write(0, b"pending".to_vec()).await.unwrap();
         storage.sync_directory(Path::new(DIRECTORY)).await.unwrap();
 
         storage.fail_writeback(path).unwrap();
-        let result = install_backup::begin_with_storage(Path::new(DIRECTORY), 
&storage).await;
+        let barrier = FileSyncBarrier::from_file(path, writer);
+        let synced_files = persistence.barrier_files(vec![barrier]);
+        assert!(synced_files.contains(path));
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        let result = async {
+            persistence.drain().await?;
+            install_backup::begin_with_storage(Path::new(DIRECTORY), 
&synced_files, &storage).await
+        }
+        .await;
 
+        let error = result.unwrap_err();
         assert!(
-            writer.sync().await.is_err(),
-            "the original writer did not observe the injected writeback 
failure"
+            error
+                .to_string()
+                .contains("/partition/materialized: writeback failed before 
this handle synced"),
+            "unexpected barrier failure: {error}"
+        );
+        assert!(
+            !storage
+                .exists(Path::new("/partition/.install-backup"))
+                .await
+                .unwrap(),
+            "a failed backup was published"
         );
+    });
+}
+
+#[test]
+fn 
given_a_failed_offset_writeback_when_barriering_install_files_should_refuse_publication()
 {
+    block_on(async {
+        let storage = storage_for_partition().await;
+        let (persistence, _) =
+            PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, 
storage.clone())
+                .await
+                .unwrap();
+        let path = Path::new("/partition/offsets/consumers/1");
+        storage
+            .create_directories(path.parent().unwrap())
+            .await
+            .unwrap();
+        let mut writer = storage.open(path, OpenMode::Create).await.unwrap();
+        writer.write(0, b"pending".to_vec()).await.unwrap();
+        storage
+            .sync_directory(path.parent().unwrap())
+            .await
+            .unwrap();
+        persistence
+            .retain_offset_file(path.to_str().unwrap().to_owned(), writer)
+            .await
+            .unwrap();
+
+        storage.fail_writeback(path).unwrap();
+        let synced_files = persistence.barrier_files(Vec::new());
+        assert!(synced_files.contains(path));
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        let result = async {
+            persistence.drain().await?;
+            install_backup::begin_with_storage(Path::new(DIRECTORY), 
&synced_files, &storage).await
+        }
+        .await;
+
         assert!(
             result.is_err(),
-            "install backup published after synchronizing a fresh hard-link 
handle past the writeback error"
+            "install backup published after offset writeback failed"
         );
+        assert!(persistence.failure().is_some());
         assert!(
             !storage
                 .exists(Path::new("/partition/.install-backup"))
                 .await
-                .unwrap(),
-            "a failed backup was published"
+                .unwrap()
+        );
+    });
+}
+
+#[test]
+fn 
given_a_truncated_install_barrier_when_checkpointing_should_report_retained_offset_writeback_failure()
+ {
+    block_on(async {
+        let storage = storage_for_partition().await;
+        let (persistence, _) =
+            PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, 
storage.clone())
+                .await
+                .unwrap();
+        let first = prepare(1, 0);
+        let second = prepare(2, first.header().checksum);
+        persistence.append(first.into_frozen(), true).unwrap();
+        persistence.append(second.into_frozen(), true).unwrap();
+
+        let path = Path::new("/partition/offsets/consumers/1");
+        storage
+            .create_directories(path.parent().unwrap())
+            .await
+            .unwrap();
+        let mut writer = storage.open(path, OpenMode::Create).await.unwrap();
+        writer.write(0, b"pending".to_vec()).await.unwrap();
+        storage
+            .sync_directory(path.parent().unwrap())
+            .await
+            .unwrap();
+        persistence
+            .retain_offset_file(path.to_str().unwrap().to_owned(), writer)
+            .await
+            .unwrap();
+
+        storage.fail_writeback(path).unwrap();
+        persistence.barrier_files(Vec::new());
+        persistence.truncate_from(2);
+        // Any later mutation must wait for the old barrier; a checkpoint also
+        // makes an accidental durability advance observable.
+        persistence.checkpoint(1);
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        let error = persistence.drain().await.unwrap_err();
+        assert!(
+            error
+                .to_string()
+                .contains("writeback failed before this handle synced"),
+            "unexpected barrier failure: {error}"
         );
+        assert!(persistence.failure().is_some());
+        assert_eq!(persistence.checkpoint_op(), 0);
+    });
+}
+
+#[test]
+fn 
given_a_truncated_install_barrier_when_rebarriering_should_make_retained_offset_durable()
 {
+    block_on(async {
+        let storage = storage_for_partition().await;
+        let (persistence, _) =
+            PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, 
storage.clone())
+                .await
+                .unwrap();
+        let first = prepare(1, 0);
+        let second = prepare(2, first.header().checksum);
+        persistence.append(first.into_frozen(), true).unwrap();
+        persistence.append(second.into_frozen(), true).unwrap();
+
+        let path = Path::new("/partition/offset");
+        let mut writer = storage.open(path, OpenMode::Create).await.unwrap();
+        writer.write(0, b"retained".to_vec()).await.unwrap();
+        storage.sync_directory(Path::new(DIRECTORY)).await.unwrap();
+        persistence
+            .retain_offset_file(path.to_str().unwrap().to_owned(), writer)
+            .await
+            .unwrap();
+
+        persistence.barrier_files(Vec::new());
+        persistence.truncate_from(2);
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        persistence.drain().await.unwrap();
+
+        persistence.barrier_files(Vec::new());
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        persistence.drain().await.unwrap();
+        storage.crash(Crash::PowerLoss);
+        let file = storage.open(path, OpenMode::Read).await.unwrap();
+        assert_eq!(file.read(0, 8).await.unwrap(), b"retained");
     });
 }
 
@@ -1179,7 +1358,7 @@ fn checkpoint_barriers_complete_before_wal_reclamation() {
             .unwrap();
         let mut file = storage.open(path, OpenMode::Create).await.unwrap();
         file.write(0, b"committed".to_vec()).await.unwrap();
-        let barrier = CheckpointBarrier::from_file(path, file);
+        let barrier = FileSyncBarrier::from_file(path, file);
         persistence.checkpoint_files(
             4,
             vec![path.to_path_buf()],
@@ -2523,7 +2702,7 @@ async fn install(
     storage: &SimStorage,
     journal: &mut PartitionPrepareJournal<SimStorage>,
 ) -> io::Result<()> {
-    install_backup::begin_with_storage(Path::new(DIRECTORY), storage).await?;
+    install_backup::begin_with_storage(Path::new(DIRECTORY), &BTreeSet::new(), 
storage).await?;
     replace(storage, Path::new("/partition/state"), b"new").await?;
     for path in MATERIALIZED_FILES {
         replace(storage, Path::new(path), b"new").await?;
@@ -2778,7 +2957,7 @@ fn 
given_a_failed_writeback_when_checkpointing_then_wal_history_should_not_be_re
         let path = Path::new("/partition/materialized");
         let mut writer = storage.open(path, OpenMode::Create).await.unwrap();
         writer.write(0, b"committed".to_vec()).await.unwrap();
-        let barrier = CheckpointBarrier::from_file(path, writer);
+        let barrier = FileSyncBarrier::from_file(path, writer);
 
         // The device drops the dirty pages before the checkpoint's barrier. 
The
         // writer that issued them is the only handle told; the descriptor
@@ -2831,8 +3010,8 @@ fn 
given_multiple_writers_for_one_checkpoint_file_when_one_has_not_observed_the_
         // checkpoint skip another writer that still has the error pending.
         assert!(first_writer.sync().await.is_err());
         let barriers = vec![
-            CheckpointBarrier::from_file(path, first_writer),
-            CheckpointBarrier::from_file(path, second_writer),
+            FileSyncBarrier::from_file(path, first_writer),
+            FileSyncBarrier::from_file(path, second_writer),
         ];
         persistence.checkpoint_files(
             4,

Reply via email to