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 2cacea5b1 fix(partitions): sync checkpoints through original writers 
(#4253)
2cacea5b1 is described below

commit 2cacea5b1dd6d7e9a9b72c9a120f648af2553be3
Author: Gunther Xing <[email protected]>
AuthorDate: Tue Sep 22 17:14:49 2026 +0800

    fix(partitions): sync checkpoints through original writers (#4253)
---
 core/partitions/Cargo.toml               |  9 ++--
 core/partitions/src/iggy_index_writer.rs |  4 ++
 core/partitions/src/iggy_partition.rs    | 28 +++++-----
 core/partitions/src/lib.rs               |  2 +
 core/partitions/src/persistence.rs       | 49 ++++++++++++++++-
 core/simulator/src/storage/tests.rs      | 93 ++++++++++++++++++++++++++++++--
 6 files changed, 162 insertions(+), 23 deletions(-)

diff --git a/core/partitions/Cargo.toml b/core/partitions/Cargo.toml
index 8b1efd778..d14692c08 100644
--- a/core/partitions/Cargo.toml
+++ b/core/partitions/Cargo.toml
@@ -36,11 +36,10 @@ publish = false
 # on the read path.
 poll-diagnostics = []
 
-# Simulator-only detector hook (`IggyPartitions::hold_borrow_across_await`):
-# deliberately holds a `with_partition` borrow across an `.await` so the
-# dispatch shell can prove its borrow-across-await detector. A
-# `-p iggy-server` build excludes it; `cargo build --workspace` unifies
-# features so the shared `partitions` unit compiles it in when the simulator
+# Simulator-only hooks: the dispatch shell's borrow-across-await detector and
+# original-writer checkpoint 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.
 simulator = []
 
diff --git a/core/partitions/src/iggy_index_writer.rs 
b/core/partitions/src/iggy_index_writer.rs
index ddd6f294d..c91cea223 100644
--- a/core/partitions/src/iggy_index_writer.rs
+++ b/core/partitions/src/iggy_index_writer.rs
@@ -144,6 +144,10 @@ impl IggyIndexWriter {
         self.index_size_bytes.fetch_add(bytes, Ordering::Release);
     }
 
+    pub(crate) fn path(&self) -> &str {
+        &self.file_path
+    }
+
     /// Flushes buffered index file contents to disk.
     ///
     /// Uses `fdatasync` (data only): index files are append-only and the
diff --git a/core/partitions/src/iggy_partition.rs 
b/core/partitions/src/iggy_partition.rs
index d3ae0e6c7..2c9d77d80 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -28,7 +28,9 @@ use crate::offset_storage::{
     persist_offset, persist_offset_max, persist_purge_generation_with_storage,
     read_purge_generation,
 };
-use crate::persistence::{PartitionPersistence, PersistenceCompletion, 
PersistenceNotifier};
+use crate::persistence::{
+    CheckpointBarrier, PartitionPersistence, PersistenceCompletion, 
PersistenceNotifier,
+};
 use crate::poll_plan::{
     DiskReadPlan, DiskSegment, PartitionDirResolution, PollContext, PollPlan, 
PollReadResult,
     PollTier, ResidentTailSnapshot,
@@ -1033,19 +1035,21 @@ where
                 return;
             }
         }
-        if let Some(writer) = 
self.log.index_writers().last().and_then(Option::as_ref)
-            && let Err(error) = writer.fsync().await
-        {
-            error!(%error, namespace_raw = self.namespace().inner(), 
"partition checkpoint index sync failed");
-            self.fatal = Some(FatalCommit {
-                namespace_raw: self.namespace().inner(),
-                op: through_op,
-                operation: Operation::SendMessages,
-            });
-            return;
+        let mut barriers = Vec::new();
+        if let Some(writer) = 
self.log.index_writers().last().and_then(Option::as_ref) {
+            if let Err(error) = writer.fsync().await {
+                error!(%error, namespace_raw = self.namespace().inner(), 
"partition checkpoint index sync failed");
+                self.fatal = Some(FatalCommit {
+                    namespace_raw: self.namespace().inner(),
+                    op: through_op,
+                    operation: Operation::SendMessages,
+                });
+                return;
+            }
+            barriers.push(CheckpointBarrier::already_synced(writer.path()));
         }
         let (files, directories) = self.persistence_checkpoint_files(config);
-        persistence.checkpoint_files(through_op, files, directories);
+        persistence.checkpoint_files(through_op, files, directories, barriers);
         self.start_persistence();
     }
 
diff --git a/core/partitions/src/lib.rs b/core/partitions/src/lib.rs
index 200892f8b..7556ea67e 100644
--- a/core/partitions/src/lib.rs
+++ b/core/partitions/src/lib.rs
@@ -30,6 +30,8 @@ mod messages_writer;
 pub mod offset_storage;
 mod persistence;
 mod poll_plan;
+#[cfg(feature = "simulator")]
+pub use persistence::CheckpointBarrier;
 pub use persistence::{
     PartitionPersistence, PersistenceCompletion, PersistenceMetrics, 
PersistenceNotifier,
 };
diff --git a/core/partitions/src/persistence.rs 
b/core/partitions/src/persistence.rs
index 19d77d564..d543fe981 100644
--- a/core/partitions/src/persistence.rs
+++ b/core/partitions/src/persistence.rs
@@ -25,8 +25,10 @@ use server_common::iobuf::Frozen;
 use smallvec::SmallVec;
 use std::cell::{Cell, RefCell};
 use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
+use std::future::Future;
 use std::io;
 use std::path::{Path, PathBuf};
+use std::pin::Pin;
 use std::rc::Rc;
 use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
 use std::sync::{Arc, LazyLock, Mutex, Weak};
@@ -127,6 +129,40 @@ pub struct PersistenceMetrics {
 
 pub type PersistenceNotifier = Rc<dyn Fn(PersistenceCompletion)>;
 
+/// A file durability barrier that keeps the original writer alive until sync.
+#[must_use]
+pub struct CheckpointBarrier {
+    path: PathBuf,
+    sync: Pin<Box<dyn Future<Output = io::Result<()>> + 'static>>,
+}
+
+impl CheckpointBarrier {
+    /// Retain a simulator file's original writer until checkpoint 
synchronizes it.
+    #[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(
+        path: impl Into<PathBuf>,
+        sync: impl Future<Output = io::Result<()>> + 'static,
+    ) -> Self {
+        Self {
+            path: path.into(),
+            sync: Box::pin(sync),
+        }
+    }
+
+    pub(crate) fn already_synced(path: impl Into<PathBuf>) -> Self {
+        Self::from_future(path, async { Ok(()) })
+    }
+
+    async fn run(self) -> io::Result<PathBuf> {
+        self.sync.await?;
+        Ok(self.path)
+    }
+}
+
 pub struct PartitionPersistence<S: DurableStorage = DiskStorage> {
     group: u64,
     instance: u64,
@@ -429,6 +465,7 @@ enum Mutation<S: DurableStorage> {
         through_op: u64,
         files: Vec<PathBuf>,
         directories: Vec<PathBuf>,
+        barriers: Vec<CheckpointBarrier>,
         offset_files: Vec<RetainedOffsetFile<S::File>>,
         synced_files: BTreeSet<PathBuf>,
     },
@@ -832,7 +869,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
     }
 
     pub fn checkpoint(&self, through_op: u64) {
-        self.checkpoint_files(through_op, Vec::new(), Vec::new());
+        self.checkpoint_files(through_op, Vec::new(), Vec::new(), Vec::new());
     }
 
     pub fn checkpoint_files(
@@ -840,6 +877,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
         through_op: u64,
         files: Vec<PathBuf>,
         directories: Vec<PathBuf>,
+        barriers: Vec<CheckpointBarrier>,
     ) {
         if through_op <= self.checkpoint_requested.get() {
             return;
@@ -859,6 +897,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
             through_op,
             files,
             directories,
+            barriers,
             offset_files,
             synced_files,
         });
@@ -1264,8 +1303,9 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                 through_op,
                 files,
                 directories,
+                barriers,
                 offset_files,
-                synced_files,
+                mut synced_files,
                 ..
             } => {
                 self.checkpoint_running.set(true);
@@ -1273,6 +1313,11 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                     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);
                     journal
                         .checkpoint_files(through_op, &files, &directories, 
&synced_files)
                         .await
