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 13a0d9165 fix(partitions): account for segment-backed append batches 
(#4202)
13a0d9165 is described below

commit 13a0d916527387d816e6222ac23d71d87802c500
Author: Gunther Xing <[email protected]>
AuthorDate: Thu Sep 17 19:04:56 2026 +0800

    fix(partitions): account for segment-backed append batches (#4202)
---
 core/journal/src/partition_journal.rs |  24 +++++++
 core/partitions/src/persistence.rs    | 128 +++++++++++++++++++++++++---------
 core/simulator/src/storage/tests.rs   |  67 ++++++++++--------
 3 files changed, 157 insertions(+), 62 deletions(-)

diff --git a/core/journal/src/partition_journal.rs 
b/core/journal/src/partition_journal.rs
index 5f783df54..55502834f 100644
--- a/core/journal/src/partition_journal.rs
+++ b/core/journal/src/partition_journal.rs
@@ -474,6 +474,30 @@ impl<S: DurableStorage> PartitionPrepareJournal<S> {
         self.retained_bytes
     }
 
+    /// Physical write lengths an append occupies in the current storage mode.
+    ///
+    /// The first return value is the padded number of bytes written to the 
WAL.
+    /// The second return value is the unpadded message-body bytes written to
+    /// segment storage, or zero when the prepare remains inline in the WAL.
+    ///
+    /// # Errors
+    /// Returns an error when `prepare_length` falls outside the supported 
bounds.
+    pub fn append_lengths(
+        &self,
+        operation: Operation,
+        prepare_length: usize,
+    ) -> io::Result<(usize, usize)> {
+        let inline_length = record_length(prepare_length)?;
+        if self.state.segment_storage.is_some() && operation == 
Operation::SendMessages {
+            Ok((
+                record_length(REFERENCED_PREPARE_BYTES)?,
+                prepare_length - size_of::<PrepareHeader>(),
+            ))
+        } else {
+            Ok((inline_length, 0))
+        }
+    }
+
     #[must_use]
     pub fn contains(&self, header: &PrepareHeader) -> bool {
         if header.op > self.durable_head {
diff --git a/core/partitions/src/persistence.rs 
b/core/partitions/src/persistence.rs
index 69c78f2bd..19d77d564 100644
--- a/core/partitions/src/persistence.rs
+++ b/core/partitions/src/persistence.rs
@@ -39,10 +39,12 @@ use nix::sys::resource::{Resource, getrlimit};
 // already queued and waiting, so widening the group moves work off the barrier
 // and onto a buffered memcpy: one body write and one durability barrier serve
 // the whole group instead of each prepare paying its own. The byte budget is
-// charged against the padded BODY size even when the WAL stores a segment
-// reference and writes 4096 bytes per record, so a tight budget caps grouping
-// far below what the write itself costs.
-const APPEND_BATCH_BYTES_MAX: u64 = 8 * 1024 * 1024;
+// charged against the padded WAL extent, which is one reference record for a
+// message body retained in segment storage.
+const APPEND_BATCH_WAL_BYTES_MAX: u64 = 8 * 1024 * 1024;
+// Bound segment body work independently of the WAL extent. References make the
+// WAL cheap, but do not make copying their bodies into segment storage cheap.
+const APPEND_BATCH_SEGMENT_BYTES_MAX: u64 = 8 * 1024 * 1024;
 const APPEND_BATCH_OPS_MAX: usize = 256;
 const CHECKPOINT_DIRTY_FILES_MAX: usize = 1024;
 /// Mutations a partition may apply before its obsolete files are reclaimed
@@ -416,7 +418,7 @@ enum Mutation<S: DurableStorage> {
         epoch: u64,
         prepare: Frozen<4096>,
         durable: bool,
-        bytes: u64,
+        retained_bytes: u64,
     },
     Truncate {
         epoch: u64,
@@ -439,6 +441,15 @@ enum Mutation<S: DurableStorage> {
     },
 }
 
+struct AppendBatchBytes {
+    /// Logical capacity in bytes retained until checkpoint, charged as padded 
inline records.
+    retained_capacity: u64,
+    /// Padded WAL extent in bytes encoded in the current storage mode.
+    wal_extent: u64,
+    /// Unpadded message-body bytes copied into segment storage.
+    segment_body: u64,
+}
+
 impl PartitionPersistence {
     /// # Errors
     /// Returns an error if the partition WAL cannot be recovered.
@@ -714,7 +725,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
     /// Returns an error if persistence fails, capacity is exhausted, or 
history is invalid.
     pub fn append(&self, prepare: Frozen<4096>, durable: bool) -> 
io::Result<()> {
         let header = prepare_header(&prepare)?;
-        let bytes = journal::partition_journal::record_length(prepare.len())? 
as u64;
+        let retained_bytes = 
journal::partition_journal::record_length(prepare.len())? as u64;
         if self.accepted.borrow().checksum(header.op) == Some(header.checksum) 
{
             return Ok(());
         }
@@ -738,7 +749,8 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                     now.saturating_duration_since(previous)
                 }),
         );
-        self.queued_bytes.set(self.queued_bytes.get() + bytes);
+        self.queued_bytes
+            .set(self.queued_bytes.get() + retained_bytes);
         self.accepted
             .borrow_mut()
             .checksums
@@ -748,7 +760,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
             epoch: self.epoch.get(),
             prepare,
             durable,
-            bytes,
+            retained_bytes,
         });
         Ok(())
     }
