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

krishvishal pushed a commit to branch part-mat-barrier
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/part-mat-barrier by this push:
     new 4d7960c77 fix: address review comments
4d7960c77 is described below

commit 4d7960c773f16ae78a17dc4e578b4a23bf371e2e
Author: Krishna Vishal <[email protected]>
AuthorDate: Tue Aug 4 00:16:00 2026 +0530

    fix: address review comments
---
 core/server-ng/src/partition_reconciler.rs | 108 ++++++++++++++++++++++++-----
 core/shard/src/lib.rs                      |  78 ++++++++++++++++-----
 2 files changed, 148 insertions(+), 38 deletions(-)

diff --git a/core/server-ng/src/partition_reconciler.rs 
b/core/server-ng/src/partition_reconciler.rs
index b150e9ecb..fde1d682a 100644
--- a/core/server-ng/src/partition_reconciler.rs
+++ b/core/server-ng/src/partition_reconciler.rs
@@ -91,14 +91,18 @@
 //! shed request costs a retry: answered with a retriable status, re-issued by
 //! the SDK. A shed prepare is permanent loss on this replica, with no client 
to
 //! answer and `consensus::retransmit_targets` skipping any op that already
-//! reached quorum. So a request is refused the moment admitting it would 
cross a
-//! budget, and answered once it outlives `MAX_PARKED_PASSES`; a prepare never
-//! expires by age and is refused only once a budget is already spent. That 
caps
-//! prepare residency at one frame of overshoot per budget instead of at the
-//! budget, and is what makes an oversize frame parkable at all: measured 
against
-//! an empty entry, a 5 MiB append would fail the per-namespace check on every
-//! attempt. A parked prepare is destroyed only for a namespace this shard 
cannot
-//! serve at all, tombstoned or not hashing here.
+//! reached quorum.
+//!
+//! All three bind a request: refused when admitting it would cross a byte 
budget
+//! or the frame cap, answered past `MAX_PARKED_PASSES`. Only the byte budgets
+//! bind a prepare, and only once one is spent. Excluding the frame cap is
+//! deliberate: a header-only frame charges `MESSAGE_ALIGN`, so 128 is 512 KiB
+//! against a 4 MiB namespace budget, and a shared cap would shed small 
prepares
+//! before any byte budget spoke. Prepares admit 1024 header-only frames per
+//! namespace, overshooting each budget by one frame rather than stopping at 
it,
+//! which also makes an oversize frame parkable: against an empty entry a 5 MiB
+//! append fails the per-namespace check every attempt. A parked prepare dies 
only
+//! for a namespace this shard cannot serve, tombstoned or not hashing here.
 //!
 //! Everything leaving the buffer unserved is counted under
 //! `frame_drops_total{variant=partition}`: `park_overflow` when shed on 
arrival,
@@ -117,7 +121,7 @@
 //! in `on_start_view` -- `tick_partitions` re-drives an existing session but
 //! cannot open one -- so the backup stays behind `commit_max` until an 
unrelated
 //! view change. It needs a normal-status repair driver. The park policy above
-//! shrinks the exposure to two cases, a genuinely exhausted budget and a
+//! shrinks the exposure to two cases, a genuinely exhausted byte budget and a
 //! namespace this shard cannot serve, but only the repair driver removes it.
 //!
 //! TODO(krishna): the park stamp is not stable across re-entry. A 
re-dispatched
@@ -3180,6 +3184,61 @@ mod tests {
         );
     }
 