diff --git a/core/simulator/src/storage/tests.rs 
b/core/simulator/src/storage/tests.rs
index 13947f668..636486619 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::{PartitionPersistence, PersistenceMetrics, install_backup};
+use partitions::{CheckpointBarrier, PartitionPersistence, PersistenceMetrics, 
install_backup};
 use server_common::send_messages::{
     BATCH_MESSAGE_HEADER_SIZE, IggyMessage, IggyMessageHeader, IggyMessages, 
SendMessagesOwned,
 };
@@ -652,6 +652,7 @@ fn 
replacing_a_retained_offset_writer_keeps_both_inodes_until_checkpoint() {
             1,
             vec![path.to_path_buf()],
             vec![Path::new(DIRECTORY).to_path_buf()],
+            Vec::new(),
         );
         assert!(persistence.start());
         Rc::clone(&persistence).run().await;
@@ -731,6 +732,7 @@ fn 
checkpoint_skips_duplicate_offset_sync_but_still_refuses_a_missing_path() {
                 1,
                 vec![path.to_path_buf()],
                 vec![Path::new(DIRECTORY).to_path_buf()],
+                Vec::new(),
             );
             storage.clear_trace();
             assert!(persistence.start());
@@ -797,6 +799,39 @@ async fn interrupted_install() -> SimStorage {
     storage
 }
 