@@ -876,7 +888,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                 Mutation::Append {
                     epoch: previous,
                     prepare,
-                    bytes,
+                    retained_bytes,
                     ..
                 } => {
                     if prepare_header(prepare).is_ok_and(|header| header.op < 
from_op) {
@@ -884,7 +896,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                         true
                     } else {
                         self.queued_bytes
-                            
.set(self.queued_bytes.get().saturating_sub(*bytes));
+                            
.set(self.queued_bytes.get().saturating_sub(*retained_bytes));
                         false
                     }
                 }
@@ -1126,8 +1138,12 @@ impl<S: DurableStorage> PartitionPersistence<S> {
             let Some(mutation) = self.queue.borrow_mut().pop_front() else {
                 break;
             };
-            let (epoch, bytes) = match &mutation {
-                Mutation::Append { epoch, bytes, .. } => (*epoch, *bytes),
+            let (epoch, retained_bytes) = match &mutation {
+                Mutation::Append {
+                    epoch,
+                    retained_bytes,
+                    ..
+                } => (*epoch, *retained_bytes),
                 Mutation::CertifyView { epoch, .. }
                 | Mutation::EnableSegments { epoch, .. }
                 | Mutation::Purge { epoch, .. }
@@ -1136,8 +1152,8 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                 | Mutation::Reset { epoch, .. } => (*epoch, 0),
             };
             self.queued_bytes
-                .set(self.queued_bytes.get().saturating_sub(bytes));
-            self.in_flight_bytes.set(bytes);
+                .set(self.queued_bytes.get().saturating_sub(retained_bytes));
+            self.in_flight_bytes.set(retained_bytes);
             let rebuild_references = matches!(
                 mutation,
                 Mutation::EnableSegments { .. }
@@ -1146,7 +1162,9 @@ impl<S: DurableStorage> PartitionPersistence<S> {
                     | Mutation::Checkpoint { .. }
                     | Mutation::Reset { .. }
             );
-            let result = self.apply_mutation(journal, mutation, epoch, 
bytes).await;
+            let result = self
+                .apply_mutation(journal, mutation, epoch, retained_bytes)
+                .await;
             self.in_flight_bytes.set(0);
             if let Err(error) = result {
                 self.failed_writes.set(self.failed_writes.get() + 1);
@@ -1223,7 +1241,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
         journal: &mut PartitionPrepareJournal<S>,
         mutation: Mutation<S>,
         epoch: u64,
-        bytes: u64,
+        retained_bytes: u64,
     ) -> io::Result<()> {
         match mutation {
             Mutation::EnableSegments {
@@ -1238,7 +1256,7 @@ impl<S: DurableStorage> PartitionPersistence<S> {
             Mutation::Append {
                 prepare, durable, ..
             } => {
-                self.append_batch(journal, prepare, durable, epoch, bytes)
+                self.append_batch(journal, prepare, durable, epoch, 
retained_bytes)
                     .await
             }
             Mutation::Truncate { from_op, .. } => 
journal.truncate_from(from_op).await,
@@ -1293,27 +1311,32 @@ impl<S: DurableStorage> PartitionPersistence<S> {
         first: Frozen<4096>,
         durable: bool,
         epoch: u64,
-        first_bytes: u64,
+        first_retained_bytes: u64,
     ) -> io::Result<()> {
+        let (first_wal_bytes, first_segment_body_bytes) = 
append_lengths(journal, &first)?;
+        let mut bytes = AppendBatchBytes {
+            retained_capacity: first_retained_bytes,
+            wal_extent: first_wal_bytes as u64,
+            segment_body: first_segment_body_bytes as u64,
+        };
         let mut batch = SmallVec::<[Frozen<4096>; 8]>::new();
         batch.push(first);
-        let mut bytes = first_bytes;
         let mut durable = durable;
-        self.collect_queued(&mut batch, &mut bytes, &mut durable, epoch);
+        self.collect_queued(journal, &mut batch, &mut bytes, &mut durable, 
epoch)?;
         // Charged before the wait: `collect_queued` took these bytes out of 
the
         // queued total, and admission and checkpoint pacing sum queued and
         // in-flight bytes against the budget, so a gap here would admit a full
         // group past it.
-        self.in_flight_bytes.set(bytes);
+        self.in_flight_bytes.set(bytes.retained_capacity);
         // The barrier is what groups prepares, so a barrier cheaper than the
         // interval between arrivals groups nothing and every prepare pays its
         // own writes. This wait puts that grouping back under operator 
control.
-        if durable && let Some(delay) = self.group_commit_wait(&batch, bytes) {
+        if durable && let Some(delay) = self.group_commit_wait(&batch, &bytes) 
{
             self.group_commit_waits
                 .set(self.group_commit_waits.get() + 1);
             compio::runtime::time::sleep(delay).await;
-            self.collect_queued(&mut batch, &mut bytes, &mut durable, epoch);
-            self.in_flight_bytes.set(bytes);
+            self.collect_queued(journal, &mut batch, &mut bytes, &mut durable, 
epoch)?;
+            self.in_flight_bytes.set(bytes.retained_capacity);
         }
         let count = batch.len() as u64;
         journal.append_batch_buffered(&batch).await?;
@@ -1329,39 +1352,58 @@ impl<S: DurableStorage> PartitionPersistence<S> {
     /// Move every queued append that still fits into `batch`.
     fn collect_queued(
         &self,
+        journal: &PartitionPrepareJournal<S>,
         batch: &mut SmallVec<[Frozen<4096>; 8]>,
-        bytes: &mut u64,
+        bytes: &mut AppendBatchBytes,
         durable: &mut bool,
         epoch: u64,
-    ) {
+    ) -> io::Result<()> {
         let mut queue = self.queue.borrow_mut();
         while batch.len() < APPEND_BATCH_OPS_MAX {
             let Some(Mutation::Append {
                 epoch: next_epoch,
-                bytes: next_bytes,
+                prepare,
                 ..
             }) = queue.front()
             else {
                 break;
             };
-            if *next_epoch != epoch || bytes.saturating_add(*next_bytes) > 
APPEND_BATCH_BYTES_MAX {
+            if *next_epoch != epoch {
+                break;
+            }
+            let (next_wal_bytes, next_segment_body_bytes) = 
append_lengths(journal, prepare)?;
+            let next_wal_bytes = next_wal_bytes as u64;
+            let next_segment_bytes = next_segment_body_bytes as u64;
+            // Inline prepares count their full padded WAL extent and zero 
segment
+            // bytes. Segment-backed SendMessages count a padded reference 
record
+            // in the WAL and their unpadded body bytes against the segment 
limit.
+            if bytes.wal_extent.saturating_add(next_wal_bytes) > 
APPEND_BATCH_WAL_BYTES_MAX
+                || bytes.segment_body.saturating_add(next_segment_bytes)
+                    > APPEND_BATCH_SEGMENT_BYTES_MAX
+            {
                 break;
             }
             let Some(Mutation::Append {
                 prepare,
                 durable: requires_sync,
-                bytes: record_bytes,
+                retained_bytes: record_retained_bytes,
                 ..
             }) = queue.pop_front()
             else {
                 unreachable!("append prefix was checked");
             };
-            *bytes += record_bytes;
-            self.queued_bytes
-                .set(self.queued_bytes.get().saturating_sub(record_bytes));
+            bytes.retained_capacity += record_retained_bytes;
+            bytes.wal_extent += next_wal_bytes;
+            bytes.segment_body += next_segment_bytes;
+            self.queued_bytes.set(
+                self.queued_bytes
+                    .get()
+                    .saturating_sub(record_retained_bytes),
+            );
             *durable |= requires_sync;
             batch.push(prepare);
         }
+        Ok(())
     }
 
     /// How long to wait for more prepares before the barrier, if at all.
@@ -1369,9 +1411,19 @@ impl<S: DurableStorage> PartitionPersistence<S> {
     /// `None` for a disabled delay, a group already at its bounds, or arrivals
     /// spaced wider than the delay, where the wait would expire before the 
next
     /// prepare reached the queue.
-    fn group_commit_wait(&self, batch: &[Frozen<4096>], bytes: u64) -> 
Option<Duration> {
+    fn group_commit_wait(
+        &self,
+        batch: &[Frozen<4096>],
+        bytes: &AppendBatchBytes,
+    ) -> Option<Duration> {
         let delay = self.group_commit_delay.get();
-        if delay.is_zero() || batch.len() >= APPEND_BATCH_OPS_MAX || bytes >= 
APPEND_BATCH_BYTES_MAX
+        // Inline prepares are bounded by their full padded WAL extent. 
Segment-
+        // backed SendMessages are bounded by both their reference-record 
extent
+        // and the message bodies copied into segment storage.
+        if delay.is_zero()
+            || batch.len() >= APPEND_BATCH_OPS_MAX
+            || bytes.wal_extent >= APPEND_BATCH_WAL_BYTES_MAX
+            || bytes.segment_body >= APPEND_BATCH_SEGMENT_BYTES_MAX
         {
             return None;
         }
@@ -1398,6 +1450,14 @@ fn prepare_header(prepare: &Frozen<4096>) -> 
io::Result<&PrepareHeader> {
         .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid 
prepare"))
 }
 
+fn append_lengths<S: DurableStorage>(
+    journal: &PartitionPrepareJournal<S>,
+    prepare: &Frozen<4096>,
+) -> io::Result<(usize, usize)> {
+    let header = prepare_header(prepare)?;
+    journal.append_lengths(header.operation, prepare.len())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/core/simulator/src/storage/tests.rs 
b/core/simulator/src/storage/tests.rs
index 51f58cc50..7e0eade54 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, install_backup};
+use partitions::{PartitionPersistence, PersistenceMetrics, install_backup};
 use server_common::send_messages::{
     BATCH_MESSAGE_HEADER_SIZE, IggyMessage, IggyMessageHeader, IggyMessages, 
SendMessagesOwned,
 };
@@ -2853,47 +2853,58 @@ fn 
given_an_interrupted_writer_when_the_process_restarts_then_the_partition_shou
 }
 
 #[test]
-#[ignore = "PR #4092 review: append coalescing is gated on message-body bytes, 
not the WAL extent, so batching is inert at the benchmarked batch sizes"]
 fn 
given_large_bodies_when_appending_then_wal_records_should_coalesce_into_one_barrier_group()
 {
     block_on(async {
         const PREPARES: u64 = 8;
-        let storage = storage_for_partition().await;
-        let (persistence, _) =
-            PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, 
storage.clone())
-                .await
-                .unwrap();
-        persistence.enable_segment_storage(
-            SegmentPosition::default(),
-            PREPARES * LARGE_BATCH_BYTES as u64,
-        );
-        assert!(persistence.start());
-        Rc::clone(&persistence).run().await;
-        persistence.take_metrics();
-
-        let mut parent = 0;
-        for offset in 0..PREPARES {
-            let prepare = owned_prepare_sized(offset + 1, parent, offset, 
LARGE_BATCH_BYTES);
-            parent = prepare.header().checksum;
-            persistence.append(prepare.into_frozen(), true).unwrap();
-        }
-        assert!(persistence.start());
-        Rc::clone(&persistence).run().await;
-
-        let metrics = persistence.take_metrics();
+        let metrics = large_body_batch_metrics(PREPARES).await;
         assert_eq!(metrics.batched_prepares, PREPARES);
         // Under segment references a record occupies one 4 KiB extent, so all
-        // eight fit far inside APPEND_BATCH_BYTES_MAX. The gate measures the
-        // message body instead, so each prepare takes its own barrier group.
+        // eight fit far inside the group-commit WAL byte budget.
         assert_eq!(
             metrics.completed_batches,
             1,
-            "coalescing is gated on body bytes, so {PREPARES} prepares paid {} 
barrier groups for {} bytes of WAL extent",
+            "{PREPARES} prepares paid {} barrier groups for {} bytes of WAL 
extent",
             metrics.completed_batches,
             PREPARES * PARTITION_WAL_BLOCK_SIZE as u64
         );
     });
 }
 
+#[test]
+fn 
given_segment_body_work_exceeds_the_limit_when_appending_then_the_batch_should_split()
 {
+    block_on(async {
+        const PREPARES: u64 = 9;
+        let metrics = large_body_batch_metrics(PREPARES).await;
+        assert_eq!(metrics.batched_prepares, PREPARES);
+        assert_eq!(metrics.completed_batches, 2);
+    });
+}
+
+async fn large_body_batch_metrics(prepares: u64) -> PersistenceMetrics {
+    let storage = storage_for_partition().await;
+    let (persistence, _) =
+        PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7, 
storage.clone())
+            .await
+            .unwrap();
+    persistence.enable_segment_storage(
+        SegmentPosition::default(),
+        prepares * LARGE_BATCH_BYTES as u64,
+    );
+    assert!(persistence.start());
+    Rc::clone(&persistence).run().await;
+    persistence.take_metrics();
+
+    let mut parent = 0;
+    for offset in 0..prepares {
+        let prepare = owned_prepare_sized(offset + 1, parent, offset, 
LARGE_BATCH_BYTES);
+        parent = prepare.header().checksum;
+        persistence.append(prepare.into_frozen(), true).unwrap();
+    }
+    assert!(persistence.start());
+    Rc::clone(&persistence).run().await;
+    persistence.take_metrics()
+}
+
 fn owned_prepare_sized(
     op: u64,
     parent: u128,

Reply via email to