+    /// The frame cap is request-only. Applied to prepares it is the binding
+    /// constraint for any footprint under `NAMESPACE_BUDGET / PARK_CAP` (32 
KiB),
+    /// so header-only prepares would shed at 128 frames, 512 KiB into a 4 MiB
+    /// budget, and the byte budgets would never speak. Small-append prepares
+    /// would then be destroyed exactly as before the class split, at every
+    /// replica count: quorum always leaves at least one surplus backup, so a
+    /// lagging one loses shed prepares with nobody noticing.
+    #[compio::test]
+    async fn 
the_frame_cap_bounds_requests_only_so_small_prepares_reach_the_byte_budget() {
+        let tmp = TempDir::new().expect("tempdir for system path");
+        let config = test_config(&tmp);
+        let mux = TestMux::default();
+        seed_stream(&mux, 1, "stream-frame-cap");
+        seed_topic(&mux, 2, 0, "topic-frame-cap", vec![assignment(0, 1)]);
+
+        let shard = build_test_shard(0, &config, mux);
+        let ns = IggyNamespace::new(0, 0, 0);
+
+        // Well past the frame cap, prepares keep parking.
+        let beyond_cap = PARK_CAP + 72;
+        for op in 0..beyond_cap {
+            park_one_prepare(&shard, ns, op as u64).await;
+        }
+        assert_eq!(
+            shard.parked_frame_count(ns),
+            beyond_cap,
+            "the frame cap must not shed prepares"
+        );
+        assert_eq!(park_overflow_count(&shard), 0);
+
+        // A request into that same deep entry is still capped.
+        park_one_request(&shard, ns).await;
+        assert_eq!(
+            shard.parked_frame_count(ns),
+            beyond_cap,
+            "a request past the cap must still be shed"
+        );
+        assert_eq!(park_overflow_count(&shard), 1);
+
+        // The per-namespace byte budget is what finally stops the prepares.
+        for op in beyond_cap..=HEADER_FRAMES_PER_NAMESPACE {
+            park_one_prepare(&shard, ns, op as u64).await;
+        }
+        assert_eq!(
+            shard.parked_frame_count(ns),
+            HEADER_FRAMES_PER_NAMESPACE,
+            "prepares must admit up to the byte budget, not the frame cap"
+        );
+        assert_eq!(
+            park_overflow_count(&shard),
+            2,
+            "and the frame crossing the byte budget is the only further shed"
+        );
+    }
+
     /// The frame cap bounds count, not residency: `Message::into_generic` is a
     /// retag, so each entry keeps its whole buffer, up to 64 MiB. With only a
     /// frame cap one namespace could pin 128 x 64 MiB, so bytes must bite 
first.
@@ -3249,16 +3308,22 @@ mod tests {
         );
 
         // One frame into an empty entry: per-namespace waived, shard-wide not.
-        let crossing = IggyNamespace::new(0, filled, 0);
-        shard
-            .on_message(build_partition_request_sized(crossing, MIB_BODY))
-            .await;
-        assert_eq!(
-            shard.parked_frame_count(crossing),
-            0,
-            "the frame that would cross the shard-wide budget must be shed"
-        );
-        assert_eq!(park_overflow_count(&shard), 1);
+        // Two distinct fresh namespaces, so the entryless shed path runs 
twice.
+        // It has no `ParkEntry` to warn once from and is gated shard-wide
+        // instead; the gate is log volume only, so the counter still records
+        // every shed.
+        for offset in 0..2 {
+            let crossing = IggyNamespace::new(0, filled + offset, 0);
+            shard
+                .on_message(build_partition_request_sized(crossing, MIB_BODY))
+                .await;
+            assert_eq!(
+                shard.parked_frame_count(crossing),
+                0,
+                "the frame that would cross the shard-wide budget must be shed"
+            );
+        }
+        assert_eq!(park_overflow_count(&shard), 2);
     }
 
     /// A refused re-dispatch re-parks the frame, and by then the namespace is