+/// A hard link preserves the inode, not the writer's error cursor. Opening the
+/// backup name after writeback failed must not authorize destructive install.
+#[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 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;
+
+        assert!(
+            writer.sync().await.is_err(),
+            "the original writer did not observe the injected writeback 
failure"
+        );
+        assert!(
+            result.is_err(),
+            "install backup published after synchronizing a fresh hard-link 
handle past the writeback error"
+        );
+        assert!(
+            !storage
+                .exists(Path::new("/partition/.install-backup"))
+                .await
+                .unwrap(),
+            "a failed backup was published"
+        );
+    });
+}
+
 #[test]
 fn lost_frontier_cannot_turn_a_durable_journal_into_an_empty_one() {
     block_on(async {
@@ -1114,7 +1149,12 @@ fn 
checkpoint_syncs_the_retained_writer_before_reclaiming_its_history() {
         );
         storage.remove_file(path).await.unwrap();
         persistence.retire_offset_file(path.to_str().unwrap());
-        persistence.checkpoint_files(4, Vec::new(), 
vec![Path::new(DIRECTORY).to_path_buf()]);
+        persistence.checkpoint_files(
+            4,
+            Vec::new(),
+            vec![Path::new(DIRECTORY).to_path_buf()],
+            Vec::new(),
+        );
         storage.fail_at(0, FaultMode::Before);
         assert!(persistence.start());
         Rc::clone(&persistence).run().await;
@@ -1142,10 +1182,12 @@ 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);
         persistence.checkpoint_files(
             4,
             vec![path.to_path_buf()],
             vec![Path::new(DIRECTORY).to_path_buf()],
+            vec![barrier],
         );
         assert!(persistence.checkpoint_pending());
         assert!(!persistence.needs_checkpoint());
@@ -1185,7 +1227,7 @@ fn 
failed_materialization_keeps_wal_coverage_and_fences_completion() {
             } else {
                 (missing, Vec::new())
             };
-            persistence.checkpoint_files(4, files, directories);
+            persistence.checkpoint_files(4, files, directories, Vec::new());
             assert!(persistence.start());
             Rc::clone(&persistence).run().await;
             assert_eq!(
@@ -2758,7 +2800,6 @@ fn 
given_a_silent_short_write_when_recovering_then_the_record_should_be_refused(
 }
 
 #[test]
-#[ignore = "PR #4092 review: `checkpoint_files` syncs materialized files 
through a descriptor opened after the writeback failure, which samples errseq 
too late and reports success over lost bytes; it must sync through the writer 
that issued them"]
 fn 
given_a_failed_writeback_when_checkpointing_then_wal_history_should_not_be_reclaimed()
 {
     block_on(async {
         let (storage, persistence) = queued_batch(4).await;
@@ -2767,6 +2808,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);
 
         // The device drops the dirty pages before the checkpoint's barrier. 
The
         // writer that issued them is the only handle told; the descriptor
@@ -2777,6 +2819,7 @@ fn 
given_a_failed_writeback_when_checkpointing_then_wal_history_should_not_be_re
             4,
             vec![path.to_path_buf()],
             vec![Path::new(DIRECTORY).to_path_buf()],
+            vec![barrier],
         );
         assert!(persistence.start());
         Rc::clone(&persistence).run().await;
@@ -2800,6 +2843,48 @@ fn 
given_a_failed_writeback_when_checkpointing_then_wal_history_should_not_be_re
     });
 }
 
+#[test]
+fn 
given_multiple_writers_for_one_checkpoint_file_when_one_has_not_observed_the_writeback_failure_then_wal_history_should_not_be_reclaimed()
+ {
+    block_on(async {
+        let (storage, persistence) = queued_batch(4).await;
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+        let path = Path::new("/partition/materialized");
+        let mut first_writer = storage.open(path, 
OpenMode::Create).await.unwrap();
+        first_writer.write(0, b"first".to_vec()).await.unwrap();
+        let mut second_writer = storage.open(path, 
OpenMode::ReadWrite).await.unwrap();
+        second_writer.write(5, b"second".to_vec()).await.unwrap();
+
+        storage.fail_writeback(path).unwrap();
+        // Consuming the inode error through one file description must not let
+        // 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),
+        ];
+        persistence.checkpoint_files(
+            4,
+            vec![path.to_path_buf()],
+            vec![Path::new(DIRECTORY).to_path_buf()],
+            barriers,
+        );
+        assert!(persistence.start());
+        Rc::clone(&persistence).run().await;
+
+        assert!(persistence.failure().is_some());
+        assert_eq!(persistence.checkpoint_op(), 0);
+        storage.crash(Crash::PowerLoss);
+        let recovered =
+            PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7, 
storage.clone())
+                .await
+                .unwrap();
+        assert_eq!(recovered.checkpoint_op(), 0);
+        assert_eq!(recovered.head(), 4);
+    });
+}
+
 #[test]
 fn 
given_sim_storage_when_opening_persistence_then_the_writer_lease_should_be_taken()
 {
     block_on(async {

Reply via email to