@@ -3394,6 +3459,11 @@ mod tests {
     /// pushes a 1 MiB body into the next page.
     const MIB_BODY: usize = 1024 * 1024;
     const MIB_FOOTPRINT: usize = MIB_BODY + 4096;
+    /// Footprint of a header-only frame: buffers are `MESSAGE_ALIGN`-granular.
+    const HEADER_FOOTPRINT: usize = 4096;
+    /// Header-only frames one namespace admits before its byte budget refuses
+    /// more. Requests stop at [`PARK_CAP`] long before this; prepares do not.
+    const HEADER_FRAMES_PER_NAMESPACE: usize = NAMESPACE_BUDGET / 
HEADER_FOOTPRINT;
     /// [`MIB_BODY`] frames one namespace admits before its budget refuses 
more.
     const PER_NAMESPACE_MIB_FRAMES: usize = NAMESPACE_BUDGET / MIB_FOOTPRINT;
     /// Mirrors `MAX_PARKED_PASSES`.
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 6bca33a19..aec89d61b 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -1034,6 +1034,11 @@ where
     /// function of the namespaces alone.
     reparked_partition_namespaces: RefCell<BTreeSet<IggyNamespace>>,
 
+    /// Set while the shard-wide budget is shedding for namespaces holding no
+    /// park entry of their own, which have no [`ParkEntry::shed`] to warn once
+    /// from. Cleared when the park map empties, so one episode warns once.
+    shard_park_shedding: Cell<bool>,
+
     /// Live ceiling on prepares served per `RequestPrepares` round. Defaults
     /// to [`REPAIR_CHUNK_MAX`]; server-ng overrides it from
     /// `[cluster] repair_chunk_max` at bootstrap.
@@ -1164,6 +1169,7 @@ where
             pending_partition_frames: RefCell::new(BTreeMap::new()),
             parked_partition_bytes: Cell::new(0),
             reparked_partition_namespaces: RefCell::new(BTreeSet::new()),
+            shard_park_shedding: Cell::new(false),
             metadata_repair: RefCell::new(None),
             metadata_transfer: RefCell::new(None),
             metadata_transfer_offers: RefCell::new(HashMap::new()),
@@ -1401,6 +1407,7 @@ where
             pending_partition_frames: RefCell::new(BTreeMap::new()),
             parked_partition_bytes: Cell::new(0),
             reparked_partition_namespaces: RefCell::new(BTreeSet::new()),
+            shard_park_shedding: Cell::new(false),
             metadata_repair: RefCell::new(None),
             metadata_transfer: RefCell::new(None),
             metadata_transfer_offers: RefCell::new(HashMap::new()),
@@ -1762,7 +1769,14 @@ impl ParkEntry {
     }
 }
 
-/// Per-namespace ceiling on parked frames.
+/// Per-namespace ceiling on parked CLIENT REQUESTS.
+///
+/// Requests only, like the byte budgets: a header-only frame charges
+/// [`MESSAGE_ALIGN`], so 128 of them is 512 KiB against a 4 MiB per-namespace
+/// budget. Applied to prepares this would be the binding constraint for every
+/// footprint under 32 KiB and would shed them long before any byte budget 
could,
+/// which is the loss class the split exists to remove. A prepare is bounded by
+/// [`MAX_PARKED_BYTES_PER_NAMESPACE`] instead: 1024 header-only frames.
 const MAX_PARKED_PER_NAMESPACE: usize = 128;
 
 /// Shard-wide ceiling on parked bytes, measured as resident footprint (see
@@ -2019,15 +2033,21 @@ where
         self.reparked_partition_namespaces
             .borrow_mut()
             .remove(&namespace);
-        let entry = self
-            .pending_partition_frames
-            .borrow_mut()
-            .remove(&namespace)?;
+        let (entry, converged) = {
+            let mut pending = self.pending_partition_frames.borrow_mut();
+            let entry = pending.remove(&namespace)?;
+            let converged = pending.is_empty();
+            (entry, converged)
+        };
         self.parked_partition_bytes.set(
             self.parked_partition_bytes
                 .get()
                 .saturating_sub(entry.bytes),
         );
+        if converged {
+            // Episode over: the next entryless shed is a new one and warns.
+            self.shard_park_shedding.set(false);
+        }
         Some(entry.frames)
     }
 
@@ -2104,7 +2124,10 @@ where
     /// A frame the inbox refuses is re-parked: retained, so not counted as a
     /// drop. Re-queuing appends, so a pass materialising many namespaces can
     /// overrun the inbox; staging a deny is futile because it rides the same
-    /// sender with no await in between. The first `Full` ends the loop, since
+    /// sender with no await in between. One namespace alone can now do it, 
since
+    /// [`MAX_PARKED_BYTES_PER_NAMESPACE`] admits 1024 header-only prepares
+    /// against a default `inbox_capacity` of 1024. That costs other 
namespaces a
+    /// later convergence, not a frame. The first `Full` ends the loop, since
     /// the sole consumer of `senders[self.id]` is the pump task running this
     /// call and no later frame can find a slot the first one could not.
     ///
@@ -2274,8 +2297,9 @@ where
         count
     }
 
-    /// How many frames are parked under `namespace`. Bounded by
-    /// `MAX_PARKED_PER_NAMESPACE`; a shed frame must never grow it past that.
+    /// How many frames are parked under `namespace`. Client requests are 
bounded
+    /// by `MAX_PARKED_PER_NAMESPACE`, prepares by
+    /// `MAX_PARKED_BYTES_PER_NAMESPACE`; a shed frame must never grow either.
     ///
     /// Test/simulator accessor: nothing in production branches on a 
per-namespace
     /// park depth, and gating keeps it that way.
@@ -2430,21 +2454,37 @@ where
         } else {
             parked_bytes.saturating_add(frame_cost) > MAX_PARKED_BYTES
         };
-        if parked_len >= MAX_PARKED_PER_NAMESPACE || namespace_budget_spent || 
shard_budget_spent {
+        // The frame cap is request-only for the same reason. Applied to both 
it
+        // would be the binding constraint for any footprint under
+        // `MAX_PARKED_BYTES_PER_NAMESPACE / MAX_PARKED_PER_NAMESPACE` (32 
KiB),
+        // so header-only prepares would shed at 128 frames, 512 KiB into a 4 
MiB
+        // budget, and the byte budgets above would never get a say.
+        let frame_cap_spent = !replicated && parked_len >= 
MAX_PARKED_PER_NAMESPACE;
+        if frame_cap_spent || namespace_budget_spent || shard_budget_spent {
             self.metrics.record_frame_drop(
                 crate::metrics::frame_drop_variant::PARTITION,
                 crate::metrics::frame_drop_reason::PARK_OVERFLOW,
             );
-            // Warn once per namespace on the transition into shedding, then
-            // `debug`. A full buffer is this branch's trigger, not a rate 
limit:
-            // every later frame lands here too, and one formatted `warn` 
apiece
-            // is enough for the non-blocking appender to shed unrelated lines.
-            // The counter carries the volume.
-            let first_shed = existing.is_none_or(|entry| {
-                let first = entry.shed == 0;
-                entry.shed = entry.shed.saturating_add(1);
-                first
-            });
+            // Warn once per namespace on entering the shed, then `debug`: a
+            // full buffer is this branch's trigger, not a rate limit, so every
+            // later frame lands here too, and one formatted `warn` apiece 
makes
+            // the non-blocking appender shed unrelated lines. The counter
+            // carries the volume.
+            //
+            // An entryless namespace has no `ParkEntry::shed` to gate on and 
is
+            // reachable only via the shard-wide budget (the other two 
conditions
+            // need a non-empty entry), which is the many-namespace burst
+            // `MAX_PARKED_BYTES` is sized for. Hence the shard-level gate, and
+            // not `entry().or_default()`, which leaves the empty entry the 
read
+            // above avoids.
+            let first_shed = match existing {
+                Some(entry) => {
+                    let first = entry.shed == 0;
+                    entry.shed = entry.shed.saturating_add(1);
+                    first
+                }
+                None => !self.shard_park_shedding.replace(true),
+            };
             if first_shed {
                 tracing::warn!(
                     shard = self.id,

Reply via email to