hubcio commented on code in PR #3786:
URL: https://github.com/apache/iggy/pull/3786#discussion_r3703809464
##########
core/shard/src/lib.rs:
##########
@@ -1609,8 +1685,106 @@ enum ParkOutcome<H> {
/// transient status; replicated traffic still flows to the plane, whose
/// own tombstone guards drop it.
Tombstoned(Message<H>),
+ /// Namespace is unmaterialised and its park buffer is at capacity. Client
+ /// requests must be denied with a transient status: the frame is gone, and
+ /// silence would leave a lockstep transport waiting out its response
+ /// read-timeout. Replicated traffic is dropped, recovered by retransmit.
+ Overflow(Message<H>),
+}
+
+/// A partition frame held until its namespace materialises.
+///
+/// `epoch` is the committed `created_revision` observed when the frame was
+/// parked, or `None` when the namespace had no committed partition to read one
+/// from. Delete + recreate recycles the slab keys, so the namespace alone
cannot
+/// distinguish incarnations: without this stamp a frame parked against the
dead
+/// incarnation would be drained into its replacement by `InsertOwned` and
+/// served, because `serves_committed_incarnation` compares the committed
+/// revision against the routing row - both of which describe the NEW
+/// incarnation - and never the frame's provenance.
+struct ParkedFrame {
+ epoch: Option<u64>,
+ /// Reconciler passes this frame has survived. The sweep in
+ /// `partition_reconciler::reconcile_parked_frames` increments it and
+ /// reclaims past [`MAX_PARKED_PASSES`], bounding how long a frame can sit
+ /// here in units the simulator's virtual clock already controls.
+ ///
+ /// This bounds RESIDENCY, not staleness. Answering a late frame does not
stop
+ /// its operation from being applied late: the SDK replays the identical
+ /// request, same payload, for the rest of its response timeout, so an
+ /// absolute-offset `StoreConsumerOffset` that would have rewound the
group on
+ /// admission rewinds it on the replay too. What the bound buys is a park
+ /// buffer that cannot accumulate without limit, and a client that learns
the
+ /// outcome from a reply rather than a timeout.
+ passes: u32,
+ message: Message<GenericHeader>,
+}
+
+/// Per-namespace ceiling on parked frames.
+const MAX_PARKED_PER_NAMESPACE: usize = 128;
+
+/// Shard-wide ceiling on parked bytes, measured as resident footprint (see
+/// [`parked_footprint`]).
+///
+/// The per-namespace cap counts frames, and `Message::into_generic` is a retag
+/// rather than a copy, so each entry retains its whole buffer -- up to
+/// `message_bus::framing::MAX_MESSAGE_SIZE` (64 MiB). Frames alone therefore
+/// bound nothing useful: 128 × 64 MiB is 8 GiB for a single namespace, and
+/// nothing capped the namespace count. This is the budget that actually bounds
+/// residency, so a burst against many un-materialised namespaces sheds instead
+/// of exhausting the host.
+///
+/// Deliberately well below `MAX_MESSAGE_SIZE`. Sized equal to it, one legal
+/// max-size frame consumes the entire shard-wide budget and
head-of-line-blocks
+/// every other namespace's convergence window.
+const MAX_PARKED_BYTES: usize = 16 * 1024 * 1024;
+
+/// Per-namespace ceiling on parked bytes, so one un-materialised namespace
+/// cannot spend the whole shard's budget and shed everyone else's frames.
+///
+/// Applied only to a namespace that already holds something. A frame larger
+/// than this on its own would otherwise never park at all -- the check fails
+/// even against an empty entry -- and for a replicated prepare that is silent,
+/// unrecoverable loss: `consensus::retransmit_targets` skips an op that
already
+/// reached quorum, so the backup stays permanently short of it. Shipped
+/// `message_bus.max_message_size` is 64 MiB, well past this, so an ordinary
+/// batched append hits it. Admitting the first frame regardless costs the
other
+/// namespaces up to the whole shard budget for one convergence window; losing
a
+/// committed op costs them the replica.
+///
+/// TODO(krishna): [`MAX_PARKED_BYTES`] is still a hard ceiling, so a frame
above
+/// 16 MiB remains unparkable. Closing that needs the budget derived from the
+/// configured `message_bus.max_message_size` rather than a const here, which
+/// puts worst-case park residency at one max-size frame per shard.
+const MAX_PARKED_BYTES_PER_NAMESPACE: usize = MAX_PARKED_BYTES / 4;
+
+/// Resident cost of parking a frame of `len` bytes.
+///
+/// A parked frame retains its whole [`server_common::iobuf`] buffer, which is
+/// allocated at [`MESSAGE_ALIGN`] granularity, so a 256-byte frame occupies
+/// 4 KiB. Charging the logical length instead under-counts RSS by up to 16x
for
+/// header-only frames, which would let an accounted 16 MiB grow to ~256 MiB
+/// resident per shard.
+const fn parked_footprint(len: usize) -> usize {
+ len.next_multiple_of(MESSAGE_ALIGN)
}
+/// Reconciler passes a frame may survive before it is answered rather than
held.
+///
+/// Passes, not seconds, and deliberately not described in seconds: a pass
fires
+/// on the periodic interval OR on a commit-tick wake, so the wall-clock window
+/// this maps to spans orders of magnitude. `reconcile_periodic_interval`
legally
+/// reaches 30s, which would put four passes at 120s -- four times the SDK's
+/// response read-timeout, so the client times out first and the bound stops
+/// being the thing that answers it. Commit-tick wakes collapse it the other
way,
+/// to tens of milliseconds. It bounds residency in units the simulator's
virtual
+/// clock governs; it is not a latency guarantee.
+///
+/// TODO(krishna): derive this from `reconcile_periodic_interval` and the SDK
+/// response timeout so the bound tracks the configured interval instead of
+/// assuming one.
+const MAX_PARKED_PASSES: u32 = 3;
Review Comment:
`MAX_PARKED_PASSES` also ages out replicated prepares, and that is a new
loss path: on master a parked frame was held until materialisation, so a
lagging backup's prepare was eventually served. a prepare has no client
(`deny_parked_client_request` returns false) and nothing recovers the op
afterwards - `retransmit_targets` skips anything with `ok_quorum_received`, and
the partition plane opens a repair session only in `on_start_view`. the module
TODO covers shed/refused prepares but not aged ones.
passes are commit-driven (`MetadataCommitTick` fires per partition-shaping
commit, and a non-empty park buffer defeats the fast-skip), so 4 passes elapse
in milliseconds during a create burst - and the sweep ages every parked
namespace on each pass while `staged_this_pass` exempts only the one built that
pass, so unrelated commits burn a lagging namespace's budget. worst shape is
replica_count=2: the primary self-acks to quorum alone, so every prepare the
backup ages out is permanently lost and the backup stays behind `commit_max`
until an unrelated view change.
prepares should not be aged at all - the byte budget already bounds
residency.
##########
core/server-ng/src/partition_reconciler.rs:
##########
@@ -510,6 +599,113 @@ async fn reconcile_additions(
}
}
}
+
+ staged
+}
+
+/// Answer parked frames for namespaces this shard is not going to materialise.
+///
+/// `park_if_unmaterialised` holds a frame until `ReconcileOp::InsertOwned`
lands
+/// for its namespace, and the only other things that drain the entry are
+/// `ConfirmRemove` and `RemoveRouted`. Neither can name a namespace that was
+/// never built: it is absent from `IggyPartitions` (so `reconcile_removals`
+/// sees no owned ghost) and absent from `shards_table` (the owner seeds a row
+/// only via `InsertOwned`, and emits `InsertRouted` only for namespaces it
does
+/// NOT own). So without this sweep the frames are held for the process
+/// lifetime and every waiting client burns its full response read-timeout.
+///
+/// Immediate reclaim needs positive evidence that the build will not finish.
Two
+/// signals carry it: `build_partition_fresh` failed (ENOSPC, EPERM) and is
backed
+/// off -- the backoff clamps at 60s, well past the client's 30s read timeout,
so
Review Comment:
this justification does not hold for the case that actually fires:
`next_backoff(1)` is 1s (`BACKOFF_BASE`, shift 0) - 60s needs 7 consecutive
failures. `record_failure` stamps attempts 0 -> 1 and the sweep's `now`
postdates the stamp, so the reclaim below fires on the very first failed build.
one transient ENOSPC destroys every parked prepare for the namespace while the
rebuild succeeds a second later, and a reclaimed prepare is unrecoverable (no
client to answer, retransmit skips quorum-acked ops).
reclaim only when the backoff actually exceeds the remaining age budget - or
better, never reclaim prepares on local-convergence grounds.
##########
core/shard/src/lib.rs:
##########
@@ -1841,23 +2331,74 @@ where
if partitions.contains(&namespace) {
return ParkOutcome::Deliver(message);
}
+ // Read the committed revision before taking the borrow below: the
frame
+ // is stamped with the incarnation it was addressed to, so a later
drain
+ // can tell it apart from a same-key replacement.
+ let epoch = self
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .created_revision_for_namespace(namespace);
+ let frame_cost = parked_footprint(message.as_slice().len());
let mut pending = self.pending_partition_frames.borrow_mut();
- let parked = pending.entry(namespace).or_default();
- if parked.len() >= MAX_PARKED_PER_NAMESPACE {
+ let parked_bytes = self.parked_partition_bytes.get();
+ // Read the entry without `entry().or_default()`: inserting first would
+ // leave an empty Vec behind on the overflow path below, which reads
as a
+ // parked namespace to the reconciler sweep and its fast-skip guard.
+ let existing = pending.get(&namespace);
+ let parked_len = existing.map_or(0, Vec::len);
+ let namespace_bytes: usize = existing.map_or(0, |frames| {
+ frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum()
+ });
+ // The per-namespace byte cap is waived for the first frame, or a frame
+ // bigger than the cap could never park at all; see
+ // [`MAX_PARKED_BYTES_PER_NAMESPACE`]. The shard-wide budget is not
+ // waived, so residency is unchanged.
+ let over_namespace_budget = parked_len > 0
+ && namespace_bytes.saturating_add(frame_cost) >
MAX_PARKED_BYTES_PER_NAMESPACE;
+ if parked_len >= MAX_PARKED_PER_NAMESPACE
+ || over_namespace_budget
+ || parked_bytes.saturating_add(frame_cost) > MAX_PARKED_BYTES
Review Comment:
the shard-wide check has no empty-buffer waiver, while the per-namespace
check one line up does (`parked_len > 0`). so a partition frame with footprint
over 16 MiB can never park, on any attempt - `message_bus.max_message_size`
ships at 64 MiB and nothing narrows partition frames. for a client request that
is a transient deny plus a retry; for a prepare it is destroyed at the
`Overflow(_) => {}` arm, whose comment says retransmit redelivers -
contradicted by this PR's own TODO in partition_reconciler.rs (retransmit skips
quorum-acked ops; repair only opens in `on_start_view`).
the const's own TODO admits the >16 MiB gap, but shipping it turns a bound
change into committed-data loss on a backup: on master the only cap was 128
frames, so an oversize prepare parked and was served. and the loss door does
not even need an oversize frame - `MAX_PARKED_BYTES_PER_NAMESPACE` sheds the
4th ordinary 1 MiB frame (footprint 1052672 x 4 > 4 MiB) where master allowed
128.
cleanest fix: do not shed prepares on the byte budgets at all - allow a
one-frame overshoot (worst case = budget + one max-size frame, which is what
the TODO proposes anyway). shedding only requests makes the loss class
structurally impossible instead of raising a ceiling.
##########
core/shard/src/lib.rs:
##########
@@ -1781,29 +1958,340 @@ where
/// decode replies in lockstep, so silence wedges the connection until the
/// SDK's response read-timeout.
fn discard_parked_partition_frames(&self, namespace: IggyNamespace) {
- if let Some(frames) = self
- .pending_partition_frames
- .borrow_mut()
- .remove(&namespace)
+ // Bound the borrow to this statement: the guard in an `if let`
+ // scrutinee otherwise lives to the end of the then-block, holding a
+ // shard-global map locked across the outbound sends below.
+ let parked = self.take_parked_partition_frames(namespace);
+ if let Some(frames) = parked
&& !frames.is_empty()
{
+ let total = frames.len();
+ let mut answered = 0;
+ for frame in frames {
+ if self.deny_parked_client_request(frame) {
+ answered += 1;
+ } else {
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::metrics::frame_drop_reason::PARK_DROPPED,
+ );
+ }
+ }
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
- count = frames.len(),
+ answered,
+ dropped = total - answered,
"discarding parked partition frames for removed namespace"
);
- for frame in frames {
- if frame.header().command == Command2::Request
- && let Ok(request) =
frame.try_into_typed::<RequestHeader>()
+ }
+ }
+
+ /// Remove a namespace's park entry, debiting its bytes from
+ /// [`Self::parked_partition_bytes`]. The single place entries leave the
map,
+ /// so the running total and the pending-retry set cannot drift out of step
+ /// with it.
+ fn take_parked_partition_frames(&self, namespace: IggyNamespace) ->
Option<Vec<ParkedFrame>> {
+ self.reparked_partition_namespaces
+ .borrow_mut()
+ .remove(&namespace);
+ let frames = self
+ .pending_partition_frames
+ .borrow_mut()
+ .remove(&namespace)?;
+ let freed: usize = frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_sub(freed));
+ Some(frames)
+ }
+
+ /// Whether any frame is parked. Cheap enough for the reconciler's per-tick
+ /// fast-skip guard: a non-empty buffer means the shard is by definition
not
+ /// converged, so the skip must not fire.
+ #[must_use]
+ pub const fn has_parked_partition_frames(&self) -> bool {
+ self.parked_partition_bytes.get() > 0
+ }
+
+ /// Namespaces currently holding parked frames. The reconciler pairs this
+ /// against committed metadata to find the ones that will never
materialise,
+ /// which no `ConfirmRemove` / `RemoveRouted` can reach: a namespace that
was
+ /// never built is in neither `IggyPartitions` nor the routing table, so
+ /// nothing else names it.
+ #[must_use]
+ pub fn parked_namespaces(&self) -> Vec<IggyNamespace> {
+ self.pending_partition_frames
+ .borrow()
+ .keys()
+ .copied()
+ .collect()
+ }
+
+ /// Re-queue the frames parked for `namespace` now that its partition
exists
+ /// at `epoch`, onto this shard's own inbox so the pump serves them after
the
+ /// current drain.
+ ///
+ /// A frame stamped with a DIFFERENT incarnation never makes it back: the
+ /// namespace is byte-identical across incarnations, so serving it would
land
+ /// a dead topic's write inside the topic that recycled its keys, and the
+ /// downstream fence cannot see it -- that compares the committed revision
+ /// against the routing row, both of which now describe THIS incarnation.
+ ///
+ /// An UNSTAMPED frame (`epoch: None`) is served. `None` means this node's
+ /// metadata held no committed partition for the namespace when the frame
+ /// arrived, which on a metadata-lagging backup is the ordinary case the
park
+ /// buffer exists to absorb -- the partition primary materialises and
+ /// replicates as soon as its own metadata commits, well before a lagging
+ /// backup applies the same commit. Treating that as "prior incarnation"
+ /// destroys live traffic: a replicated prepare has no client to answer, so
+ /// it would be dropped with no recovery until an unrelated view change.
+ /// The residual is unchanged from before the stamp existed -- a frame
parked
+ /// while the namespace was absent, then recreated under a new incarnation,
+ /// is served against the replacement -- and closing it needs a wire-level
+ /// discriminator (see the `TODO(krishna)` in
+ /// `partition_reconciler`'s module docs), not a `None`-means-stale rule.
+ ///
+ /// A frame the inbox refuses is re-parked, not answered. Re-queuing
appends,
+ /// so a pass that materialises many namespaces at once can overrun the
inbox;
+ /// staging a deny there is futile because the deny rides that same sender
+ /// with no await in between, so nothing can have drained a slot.
+ ///
+ /// [`MAX_PARKED_PASSES`] does NOT bound a re-parked frame -- the
reconciler
+ /// sweep ages a namespace only while it is un-materialised, and by here it
+ /// is materialised. [`Self::repark_partition_frames`] arms the pump-side
+ /// retry instead, and the sweep's backstop for an inbox that never drains
is
+ /// `partition_reconciler::reconcile_parked_frames`, which now ages a
+ /// materialised namespace too.
+ fn redispatch_parked_frames(&self, namespace: IggyNamespace, epoch: u64)
+ where
+ B: MessageBus + 'static,
+ {
+ let Some(frames) = self.take_parked_partition_frames(namespace) else {
+ return;
+ };
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ count = frames.len(),
+ epoch,
+ "re-dispatching parked partition frames after materialisation"
+ );
+ let mut refused_frames: Vec<ParkedFrame> = Vec::new();
+ for frame in frames {
+ // Only a stamp that exists and disagrees is evidence of a prior
+ // incarnation; see this function's docs on why `None` is not.
+ if let Some(parked_epoch) = frame.epoch
+ && parked_epoch != epoch
+ {
+ self.reject_stale_parked_frame(namespace, epoch, frame);
+ continue;
+ }
+ let Some(sender) = self.senders.get(self.id as usize) else {
+ continue;
+ };
+ let passes = frame.passes;
+ let parked_epoch = frame.epoch;
+ let Err(error) = sender.try_send(ShardFrame::consensus(self.id,
frame.message)) else {
+ continue;
+ };
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::coordinator::classify_try_send_err(&error),
Review Comment:
two compounding issues in this loop. the drop is recorded before the `Full`
vs `Disconnected` match, so a frame that gets re-parked (retained, not lost)
still bumps `frame_drops_total{variant=partition,reason=full}` - the counter
metrics.rs documents as the only stranded-backup signal, and the one the
simulator's `assert_no_frame_drops` pins at zero. and the loop keeps calling
`try_send` after the first `Full`, which is provably futile: no await in the
loop, and the only consumer of `senders[self.id]` is the pump task executing
this very call, so nothing can free a slot mid-loop - up to 127 wasted sends
per call, re-driven by `retry_reparked_frames` on every pump iteration.
one edit fixes both: on `Full`, push the frame, `extend` with the rest of
the iterator, `break`; record a drop only on the `Disconnected` arm. (the
unreachable `let ShardFrame::Consensus .. else` destructure and the inline
duplicate of `deny_parked_client_request` on the disconnected path can fold
into the same edit.)
##########
core/shard/src/lib.rs:
##########
@@ -1383,6 +1429,29 @@ where
&self.metrics
}
+ /// Attach this shard's own inbox sender to a shard built by
+ /// [`Self::without_inbox`], which leaves the mesh empty.
+ ///
+ /// Exists for out-of-crate tests: the paths that hand work back to the
pump
+ /// (`stage_transient_deny`, the parked-frame re-dispatch) index
+ /// `senders[self.id]`, so without it they silently no-op and a test
asserting
+ /// on them proves nothing. The caller must keep the paired receiver alive;
+ /// dropping it turns every `try_send` into `Disconnected`.
+ ///
+ /// # Panics
+ /// If `sender` is not tagged for this shard, which would route this
shard's
+ /// own frames to a peer.
+ pub fn attach_self_sender(&mut self, sender: TaggedSender) {
Review Comment:
`senders = vec![sender]` puts the sender at index 0, but every consumer
indexes `senders[self.id]` - so for any shard id > 0 the re-dispatch and
`stage_transient_deny` silently no-op, which is the exact failure the doc says
this prevents. it also breaks the `senders[i].shard_id() == i` invariant both
ctors validate, `forward_metadata_submit` indexes `senders[0]` directly
(metadata submits would land in shard id's inbox instead of shard 0's), and
`without_inbox` pins `shard_count: 1` which this never updates. latent today
since every caller passes 0, but a future multi-shard test would silently prove
nothing.
sturdier shape: `attach_senders(&mut self, Vec<TaggedSender>)` run through
the existing `validate_sender_ordering`, update `shard_count` to match, and
gate it `#[cfg(any(test, feature = "simulator"))]` like `parked_frame_count`.
##########
core/shard/src/lib.rs:
##########
@@ -1841,23 +2331,74 @@ where
if partitions.contains(&namespace) {
return ParkOutcome::Deliver(message);
}
+ // Read the committed revision before taking the borrow below: the
frame
+ // is stamped with the incarnation it was addressed to, so a later
drain
+ // can tell it apart from a same-key replacement.
+ let epoch = self
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .created_revision_for_namespace(namespace);
Review Comment:
the stamp is not stable across re-entry: a re-dispatched frame still sitting
in the inbox when a delete + recreate completes (`ConfirmRemove` removes and
untombstones in the same arm, then the rebuild lands) re-parks here stamped
with the new incarnation's revision - read fresh from committed metadata - and
with `passes: 0`, so it is then served against the replacement. that is the
exact write the stamp exists to block, and a third door beyond the two the
module docs admit (`epoch: None`, and the wire-level prepare gap). narrow
window (the full delete + recreate has to finish while the frame waits in the
inbox), but worth carrying the original stamp across re-entry and making
`passes` monotonic - or at least softening the module doc's absolute about
stale drains.
##########
core/server-ng/src/partition_reconciler.rs:
##########
@@ -2104,4 +2423,918 @@ mod tests {
"survivor must take over the disconnected member's partitions"
);
}
+
+ /// A namespace deleted before its build finished is named by nothing: it
is
+ /// absent from `IggyPartitions`, so the removals pass sees no owned ghost,
+ /// and absent from `shards_table`, since the owner seeds a row only via
+ /// `InsertOwned`. Neither `ConfirmRemove` nor `RemoveRouted` can therefore
+ /// reach its parked frames, and without the sweep they are held for the
+ /// process lifetime while every waiting client burns its read timeout.
+ ///
+ /// Reclaim is via the age bound, not on sight of the namespace leaving the
+ /// target set: "absent from committed metadata" reads identically for a
+ /// deleted namespace and for one a metadata-lagging replica has not
applied
+ /// yet, so reclaiming on that would destroy live in-flight traffic. The
first
+ /// pass must therefore hold the frames, and a few passes later they are
gone.
+ #[compio::test]
+ async fn parked_frames_are_reclaimed_when_the_namespace_leaves_metadata() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-reclaim");
+ seed_topic(&mux, 2, 0, "topic-reclaim", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ // No pass yet, so the namespace is committed but unmaterialised.
+ park_one_request(&shard, ns).await;
+ assert_eq!(
+ shard.parked_namespaces(),
+ vec![ns],
+ "a request for an unmaterialised namespace must park"
+ );
+
+ // Delete before any pass builds it: the reconciler never materialises
+ // it, so nothing drains the entry the normal way.
+ seed_delete_topic(&shard.plane.metadata().mux_stm, 3, 0, 0);
+ reconcile_pass(&ctx).await;
+ assert_eq!(
+ shard.parked_frame_count(ns),
+ 1,
+ "the first pass must not destroy the frame: absence from the
target set \
+ is also what a not-yet-applied commit looks like"
+ );
+
+ // Every subsequent pass ages it, and the park buffer keeps defeating
the
+ // revision fast-skip until it drains.
+ for _ in 0..=PARK_MAX_PASSES {
+ reconcile_pass(&ctx).await;
+ }
+
+ assert!(
+ shard.parked_namespaces().is_empty(),
+ "frames for a namespace that left metadata must be answered and
reclaimed"
+ );
+ assert_eq!(
+ drain_staged_client_sends(&inbox),
+ 1,
+ "and the waiting client must get a retriable answer"
+ );
+ }
+
+ /// A frame parked before this node's metadata knew the namespace carries
no
+ /// epoch stamp, and `None` must NOT read as "prior incarnation": on a
+ /// metadata-lagging replica it is the ordinary case, since the partition
+ /// primary materialises and replicates as soon as its own metadata
commits.
+ /// Rejecting it destroys live traffic -- silently for a replicated
prepare,
+ /// which has no client to answer -- and the pre-stamp code served it.
+ #[compio::test]
+ async fn unstamped_parked_frame_is_served_not_rejected_as_stale() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-unstamped");
+ seed_topic(&mux, 2, 0, "topic-known", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+ // Topic slab 1 does not exist yet, so there is no committed
+ // `created_revision` to stamp: the frame parks with `epoch: None`.
+ let unknown = IggyNamespace::new(0, 1, 0);
+ park_one_request(&shard, unknown).await;
+ assert_eq!(
+ shard.parked_frame_count(unknown),
+ 1,
+ "a request for a namespace this node has not applied must park"
+ );
+
+ // The commit this node was lagging behind now lands, and the pass
+ // materialises the namespace.
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 3,
+ 0,
+ "topic-late",
+ vec![assignment(0, 2)],
+ );
+ reconcile_pass(&ctx).await;
+
+ assert_eq!(
+ shard.parked_frame_count(unknown),
+ 0,
+ "materialisation must drain the park entry"
+ );
+ let (served, answered) = drain_inbox(&inbox);
+ assert_eq!(
+ served, 1,
+ "the unstamped frame must be re-dispatched onto the pump, not
rejected"
+ );
+ assert_eq!(
+ answered, 0,
+ "and it must not be answered with a deny instead of served"
+ );
+ assert_eq!(
+ shard.metrics().partition_frames_rejected_stale_value(),
+ 0,
+ "an absent stamp is not evidence of a prior incarnation"
+ );
+ }
+
+ /// The shard-wide byte budget is a running total maintained at each
mutation
+ /// site rather than rescanned per arriving frame. A total that fails to
debit
+ /// on drain silently wedges the budget: the shard would shed every
namespace's
+ /// frames while nothing is actually parked. Exercise each way frames
leave --
+ /// re-dispatch on materialisation, the age bound, and reclaim -- and
assert the
+ /// total returns to empty.
+ #[compio::test]
+ async fn park_byte_total_returns_to_zero_on_every_drain_path() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-bytes");
+ seed_topic(&mux, 2, 0, "topic-bytes", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 16);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+ // Drain path 1: materialisation re-dispatches.
+ let late = IggyNamespace::new(0, 1, 0);
+ park_one_request(&shard, late).await;
+ assert!(shard.has_parked_partition_frames());
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 3,
+ 0,
+ "topic-bytes-late",
+ vec![assignment(0, 2)],
+ );
+ reconcile_pass(&ctx).await;
+ assert!(
+ !shard.has_parked_partition_frames(),
+ "re-dispatch must debit the parked-byte total"
+ );
+
+ // Drain path 2: the age bound answers the frame.
+ let never = IggyNamespace::new(0, 9, 0);
+ park_one_request(&shard, never).await;
+ assert!(shard.has_parked_partition_frames());
+ for _ in 0..=PARK_MAX_PASSES {
+ shard.age_parked_partition_frames(never);
+ }
+ assert!(
+ !shard.has_parked_partition_frames(),
+ "aging out must debit the parked-byte total"
+ );
+
+ // Drain path 3: an explicit reclaim.
+ park_one_request(&shard, never).await;
+ assert!(shard.has_parked_partition_frames());
+ shard.reclaim_parked_partition_frames(never);
+ assert!(
+ !shard.has_parked_partition_frames(),
+ "reclaim must debit the parked-byte total"
+ );
+
+ drop(inbox);
+ }
+
+ /// The replicated-prepare shape, which no other test covers and where both
+ /// park critical are worst: a prepare has no client, so
`deny_parked_frame`
+ /// no-ops on it and anything that discards it loses committed data
silently,
+ /// with no normal-status repair driver to refetch it.
+ ///
+ /// A backup receives the prepare before its own metadata commits (so the
frame
+ /// parks unstamped), then applies the commit and materialises. The
prepare must
+ /// be re-dispatched, not rejected as a prior incarnation.
+ #[compio::test]
+ async fn unstamped_parked_prepare_is_served_after_materialisation() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-prepare");
+ seed_topic(&mux, 2, 0, "topic-known", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+ // The primary replicates ahead of this node's metadata: topic slab 1
is
+ // not committed here yet, so the prepare parks with `epoch: None`.
+ let lagging = IggyNamespace::new(0, 1, 0);
+ park_one_prepare(&shard, lagging, 7).await;
+ assert_eq!(
+ shard.parked_frame_count(lagging),
+ 1,
+ "a prepare for a namespace this backup has not applied must park"
+ );
+
+ // The metadata commit catches up and the pass materialises the
namespace.
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 3,
+ 0,
+ "topic-late",
+ vec![assignment(0, 2)],
+ );
+ reconcile_pass(&ctx).await;
+
+ let (served, answered) = drain_inbox(&inbox);
+ assert_eq!(
+ served, 1,
+ "the parked prepare must be re-dispatched; discarding it is silent
\
+ committed-data loss, since a prepare has no client to answer"
+ );
+ assert_eq!(answered, 0, "a prepare has no client deny to send");
+ assert_eq!(
+ shard.metrics().partition_frames_rejected_stale_value(),
+ 0,
+ "an unstamped prepare is not a prior incarnation"
+ );
+ }
+
+ /// A parked prepare whose stamp names a DIFFERENT incarnation must still
be
+ /// dropped: applying a dead topic's op into the topic that recycled its
slab
+ /// keys diverges this replica. This is the half of the fence that stays.
+ #[compio::test]
+ async fn stamped_parked_prepare_from_a_prior_incarnation_is_rejected() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-stale");
+ seed_topic(&mux, 2, 0, "topic-first", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ // Parked against the FIRST incarnation, so it carries that revision.
+ park_one_prepare(&shard, ns, 7).await;
+ assert_eq!(shard.parked_frame_count(ns), 1);
+
+ // Delete and recreate: same namespace keys, new committed revision.
+ seed_delete_topic(&shard.plane.metadata().mux_stm, 3, 0, 0);
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 4,
+ 0,
+ "topic-recreated",
+ vec![assignment(0, 2)],
+ );
+ reconcile_pass(&ctx).await;
+
+ let (served, _answered) = drain_inbox(&inbox);
+ assert_eq!(
+ served, 0,
+ "a prepare stamped with the dead incarnation must not be served
against \
+ its replacement"
+ );
+ assert_eq!(
+ shard.metrics().partition_frames_rejected_stale_value(),
+ 1,
+ "and the reject must be counted"
+ );
+ }
+
+ /// Parking does not bump `Streams::revision` and does not wake the
reconciler,
+ /// so a frame that parks in a converged steady state would be held for the
+ /// process lifetime if the revision fast-skip could still fire. A
non-empty
+ /// park buffer must therefore defeat the skip.
+ #[compio::test]
+ async fn park_buffer_defeats_the_revision_fast_skip() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-skip");
+ seed_topic(&mux, 2, 0, "topic-skip", vec![assignment(0, 1)]);
+
+ let (shard, _inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+ // Converge: the first pass materialises, the verify pass after it arms
+ // the skip (same sequence as
`reconcile_fast_skips_when_revision_unchanged`).
+ assert!(reconcile_once(&ctx).await, "first pass runs the full diff");
+ ctx.shard.apply_reconcile_ops();
+ assert!(
+ reconcile_once(&ctx).await,
+ "the verify pass after a working pass must still run"
+ );
+ ctx.shard.apply_reconcile_ops();
+ assert!(
+ !reconcile_once(&ctx).await,
+ "a converged pass with an unchanged revision must fast-skip"
+ );
+
+ // Park a frame for a namespace that is NOT materialised, without
touching
+ // the revision, and the skip must stop firing.
+ let unbuilt = IggyNamespace::new(0, 0, 7);
+ park_one_request(&shard, unbuilt).await;
+ assert!(
+ shard.has_parked_partition_frames(),
+ "the frame must be parked for this test to mean anything"
+ );
+ assert!(
+ reconcile_once(&ctx).await,
+ "a non-empty park buffer must defeat the fast-skip so the sweep
can run"
+ );
+ }
+
+ /// Same hole, reached the other way: the namespace stays committed but
+ /// `build_partition_fresh` keeps failing. The `FailureCause::Add` backoff
+ /// clamps at 60s, twice the client's read timeout, so holding the frames
+ /// cannot help - answer them and let the client re-issue.
+ #[compio::test]
+ async fn parked_frames_are_reclaimed_while_the_build_is_backed_off() {
Review Comment:
this test pins the first-failure reclaim as correct (see the comment on the
sweep - `next_backoff(1)` is 1s, not the 60s the doc claims), and it uses the
inbox-less `build_test_shard`, so it cannot observe whether the client was ever
answered: `stage_transient_deny` bails at the missing sender while
`deny_parked_client_request` still returns true. the sibling test
`parked_frames_are_answered_once_they_outlive_their_admission_window` spells
out this exact trap and uses `build_test_shard_with_inbox` +
`drain_staged_client_sends`. also nothing in this branch covers a parked
prepare.
##########
core/shard/src/lib.rs:
##########
@@ -1781,29 +1958,340 @@ where
/// decode replies in lockstep, so silence wedges the connection until the
/// SDK's response read-timeout.
fn discard_parked_partition_frames(&self, namespace: IggyNamespace) {
- if let Some(frames) = self
- .pending_partition_frames
- .borrow_mut()
- .remove(&namespace)
+ // Bound the borrow to this statement: the guard in an `if let`
+ // scrutinee otherwise lives to the end of the then-block, holding a
+ // shard-global map locked across the outbound sends below.
+ let parked = self.take_parked_partition_frames(namespace);
+ if let Some(frames) = parked
&& !frames.is_empty()
{
+ let total = frames.len();
+ let mut answered = 0;
+ for frame in frames {
+ if self.deny_parked_client_request(frame) {
+ answered += 1;
+ } else {
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::metrics::frame_drop_reason::PARK_DROPPED,
+ );
+ }
+ }
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
- count = frames.len(),
+ answered,
+ dropped = total - answered,
"discarding parked partition frames for removed namespace"
);
- for frame in frames {
- if frame.header().command == Command2::Request
- && let Ok(request) =
frame.try_into_typed::<RequestHeader>()
+ }
+ }
+
+ /// Remove a namespace's park entry, debiting its bytes from
+ /// [`Self::parked_partition_bytes`]. The single place entries leave the
map,
+ /// so the running total and the pending-retry set cannot drift out of step
+ /// with it.
+ fn take_parked_partition_frames(&self, namespace: IggyNamespace) ->
Option<Vec<ParkedFrame>> {
+ self.reparked_partition_namespaces
+ .borrow_mut()
+ .remove(&namespace);
+ let frames = self
+ .pending_partition_frames
+ .borrow_mut()
+ .remove(&namespace)?;
+ let freed: usize = frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_sub(freed));
+ Some(frames)
+ }
+
+ /// Whether any frame is parked. Cheap enough for the reconciler's per-tick
+ /// fast-skip guard: a non-empty buffer means the shard is by definition
not
+ /// converged, so the skip must not fire.
+ #[must_use]
+ pub const fn has_parked_partition_frames(&self) -> bool {
+ self.parked_partition_bytes.get() > 0
+ }
+
+ /// Namespaces currently holding parked frames. The reconciler pairs this
+ /// against committed metadata to find the ones that will never
materialise,
+ /// which no `ConfirmRemove` / `RemoveRouted` can reach: a namespace that
was
+ /// never built is in neither `IggyPartitions` nor the routing table, so
+ /// nothing else names it.
+ #[must_use]
+ pub fn parked_namespaces(&self) -> Vec<IggyNamespace> {
+ self.pending_partition_frames
+ .borrow()
+ .keys()
+ .copied()
+ .collect()
+ }
+
+ /// Re-queue the frames parked for `namespace` now that its partition
exists
+ /// at `epoch`, onto this shard's own inbox so the pump serves them after
the
+ /// current drain.
+ ///
+ /// A frame stamped with a DIFFERENT incarnation never makes it back: the
+ /// namespace is byte-identical across incarnations, so serving it would
land
+ /// a dead topic's write inside the topic that recycled its keys, and the
+ /// downstream fence cannot see it -- that compares the committed revision
+ /// against the routing row, both of which now describe THIS incarnation.
+ ///
+ /// An UNSTAMPED frame (`epoch: None`) is served. `None` means this node's
+ /// metadata held no committed partition for the namespace when the frame
+ /// arrived, which on a metadata-lagging backup is the ordinary case the
park
+ /// buffer exists to absorb -- the partition primary materialises and
+ /// replicates as soon as its own metadata commits, well before a lagging
+ /// backup applies the same commit. Treating that as "prior incarnation"
+ /// destroys live traffic: a replicated prepare has no client to answer, so
+ /// it would be dropped with no recovery until an unrelated view change.
+ /// The residual is unchanged from before the stamp existed -- a frame
parked
+ /// while the namespace was absent, then recreated under a new incarnation,
+ /// is served against the replacement -- and closing it needs a wire-level
+ /// discriminator (see the `TODO(krishna)` in
+ /// `partition_reconciler`'s module docs), not a `None`-means-stale rule.
+ ///
+ /// A frame the inbox refuses is re-parked, not answered. Re-queuing
appends,
+ /// so a pass that materialises many namespaces at once can overrun the
inbox;
+ /// staging a deny there is futile because the deny rides that same sender
+ /// with no await in between, so nothing can have drained a slot.
+ ///
+ /// [`MAX_PARKED_PASSES`] does NOT bound a re-parked frame -- the
reconciler
+ /// sweep ages a namespace only while it is un-materialised, and by here it
+ /// is materialised. [`Self::repark_partition_frames`] arms the pump-side
+ /// retry instead, and the sweep's backstop for an inbox that never drains
is
+ /// `partition_reconciler::reconcile_parked_frames`, which now ages a
+ /// materialised namespace too.
+ fn redispatch_parked_frames(&self, namespace: IggyNamespace, epoch: u64)
+ where
+ B: MessageBus + 'static,
+ {
+ let Some(frames) = self.take_parked_partition_frames(namespace) else {
+ return;
+ };
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ count = frames.len(),
+ epoch,
+ "re-dispatching parked partition frames after materialisation"
+ );
+ let mut refused_frames: Vec<ParkedFrame> = Vec::new();
+ for frame in frames {
+ // Only a stamp that exists and disagrees is evidence of a prior
+ // incarnation; see this function's docs on why `None` is not.
+ if let Some(parked_epoch) = frame.epoch
+ && parked_epoch != epoch
Review Comment:
fence is direction-blind: park stamps the live committed revision, but
`InsertOwned` carries the epoch snapshotted at build time, so a delete +
recreate committing between staging and the pump apply gives the parked frame a
stamp newer than `epoch` - and it gets rejected with the "prior incarnation"
warn and counted on `partition_frames_rejected_stale_total`, whose doc says
non-zero is a caught correctness anomaly, not routine churn. false positive for
anyone alerting on it.
note the tempting one-liner `parked_epoch < epoch` is worse: a newer-stamped
prepare would then be applied into the old incarnation's partition, which the
staleness teardown erases along with its disk data. keep rejecting both
directions - just split the log line and the label by direction so the anomaly
counter keeps its contract.
##########
core/integration/tests/cluster/multi_shard_partition_convergence.rs:
##########
@@ -0,0 +1,201 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Partition convergence across shards on one node.
+//!
+//! `create_topic` returns on metadata commit, before the owning shard has run
+//! `build_partition_fresh`, so a produce issued immediately after races
+//! materialisation. Nothing about that race is resolved on the shard the
client
+//! is connected to: `shards_table` is a cache of the deterministic hash
+//! assignment and may hold a row before the partition exists. The owning shard
+//! resolves it, by parking the frame until its partition lands
+//! (`park_if_unmaterialised`) and by fencing a mismatched incarnation
+//! (`serves_committed_incarnation`), answering anything it cannot yet serve
+//! with a retriable status the SDK replays.
+//!
+//! Forcing two shards is the point. Existing coverage of these fences is
+//! single-shard, where the shard that admits the request is also the one that
+//! owns the partition, so the cross-core path never runs: the hash fallback in
+//! `router::route_typed`, the frame crossing into a peer's inbox, and that
+//! peer's park queue draining on its own pump. Roughly half of the topics
below
+//! hash to a shard other than the one homing the connection.
+//!
+//! That split is a murmur3 outcome and is invisible from here -- this test
would
+//! stay green while silently degrading to single-shard if the hash or the
shard
+//! count changed. It is pinned instead where the assignment is a pure
function,
+//! by
`partition_reconciler::tests::integration_topic_set_straddles_both_shards`,
+//! which asserts over the same namespaces this test creates. Change the topic
+//! count or the stream here and that guard has to move with it.
+//!
+//! Scope, stated plainly: this is a convergence test, not a barrier test. It
+//! cannot tell a request served straight through from one that parked and was
+//! re-dispatched, so it does not pin *which* mechanism carried it. What it
does
+//! catch is that mechanism failing outright -- a frame that never reaches the
+//! owner, a park queue that never drains, or a fence that denies forever --
+//! since every one of those surfaces as a failed send or a short poll.
+
+#![cfg(feature = "vsr")]
+
+use iggy::prelude::*;
+use integration::iggy_harness;
+
+const STREAM: &str = "convergence-stream";
+const PARTITION_ID: u32 = 0;
+/// Enough topics that the murmur3 assignment lands on both shards; every one
is
+/// asserted, so which side each falls on does not matter.
+const TOPICS: u32 = 8;
+
+fn topic_name(index: u32) -> String {
+ format!("convergence-topic-{index}")
+}
+
+async fn create_topic(client: &IggyClient, stream: &Identifier, name: &str) {
+ client
+ .create_topic(
+ stream,
+ name,
+ 1,
+ CompressionAlgorithm::default(),
+ None,
+ IggyExpiry::NeverExpire,
+ MaxTopicSize::ServerDefault,
+ )
+ .await
+ .unwrap_or_else(|error| panic!("create_topic {name}: {error}"));
+}
+
+async fn produce(client: &IggyClient, stream: &Identifier, topic: &Identifier,
payload: &str) {
+ let mut messages = vec![
+ IggyMessage::builder()
+ .payload(payload.to_owned().into())
+ .build()
+ .expect("message build"),
+ ];
+ client
+ .send_messages(
+ stream,
+ topic,
+ &Partitioning::partition_id(PARTITION_ID),
+ &mut messages,
+ )
+ .await
+ .unwrap_or_else(|error| panic!("send_messages {payload}: {error}"));
+}
+
+async fn poll_payloads(
+ client: &IggyClient,
+ stream: &Identifier,
+ topic: &Identifier,
+) -> Vec<String> {
+ client
+ .poll_messages(
+ stream,
+ topic,
+ Some(PARTITION_ID),
+ &Consumer::default(),
+ &PollingStrategy::offset(0),
+ 16,
+ false,
+ )
+ .await
+ .unwrap_or_else(|error| panic!("poll_messages: {error}"))
+ .messages
+ .iter()
+ .map(|message| String::from_utf8_lossy(&message.payload).into_owned())
+ .collect()
+}
+
+/// Topics are created in a batch first, so several materialisations are in
+/// flight at once when the produces start.
+#[iggy_harness(cluster_nodes = 1, server(system.sharding.cpu_allocation =
"2"))]
+async fn
given_two_shards_when_producing_right_after_create_topic_should_round_trip(
+ harness: &TestHarness,
+) {
+ let client = harness.new_client().await.unwrap();
+ client
+ .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+ .await
+ .unwrap();
+ let stream = Identifier::named(STREAM).unwrap();
+ client.create_stream(STREAM).await.unwrap();
+
+ for index in 0..TOPICS {
+ create_topic(&client, &stream, &topic_name(index)).await;
+ }
+ for index in 0..TOPICS {
+ let topic = Identifier::named(&topic_name(index)).unwrap();
+ produce(&client, &stream, &topic, &format!("payload-{index}")).await;
+ }
+ for index in 0..TOPICS {
+ let topic = Identifier::named(&topic_name(index)).unwrap();
+ assert_eq!(
+ poll_payloads(&client, &stream, &topic).await,
+ vec![format!("payload-{index}")],
+ "topic {index} must return the message produced right after its
creation"
+ );
+ }
+}
+
+/// Delete + recreate reuses the freed slab keys, so the namespace is
+/// byte-identical across incarnations and only `created_revision` separates
+/// them. This pins the observable outcome across that transition on a
+/// multi-shard node: the recreated topic serves its own data and none of the
+/// dead incarnation's.
+///
+/// It does NOT exercise the incarnation fence. Each step here is a completed
+/// round trip, so the reconciler converges before the next one starts and
+/// `serves_committed_incarnation` never denies (verified: zero "unverified
Review Comment:
this parenthetical cannot have verified anything: the unverified-incarnation
deny logs at `debug!` and the harness runs the server at the default info
level, so the line cannot appear in the captured log regardless of whether
denials happened.
the harness already has `ServerHandle::stdout_occurrences`, and all three
degraded-park modes log at `warn` ("park buffer at capacity", "retiring parked
partition frames", "rejecting parked partition frame from a prior incarnation")
- asserting those are zero would pin the mechanism positively, and is the
cheapest regression net for the park paths given the assertions here cannot
tell park + redispatch apart from deny + SDK replay.
##########
core/server-ng/src/partition_reconciler.rs:
##########
@@ -510,6 +599,113 @@ async fn reconcile_additions(
}
}
}
+
+ staged
+}
+
+/// Answer parked frames for namespaces this shard is not going to materialise.
+///
+/// `park_if_unmaterialised` holds a frame until `ReconcileOp::InsertOwned`
lands
+/// for its namespace, and the only other things that drain the entry are
+/// `ConfirmRemove` and `RemoveRouted`. Neither can name a namespace that was
+/// never built: it is absent from `IggyPartitions` (so `reconcile_removals`
+/// sees no owned ghost) and absent from `shards_table` (the owner seeds a row
+/// only via `InsertOwned`, and emits `InsertRouted` only for namespaces it
does
+/// NOT own). So without this sweep the frames are held for the process
+/// lifetime and every waiting client burns its full response read-timeout.
+///
+/// Immediate reclaim needs positive evidence that the build will not finish.
Two
+/// signals carry it: `build_partition_fresh` failed (ENOSPC, EPERM) and is
backed
+/// off -- the backoff clamps at 60s, well past the client's 30s read timeout,
so
+/// holding the frames cannot help -- or the namespace does not hash to this
shard
+/// at all, so no `InsertOwned` for it will ever land here.
+///
+/// Absence from the target set is NOT that evidence, which is why this no
longer
+/// consults it. "Not in the target" covers a namespace that left committed
+/// metadata AND one this replica has simply not applied yet, and those are
+/// indistinguishable from local state: `snapshot_target_namespaces` reads this
+/// node's committed metadata, so a metadata-lagging backup reports a
namespace it
+/// is milliseconds from committing exactly as it reports a deleted one.
Reclaiming
+/// on that reading destroys the in-flight traffic the park buffer exists to
hold
+/// (silently, for a replicated prepare, which has no client to answer). The
stale
+/// reading was doubly wrong: `target_set` is snapshotted before
+/// `reconcile_additions` awaits `build_partition_fresh`, so a topic committing
+/// during those awaits was judged against a set that predates it.
+///
+/// Everything without that evidence -- building, still committing, or
genuinely
+/// deleted -- is aged instead.
[`shard::IggyShard::age_parked_partition_frames`]
+/// answers frames past `MAX_PARKED_PASSES`, so residency stays bounded and no
+/// client waits out its read timeout; the deleted case simply takes a few
passes
+/// rather than one. The bound is residency only -- the SDK replays the
identical
+/// request, so answering a late frame does not stop its operation from being
+/// applied late (see `ParkedFrame::passes`).
+///
+/// A namespace in `staged_this_pass` is exempt: its `ReconcileOp::InsertOwned`
+/// is queued for the pump but not applied, so it reads as un-materialised here
+/// and would burn one of its three passes on the pass that built it.
Review Comment:
understates what the exemption does: `reconcile_additions` has no cross-pass
guard against a queued-but-unapplied `InsertOwned` (line 475 tests
`partitions.contains`, false while the op is queued), so it re-stages the
namespace on every pass until the pump drains - the set protects parked frames
across arbitrary pump lag, not one pass. worth saying outright: the one-pass
framing here is what makes removing this set look safe, and removing it would
age frames on every commit-driven pass while the pump lags.
##########
core/shard/src/lib.rs:
##########
@@ -1781,29 +1958,340 @@ where
/// decode replies in lockstep, so silence wedges the connection until the
/// SDK's response read-timeout.
fn discard_parked_partition_frames(&self, namespace: IggyNamespace) {
- if let Some(frames) = self
- .pending_partition_frames
- .borrow_mut()
- .remove(&namespace)
+ // Bound the borrow to this statement: the guard in an `if let`
+ // scrutinee otherwise lives to the end of the then-block, holding a
+ // shard-global map locked across the outbound sends below.
+ let parked = self.take_parked_partition_frames(namespace);
+ if let Some(frames) = parked
&& !frames.is_empty()
{
+ let total = frames.len();
Review Comment:
this retire loop (deny -> answered, else `park_dropped`) is duplicated
verbatim in `age_parked_partition_frames`. one helper returning (answered,
dropped) would also single-site the accounting fix needed for
`deny_parked_client_request`'s return value.
##########
core/server-ng/src/partition_reconciler.rs:
##########
@@ -969,6 +1171,83 @@ mod tests {
msg
}
+ /// Build a partition-plane replicated `Prepare` for `namespace`, as a
backup
+ /// receives it from the primary. The frame a client never sees: it has no
+ /// client to answer, so anything that discards it is silent data loss.
+ fn build_partition_prepare(namespace: IggyNamespace, op: u64) ->
Message<GenericHeader> {
+ let header_size = size_of::<PrepareHeader>();
+ let mut msg = Message::<PrepareHeader>::new(header_size);
+ let header = bytemuck::checked::try_from_bytes_mut::<PrepareHeader>(
+ &mut msg.as_mut_slice()[..header_size],
+ )
+ .expect("zeroed bytes form a valid PrepareHeader");
+ header.command = Command2::Prepare;
+ header.size = u32::try_from(header_size).expect("prepare size fits
u32");
+ header.operation = Operation::SendMessages;
+ header.namespace = namespace.inner();
+ header.op = op;
+ msg.into_generic()
+ }
+
+ async fn park_one_prepare(shard: &TestShard, namespace: IggyNamespace, op:
u64) {
+ shard
+ .on_message(build_partition_prepare(namespace, op))
+ .await;
+ }
+
+ /// Build a partition-plane client `Request` for `namespace`, as the pump
+ /// receives it off the wire. Only the routing fields matter: parking reads
+ /// `operation` + `namespace` and never touches the body.
+ fn build_partition_request(namespace: IggyNamespace) ->
Message<GenericHeader> {
Review Comment:
byte-identical to `build_partition_request_sized(namespace, 0)` - delegate.
##########
core/server-ng/Cargo.toml:
##########
@@ -184,6 +184,13 @@ vergen-git2 = { workspace = true }
assert_cmd = { workspace = true }
bytemuck = { workspace = true }
iggy = { workspace = true }
+# The reconciler's unit tests assert on `ShardMetrics` snapshots and
+# `IggyShard::parked_frame_count`, which are gated to test/simulator builds so
+# they cannot grow production callers. `shard`'s own `cfg(test)` is false when
it
+# is compiled as our dependency, so the feature is how those accessors become
+# visible here. Dev-only: a production `cargo build -p iggy-server-ng` does not
+# resolve dev-dependencies, so nothing extra is compiled in.
+shard = { path = "../shard", features = ["simulator"] }
Review Comment:
workspace already has a `shard` entry - `{ workspace = true, features =
["simulator"] }` like the sibling dev-deps. also the comment's mechanism is
slightly off: dev-dependencies are always resolved (they are in the lockfile);
what the resolver guarantees is that their feature selections are not unified
into non-test targets.
##########
core/shard/src/lib.rs:
##########
@@ -1781,29 +1958,340 @@ where
/// decode replies in lockstep, so silence wedges the connection until the
/// SDK's response read-timeout.
fn discard_parked_partition_frames(&self, namespace: IggyNamespace) {
- if let Some(frames) = self
- .pending_partition_frames
- .borrow_mut()
- .remove(&namespace)
+ // Bound the borrow to this statement: the guard in an `if let`
+ // scrutinee otherwise lives to the end of the then-block, holding a
+ // shard-global map locked across the outbound sends below.
+ let parked = self.take_parked_partition_frames(namespace);
+ if let Some(frames) = parked
&& !frames.is_empty()
{
+ let total = frames.len();
+ let mut answered = 0;
+ for frame in frames {
+ if self.deny_parked_client_request(frame) {
+ answered += 1;
+ } else {
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::metrics::frame_drop_reason::PARK_DROPPED,
+ );
+ }
+ }
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
- count = frames.len(),
+ answered,
+ dropped = total - answered,
"discarding parked partition frames for removed namespace"
);
- for frame in frames {
- if frame.header().command == Command2::Request
- && let Ok(request) =
frame.try_into_typed::<RequestHeader>()
+ }
+ }
+
+ /// Remove a namespace's park entry, debiting its bytes from
+ /// [`Self::parked_partition_bytes`]. The single place entries leave the
map,
+ /// so the running total and the pending-retry set cannot drift out of step
+ /// with it.
+ fn take_parked_partition_frames(&self, namespace: IggyNamespace) ->
Option<Vec<ParkedFrame>> {
+ self.reparked_partition_namespaces
+ .borrow_mut()
+ .remove(&namespace);
+ let frames = self
+ .pending_partition_frames
+ .borrow_mut()
+ .remove(&namespace)?;
+ let freed: usize = frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_sub(freed));
+ Some(frames)
+ }
+
+ /// Whether any frame is parked. Cheap enough for the reconciler's per-tick
+ /// fast-skip guard: a non-empty buffer means the shard is by definition
not
+ /// converged, so the skip must not fire.
+ #[must_use]
+ pub const fn has_parked_partition_frames(&self) -> bool {
+ self.parked_partition_bytes.get() > 0
+ }
+
+ /// Namespaces currently holding parked frames. The reconciler pairs this
+ /// against committed metadata to find the ones that will never
materialise,
+ /// which no `ConfirmRemove` / `RemoveRouted` can reach: a namespace that
was
+ /// never built is in neither `IggyPartitions` nor the routing table, so
+ /// nothing else names it.
+ #[must_use]
+ pub fn parked_namespaces(&self) -> Vec<IggyNamespace> {
+ self.pending_partition_frames
+ .borrow()
+ .keys()
+ .copied()
+ .collect()
+ }
+
+ /// Re-queue the frames parked for `namespace` now that its partition
exists
+ /// at `epoch`, onto this shard's own inbox so the pump serves them after
the
+ /// current drain.
+ ///
+ /// A frame stamped with a DIFFERENT incarnation never makes it back: the
+ /// namespace is byte-identical across incarnations, so serving it would
land
+ /// a dead topic's write inside the topic that recycled its keys, and the
+ /// downstream fence cannot see it -- that compares the committed revision
+ /// against the routing row, both of which now describe THIS incarnation.
+ ///
+ /// An UNSTAMPED frame (`epoch: None`) is served. `None` means this node's
+ /// metadata held no committed partition for the namespace when the frame
+ /// arrived, which on a metadata-lagging backup is the ordinary case the
park
+ /// buffer exists to absorb -- the partition primary materialises and
+ /// replicates as soon as its own metadata commits, well before a lagging
+ /// backup applies the same commit. Treating that as "prior incarnation"
+ /// destroys live traffic: a replicated prepare has no client to answer, so
+ /// it would be dropped with no recovery until an unrelated view change.
+ /// The residual is unchanged from before the stamp existed -- a frame
parked
+ /// while the namespace was absent, then recreated under a new incarnation,
+ /// is served against the replacement -- and closing it needs a wire-level
+ /// discriminator (see the `TODO(krishna)` in
+ /// `partition_reconciler`'s module docs), not a `None`-means-stale rule.
+ ///
+ /// A frame the inbox refuses is re-parked, not answered. Re-queuing
appends,
+ /// so a pass that materialises many namespaces at once can overrun the
inbox;
+ /// staging a deny there is futile because the deny rides that same sender
+ /// with no await in between, so nothing can have drained a slot.
+ ///
+ /// [`MAX_PARKED_PASSES`] does NOT bound a re-parked frame -- the
reconciler
+ /// sweep ages a namespace only while it is un-materialised, and by here it
+ /// is materialised. [`Self::repark_partition_frames`] arms the pump-side
+ /// retry instead, and the sweep's backstop for an inbox that never drains
is
+ /// `partition_reconciler::reconcile_parked_frames`, which now ages a
+ /// materialised namespace too.
+ fn redispatch_parked_frames(&self, namespace: IggyNamespace, epoch: u64)
+ where
+ B: MessageBus + 'static,
+ {
+ let Some(frames) = self.take_parked_partition_frames(namespace) else {
+ return;
+ };
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ count = frames.len(),
+ epoch,
+ "re-dispatching parked partition frames after materialisation"
+ );
+ let mut refused_frames: Vec<ParkedFrame> = Vec::new();
+ for frame in frames {
+ // Only a stamp that exists and disagrees is evidence of a prior
+ // incarnation; see this function's docs on why `None` is not.
+ if let Some(parked_epoch) = frame.epoch
+ && parked_epoch != epoch
+ {
+ self.reject_stale_parked_frame(namespace, epoch, frame);
+ continue;
+ }
+ let Some(sender) = self.senders.get(self.id as usize) else {
+ continue;
+ };
+ let passes = frame.passes;
+ let parked_epoch = frame.epoch;
+ let Err(error) = sender.try_send(ShardFrame::consensus(self.id,
frame.message)) else {
+ continue;
+ };
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::coordinator::classify_try_send_err(&error),
+ );
+ let (refused, disconnected) = match error {
+ TrySendError::Full(frame) => (frame, false),
+ TrySendError::Disconnected(frame) => (frame, true),
+ };
+ let ShardFrame::Consensus { message, .. } = refused else {
+ continue;
+ };
+ if disconnected {
+ // The pump is gone, so re-parking would hold the frame until
+ // process exit. Answer a client request; a prepare has nothing
+ // left to serve it.
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ "re-dispatch of parked partition frame refused: inbox
disconnected"
+ );
+ if message.header().command == Command2::Request
+ && let Ok(request) =
message.try_into_typed::<RequestHeader>()
{
- // Callers are synchronous (`apply_reconcile_ops`), so the
- // deny rides the pump's outbound lifecycle path instead of
- // an inline bus send.
self.stage_transient_deny(request.header());
}
+ continue;
+ }
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ passes,
+ "re-parking parked partition frame: inbox full"
+ );
+ refused_frames.push(ParkedFrame {
+ epoch: parked_epoch,
+ passes,
+ message,
+ });
+ }
+ if !refused_frames.is_empty() {
+ self.repark_partition_frames(namespace, refused_frames);
+ }
+ }
+
+ /// Put frames back under `namespace` after a refused re-dispatch, keeping
+ /// [`Self::parked_partition_bytes`] in step and arming the pump-side
retry.
+ ///
+ /// Deliberately not budget-checked: these bytes were already counted while
+ /// parked, so re-admitting them cannot grow the total past what it held a
+ /// moment ago, and shedding here would answer a frame the inbox merely
+ /// deferred.
+ ///
+ /// Arming [`Self::reparked_partition_namespaces`] is what makes the
deferral
+ /// a deferral. Every other exit from the park map is closed for a
+ /// materialised namespace: the reconciler sweep only ages one it has not
+ /// built, and `reconcile_additions` never stages a second `InsertOwned`
for
+ /// one already in `IggyPartitions`.
+ fn repark_partition_frames(&self, namespace: IggyNamespace, frames:
Vec<ParkedFrame>) {
+ let restored: usize = frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.pending_partition_frames
+ .borrow_mut()
+ .entry(namespace)
+ .or_default()
+ .extend(frames);
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_add(restored));
+ self.reparked_partition_namespaces
+ .borrow_mut()
+ .insert(namespace);
+ }
+
+ /// Age every frame parked under `namespace` by one reconciler pass and
+ /// retire the ones that have outlived [`MAX_PARKED_PASSES`]. Returns the
+ /// number retired -- client requests answered plus prepares destroyed, the
+ /// latter counted under
+ /// `frame_drops_total{variant=partition,reason=park_dropped}`.
+ ///
+ /// The bound is in passes rather than wall-clock so the simulator's
virtual
+ /// clock governs it like everything else. It exists to bound residency: a
+ /// namespace can stay un-materialised indefinitely, and the buffer must
not
+ /// grow with it. See [`ParkedFrame::passes`] for why this is not also
+ /// staleness protection -- the SDK replays the same request, so answering
a
+ /// late frame does not prevent its operation from being applied late.
+ pub fn age_parked_partition_frames(&self, namespace: IggyNamespace) ->
usize {
+ let expired = {
+ let mut pending = self.pending_partition_frames.borrow_mut();
+ let Some(frames) = pending.get_mut(&namespace) else {
+ return 0;
+ };
+ for frame in frames.iter_mut() {
+ frame.passes += 1;
+ }
+ let expired: Vec<ParkedFrame> = frames
+ .extract_if(.., |frame| frame.passes > MAX_PARKED_PASSES)
+ .collect();
+ if frames.is_empty() {
+ pending.remove(&namespace);
+ }
+ let freed: usize = expired
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_sub(freed));
+ expired
+ };
+ let count = expired.len();
+ if count > 0 {
+ let mut answered = 0;
+ for frame in expired {
+ if self.deny_parked_client_request(frame) {
+ answered += 1;
+ } else {
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::metrics::frame_drop_reason::PARK_DROPPED,
+ );
+ }
}
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ answered,
+ dropped = count - answered,
+ "retiring parked partition frames that outlived their
admission window"
+ );
}
+ count
+ }
+
+ /// How many frames are parked under `namespace`. Bounded by
+ /// `MAX_PARKED_PER_NAMESPACE`; a shed frame must never grow it past that.
+ ///
+ /// Test/simulator accessor: nothing in production branches on a
per-namespace
+ /// park depth, and gating keeps it that way.
+ #[cfg(any(test, feature = "simulator"))]
+ #[must_use]
+ pub fn parked_frame_count(&self, namespace: IggyNamespace) -> usize {
+ self.pending_partition_frames
+ .borrow()
+ .get(&namespace)
+ .map_or(0, Vec::len)
+ }
+
+ /// Answer every frame parked under `namespace` and drop the entry, without
+ /// touching the routing table. Used by the reconciler for a namespace it
has
+ /// given up on materialising this pass.
+ pub fn reclaim_parked_partition_frames(&self, namespace: IggyNamespace) {
Review Comment:
pure alias of `discard_parked_partition_frames` - make that one pub and
delete this; the park vocabulary is already at park / shed / repark /
redispatch / age / retire / discard / reclaim.
##########
core/shard/src/lib.rs:
##########
@@ -995,9 +996,50 @@ where
/// materialised the namespace (post-`CreateTopic` convergence window).
/// Parked here instead of dropped -- there is no consensus retransmit
/// driver in production yet -- and re-dispatched when the matching
- /// `ReconcileOp::InsertOwned` lands. Bounded per namespace; overflow
- /// drops the frame (at-least-once: client/primary retries recover).
- pending_partition_frames: RefCell<HashMap<IggyNamespace,
Vec<Message<GenericHeader>>>>,
+ /// `ReconcileOp::InsertOwned` lands with the epoch they were stamped
+ /// against. Bounded per namespace; a full buffer sheds via
+ /// [`ParkOutcome::Overflow`] so the caller can still answer.
+ ///
+ /// An entry only drains when the namespace materialises or leaves
committed
+ /// metadata, so the reconciler reclaims the ones that will do neither --
see
+ /// `partition_reconciler::reconcile_parked_frames`. Without that sweep a
+ /// namespace whose build keeps failing would hold its frames for the
process
+ /// lifetime while every client waited out its read timeout.
+ ///
+ /// [`BTreeMap`], not `HashMap`: [`Self::parked_namespaces`] feeds the
+ /// reconciler sweep, which answers frames in the order it walks them.
+ /// `std::collections::HashMap` seeds its hasher per process, so iteration
+ /// order would vary run to run for identical committed state, making the
+ /// simulator's deny ordering unreproducible for a fixed seed -- the same
+ /// hazard `router.rs` documents as its reason for `select_biased!`.
+ pending_partition_frames: RefCell<BTreeMap<IggyNamespace,
Vec<ParkedFrame>>>,
+
+ /// Running sum of [`parked_footprint`] over every frame in
+ /// [`Self::pending_partition_frames`], maintained at each mutation site.
+ ///
+ /// Recomputing it per arriving frame is O(all parked frames): the budget
+ /// admits ~262k entries at 256 bytes each, so filling the buffer would
cost
Review Comment:
math is off here: the budget is charged on `parked_footprint`, which floors
at 4096, so the ceiling is 16 MiB / 4 KiB = 4096 entries and the worst-case
fill is around 8.4M visits - not 262k entries / 10^10. (262144 is 64 MiB / 256,
looks keyed off `MAX_MESSAGE_SIZE` instead of `MAX_PARKED_BYTES`; even on
logical bytes 16 MiB / 256 is 65536.) the conclusion stands, the running total
is the right call.
##########
core/server-ng/src/partition_reconciler.rs:
##########
@@ -280,6 +344,9 @@ struct PassCounters {
/// blocked by a consumer barrier or by a rejoin whose offsets land via
/// journal repair, and neither unblocking bumps `Streams::revision`.
trims_pending: usize,
+ /// Namespaces whose parked frames were answered because this shard is not
Review Comment:
doc matches 2 of the 4 bump sites - the aged branches below also count
namespaces that are materialised or still building, not just ones this shard
"is not going to materialise". and "answered" is wrong for prepares, which are
destroyed with no reply; that answered/destroyed conflation makes the loss
paths easy to misread.
##########
core/shard/src/metrics.rs:
##########
@@ -94,16 +113,26 @@ pub mod frame_drop_variant {
/// is the receiver-side equivalent: the frame arrived at the owning shard
/// but the local registry refused it. `MISROUTED` ticks when the pump
/// receives a Consensus frame whose target shard is not `self.id`.
+/// `PARK_OVERFLOW` ticks when a partition frame arrives for a namespace this
+/// shard has not materialised and the per-namespace park buffer is already at
+/// its cap, so the frame is shed with no reply. `PARK_DROPPED` ticks when a
+/// frame that did park leaves the buffer without being served: it outlived
+/// `MAX_PARKED_PASSES`, or its namespace was torn down. A client request also
+/// bumps `partition_requests_denied_transient_total` there, since it gets a
+/// reply; replicated traffic has nobody to answer, so this counter is the only
+/// record that the op was destroyed.
pub mod frame_drop_reason {
pub const FULL: &str = "full";
pub const DISCONNECTED: &str = "disconnected";
pub const UNROUTABLE: &str = "unroutable";
pub const DELIVERY_FAILED: &str = "delivery_failed";
pub const MISROUTED: &str = "misrouted";
+ pub const PARK_OVERFLOW: &str = "park_overflow";
+ pub const PARK_DROPPED: &str = "park_dropped";
}
const VARIANT_COUNT: usize = 7;
-const REASON_COUNT: usize = 5;
+const REASON_COUNT: usize = 7;
Review Comment:
5 -> 7 mints the full 7x7 counter cross product per shard; `park_overflow` /
`park_dropped` are only ever bumped with `variant=partition`, so 12 pairs are
unreachable. free today with no registry - noting for when scraping lands.
##########
core/shard/src/lib.rs:
##########
@@ -1841,23 +2331,74 @@ where
if partitions.contains(&namespace) {
return ParkOutcome::Deliver(message);
}
+ // Read the committed revision before taking the borrow below: the
frame
+ // is stamped with the incarnation it was addressed to, so a later
drain
+ // can tell it apart from a same-key replacement.
+ let epoch = self
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .created_revision_for_namespace(namespace);
+ let frame_cost = parked_footprint(message.as_slice().len());
let mut pending = self.pending_partition_frames.borrow_mut();
- let parked = pending.entry(namespace).or_default();
- if parked.len() >= MAX_PARKED_PER_NAMESPACE {
+ let parked_bytes = self.parked_partition_bytes.get();
+ // Read the entry without `entry().or_default()`: inserting first would
+ // leave an empty Vec behind on the overflow path below, which reads
as a
+ // parked namespace to the reconciler sweep and its fast-skip guard.
+ let existing = pending.get(&namespace);
+ let parked_len = existing.map_or(0, Vec::len);
+ let namespace_bytes: usize = existing.map_or(0, |frames| {
+ frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum()
+ });
+ // The per-namespace byte cap is waived for the first frame, or a frame
+ // bigger than the cap could never park at all; see
+ // [`MAX_PARKED_BYTES_PER_NAMESPACE`]. The shard-wide budget is not
+ // waived, so residency is unchanged.
+ let over_namespace_budget = parked_len > 0
+ && namespace_bytes.saturating_add(frame_cost) >
MAX_PARKED_BYTES_PER_NAMESPACE;
+ if parked_len >= MAX_PARKED_PER_NAMESPACE
+ || over_namespace_budget
+ || parked_bytes.saturating_add(frame_cost) > MAX_PARKED_BYTES
+ {
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::metrics::frame_drop_reason::PARK_OVERFLOW,
+ );
+ // Shedding drops a frame, so it is logged at `warn` like every
other
+ // drop site: the counter is not registered with a scrape endpoint
yet
+ // (see the TODO in `crate::metrics`), so these logs are the only
+ // alertable signal. Rate-limited by the buffer being full -- once
it
Review Comment:
"rate-limited by the buffer being full" is not rate limiting - the full
buffer is the trigger, and this emits one formatted warn per arriving frame
while the map borrow is held. with the per-namespace cap biting at 4 ordinary 1
MiB frames the storm is easy to reach, and the non-blocking appender is lossy
under pressure, so it can drop unrelated lines. log the empty -> full
transition once and let the counter carry the rest.
##########
core/shard/src/lib.rs:
##########
@@ -1781,29 +1958,340 @@ where
/// decode replies in lockstep, so silence wedges the connection until the
/// SDK's response read-timeout.
fn discard_parked_partition_frames(&self, namespace: IggyNamespace) {
- if let Some(frames) = self
- .pending_partition_frames
- .borrow_mut()
- .remove(&namespace)
+ // Bound the borrow to this statement: the guard in an `if let`
+ // scrutinee otherwise lives to the end of the then-block, holding a
+ // shard-global map locked across the outbound sends below.
+ let parked = self.take_parked_partition_frames(namespace);
+ if let Some(frames) = parked
&& !frames.is_empty()
{
+ let total = frames.len();
+ let mut answered = 0;
+ for frame in frames {
+ if self.deny_parked_client_request(frame) {
+ answered += 1;
+ } else {
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::metrics::frame_drop_reason::PARK_DROPPED,
+ );
+ }
+ }
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
- count = frames.len(),
+ answered,
+ dropped = total - answered,
"discarding parked partition frames for removed namespace"
);
- for frame in frames {
- if frame.header().command == Command2::Request
- && let Ok(request) =
frame.try_into_typed::<RequestHeader>()
+ }
+ }
+
+ /// Remove a namespace's park entry, debiting its bytes from
+ /// [`Self::parked_partition_bytes`]. The single place entries leave the
map,
+ /// so the running total and the pending-retry set cannot drift out of step
+ /// with it.
+ fn take_parked_partition_frames(&self, namespace: IggyNamespace) ->
Option<Vec<ParkedFrame>> {
+ self.reparked_partition_namespaces
+ .borrow_mut()
+ .remove(&namespace);
+ let frames = self
+ .pending_partition_frames
+ .borrow_mut()
+ .remove(&namespace)?;
+ let freed: usize = frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_sub(freed));
+ Some(frames)
+ }
+
+ /// Whether any frame is parked. Cheap enough for the reconciler's per-tick
+ /// fast-skip guard: a non-empty buffer means the shard is by definition
not
+ /// converged, so the skip must not fire.
+ #[must_use]
+ pub const fn has_parked_partition_frames(&self) -> bool {
+ self.parked_partition_bytes.get() > 0
+ }
+
+ /// Namespaces currently holding parked frames. The reconciler pairs this
+ /// against committed metadata to find the ones that will never
materialise,
+ /// which no `ConfirmRemove` / `RemoveRouted` can reach: a namespace that
was
+ /// never built is in neither `IggyPartitions` nor the routing table, so
+ /// nothing else names it.
+ #[must_use]
+ pub fn parked_namespaces(&self) -> Vec<IggyNamespace> {
+ self.pending_partition_frames
+ .borrow()
+ .keys()
+ .copied()
+ .collect()
+ }
+
+ /// Re-queue the frames parked for `namespace` now that its partition
exists
+ /// at `epoch`, onto this shard's own inbox so the pump serves them after
the
+ /// current drain.
+ ///
+ /// A frame stamped with a DIFFERENT incarnation never makes it back: the
+ /// namespace is byte-identical across incarnations, so serving it would
land
+ /// a dead topic's write inside the topic that recycled its keys, and the
+ /// downstream fence cannot see it -- that compares the committed revision
+ /// against the routing row, both of which now describe THIS incarnation.
+ ///
+ /// An UNSTAMPED frame (`epoch: None`) is served. `None` means this node's
+ /// metadata held no committed partition for the namespace when the frame
+ /// arrived, which on a metadata-lagging backup is the ordinary case the
park
+ /// buffer exists to absorb -- the partition primary materialises and
+ /// replicates as soon as its own metadata commits, well before a lagging
+ /// backup applies the same commit. Treating that as "prior incarnation"
+ /// destroys live traffic: a replicated prepare has no client to answer, so
+ /// it would be dropped with no recovery until an unrelated view change.
+ /// The residual is unchanged from before the stamp existed -- a frame
parked
+ /// while the namespace was absent, then recreated under a new incarnation,
+ /// is served against the replacement -- and closing it needs a wire-level
+ /// discriminator (see the `TODO(krishna)` in
+ /// `partition_reconciler`'s module docs), not a `None`-means-stale rule.
+ ///
+ /// A frame the inbox refuses is re-parked, not answered. Re-queuing
appends,
+ /// so a pass that materialises many namespaces at once can overrun the
inbox;
+ /// staging a deny there is futile because the deny rides that same sender
+ /// with no await in between, so nothing can have drained a slot.
+ ///
+ /// [`MAX_PARKED_PASSES`] does NOT bound a re-parked frame -- the
reconciler
+ /// sweep ages a namespace only while it is un-materialised, and by here it
+ /// is materialised. [`Self::repark_partition_frames`] arms the pump-side
+ /// retry instead, and the sweep's backstop for an inbox that never drains
is
+ /// `partition_reconciler::reconcile_parked_frames`, which now ages a
+ /// materialised namespace too.
+ fn redispatch_parked_frames(&self, namespace: IggyNamespace, epoch: u64)
+ where
+ B: MessageBus + 'static,
+ {
+ let Some(frames) = self.take_parked_partition_frames(namespace) else {
+ return;
+ };
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ count = frames.len(),
+ epoch,
+ "re-dispatching parked partition frames after materialisation"
+ );
+ let mut refused_frames: Vec<ParkedFrame> = Vec::new();
+ for frame in frames {
+ // Only a stamp that exists and disagrees is evidence of a prior
+ // incarnation; see this function's docs on why `None` is not.
+ if let Some(parked_epoch) = frame.epoch
+ && parked_epoch != epoch
+ {
+ self.reject_stale_parked_frame(namespace, epoch, frame);
+ continue;
+ }
+ let Some(sender) = self.senders.get(self.id as usize) else {
+ continue;
+ };
+ let passes = frame.passes;
+ let parked_epoch = frame.epoch;
+ let Err(error) = sender.try_send(ShardFrame::consensus(self.id,
frame.message)) else {
+ continue;
+ };
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::coordinator::classify_try_send_err(&error),
+ );
+ let (refused, disconnected) = match error {
+ TrySendError::Full(frame) => (frame, false),
+ TrySendError::Disconnected(frame) => (frame, true),
+ };
+ let ShardFrame::Consensus { message, .. } = refused else {
+ continue;
+ };
+ if disconnected {
+ // The pump is gone, so re-parking would hold the frame until
+ // process exit. Answer a client request; a prepare has nothing
+ // left to serve it.
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ "re-dispatch of parked partition frame refused: inbox
disconnected"
+ );
+ if message.header().command == Command2::Request
+ && let Ok(request) =
message.try_into_typed::<RequestHeader>()
{
- // Callers are synchronous (`apply_reconcile_ops`), so the
- // deny rides the pump's outbound lifecycle path instead of
- // an inline bus send.
self.stage_transient_deny(request.header());
}
+ continue;
+ }
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ passes,
+ "re-parking parked partition frame: inbox full"
+ );
+ refused_frames.push(ParkedFrame {
+ epoch: parked_epoch,
+ passes,
+ message,
+ });
+ }
+ if !refused_frames.is_empty() {
+ self.repark_partition_frames(namespace, refused_frames);
+ }
+ }
+
+ /// Put frames back under `namespace` after a refused re-dispatch, keeping
+ /// [`Self::parked_partition_bytes`] in step and arming the pump-side
retry.
+ ///
+ /// Deliberately not budget-checked: these bytes were already counted while
+ /// parked, so re-admitting them cannot grow the total past what it held a
+ /// moment ago, and shedding here would answer a frame the inbox merely
+ /// deferred.
+ ///
+ /// Arming [`Self::reparked_partition_namespaces`] is what makes the
deferral
+ /// a deferral. Every other exit from the park map is closed for a
+ /// materialised namespace: the reconciler sweep only ages one it has not
+ /// built, and `reconcile_additions` never stages a second `InsertOwned`
for
+ /// one already in `IggyPartitions`.
+ fn repark_partition_frames(&self, namespace: IggyNamespace, frames:
Vec<ParkedFrame>) {
+ let restored: usize = frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.pending_partition_frames
+ .borrow_mut()
+ .entry(namespace)
+ .or_default()
+ .extend(frames);
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_add(restored));
+ self.reparked_partition_namespaces
+ .borrow_mut()
+ .insert(namespace);
+ }
+
+ /// Age every frame parked under `namespace` by one reconciler pass and
+ /// retire the ones that have outlived [`MAX_PARKED_PASSES`]. Returns the
+ /// number retired -- client requests answered plus prepares destroyed, the
+ /// latter counted under
+ /// `frame_drops_total{variant=partition,reason=park_dropped}`.
+ ///
+ /// The bound is in passes rather than wall-clock so the simulator's
virtual
+ /// clock governs it like everything else. It exists to bound residency: a
+ /// namespace can stay un-materialised indefinitely, and the buffer must
not
+ /// grow with it. See [`ParkedFrame::passes`] for why this is not also
+ /// staleness protection -- the SDK replays the same request, so answering
a
+ /// late frame does not prevent its operation from being applied late.
+ pub fn age_parked_partition_frames(&self, namespace: IggyNamespace) ->
usize {
+ let expired = {
+ let mut pending = self.pending_partition_frames.borrow_mut();
+ let Some(frames) = pending.get_mut(&namespace) else {
+ return 0;
+ };
+ for frame in frames.iter_mut() {
+ frame.passes += 1;
+ }
+ let expired: Vec<ParkedFrame> = frames
+ .extract_if(.., |frame| frame.passes > MAX_PARKED_PASSES)
+ .collect();
+ if frames.is_empty() {
+ pending.remove(&namespace);
+ }
+ let freed: usize = expired
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_sub(freed));
+ expired
+ };
+ let count = expired.len();
+ if count > 0 {
+ let mut answered = 0;
+ for frame in expired {
+ if self.deny_parked_client_request(frame) {
+ answered += 1;
+ } else {
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::metrics::frame_drop_reason::PARK_DROPPED,
+ );
+ }
}
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ answered,
+ dropped = count - answered,
+ "retiring parked partition frames that outlived their
admission window"
+ );
}
+ count
+ }
+
+ /// How many frames are parked under `namespace`. Bounded by
+ /// `MAX_PARKED_PER_NAMESPACE`; a shed frame must never grow it past that.
+ ///
+ /// Test/simulator accessor: nothing in production branches on a
per-namespace
+ /// park depth, and gating keeps it that way.
+ #[cfg(any(test, feature = "simulator"))]
+ #[must_use]
+ pub fn parked_frame_count(&self, namespace: IggyNamespace) -> usize {
+ self.pending_partition_frames
+ .borrow()
+ .get(&namespace)
+ .map_or(0, Vec::len)
+ }
+
+ /// Answer every frame parked under `namespace` and drop the entry, without
+ /// touching the routing table. Used by the reconciler for a namespace it
has
+ /// given up on materialising this pass.
+ pub fn reclaim_parked_partition_frames(&self, namespace: IggyNamespace) {
+ self.discard_parked_partition_frames(namespace);
+ }
+
+ /// Retire a parked frame that will never be served: a client request gets
a
+ /// transient deny so it can re-issue, replicated traffic is destroyed.
+ /// Returns `true` when a reply was staged. Callers are synchronous
+ /// (`apply_reconcile_ops`, the reconciler sweep), so the deny rides the
+ /// pump's outbound lifecycle path instead of an inline bus send.
+ ///
+ /// A prepare gets no reply because there is no client on this node to send
+ /// one to, but "no reply" must not mean "no record": the primary only
+ /// retransmits an op that has not reached quorum, so a destroyed prepare
on
+ /// a lagging backup is invisible loss. The `false` return makes callers
+ /// count it under
`frame_drops_total{variant=partition,reason=park_dropped}`.
+ fn deny_parked_client_request(&self, frame: ParkedFrame) -> bool {
Review Comment:
returns true for any request even when nothing was staged:
`stage_transient_deny` returns `()` and silently bails when `senders[self.id]`
is missing, so callers log the frame as answered and skip the `park_dropped`
bump for a request that was destroyed unanswered - while the doc above says the
false return is exactly what makes callers count it. same count-before-delivery
pattern this PR carefully fixes in `deny_partition_request_transient` and in
`stage_transient_deny`'s own counter. make `stage_transient_deny` return bool
and propagate it.
##########
core/server-ng/src/partition_reconciler.rs:
##########
@@ -2104,4 +2423,918 @@ mod tests {
"survivor must take over the disconnected member's partitions"
);
}
+
+ /// A namespace deleted before its build finished is named by nothing: it
is
+ /// absent from `IggyPartitions`, so the removals pass sees no owned ghost,
+ /// and absent from `shards_table`, since the owner seeds a row only via
+ /// `InsertOwned`. Neither `ConfirmRemove` nor `RemoveRouted` can therefore
+ /// reach its parked frames, and without the sweep they are held for the
+ /// process lifetime while every waiting client burns its read timeout.
+ ///
+ /// Reclaim is via the age bound, not on sight of the namespace leaving the
+ /// target set: "absent from committed metadata" reads identically for a
+ /// deleted namespace and for one a metadata-lagging replica has not
applied
+ /// yet, so reclaiming on that would destroy live in-flight traffic. The
first
+ /// pass must therefore hold the frames, and a few passes later they are
gone.
+ #[compio::test]
+ async fn parked_frames_are_reclaimed_when_the_namespace_leaves_metadata() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-reclaim");
+ seed_topic(&mux, 2, 0, "topic-reclaim", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ // No pass yet, so the namespace is committed but unmaterialised.
+ park_one_request(&shard, ns).await;
+ assert_eq!(
+ shard.parked_namespaces(),
+ vec![ns],
+ "a request for an unmaterialised namespace must park"
+ );
+
+ // Delete before any pass builds it: the reconciler never materialises
+ // it, so nothing drains the entry the normal way.
+ seed_delete_topic(&shard.plane.metadata().mux_stm, 3, 0, 0);
+ reconcile_pass(&ctx).await;
+ assert_eq!(
+ shard.parked_frame_count(ns),
+ 1,
+ "the first pass must not destroy the frame: absence from the
target set \
+ is also what a not-yet-applied commit looks like"
+ );
+
+ // Every subsequent pass ages it, and the park buffer keeps defeating
the
+ // revision fast-skip until it drains.
+ for _ in 0..=PARK_MAX_PASSES {
+ reconcile_pass(&ctx).await;
+ }
+
+ assert!(
+ shard.parked_namespaces().is_empty(),
+ "frames for a namespace that left metadata must be answered and
reclaimed"
+ );
+ assert_eq!(
+ drain_staged_client_sends(&inbox),
+ 1,
+ "and the waiting client must get a retriable answer"
+ );
+ }
+
+ /// A frame parked before this node's metadata knew the namespace carries
no
+ /// epoch stamp, and `None` must NOT read as "prior incarnation": on a
+ /// metadata-lagging replica it is the ordinary case, since the partition
+ /// primary materialises and replicates as soon as its own metadata
commits.
+ /// Rejecting it destroys live traffic -- silently for a replicated
prepare,
+ /// which has no client to answer -- and the pre-stamp code served it.
+ #[compio::test]
+ async fn unstamped_parked_frame_is_served_not_rejected_as_stale() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-unstamped");
+ seed_topic(&mux, 2, 0, "topic-known", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+ // Topic slab 1 does not exist yet, so there is no committed
+ // `created_revision` to stamp: the frame parks with `epoch: None`.
+ let unknown = IggyNamespace::new(0, 1, 0);
+ park_one_request(&shard, unknown).await;
+ assert_eq!(
+ shard.parked_frame_count(unknown),
+ 1,
+ "a request for a namespace this node has not applied must park"
+ );
+
+ // The commit this node was lagging behind now lands, and the pass
+ // materialises the namespace.
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 3,
+ 0,
+ "topic-late",
+ vec![assignment(0, 2)],
+ );
+ reconcile_pass(&ctx).await;
+
+ assert_eq!(
+ shard.parked_frame_count(unknown),
+ 0,
+ "materialisation must drain the park entry"
+ );
+ let (served, answered) = drain_inbox(&inbox);
+ assert_eq!(
+ served, 1,
+ "the unstamped frame must be re-dispatched onto the pump, not
rejected"
+ );
+ assert_eq!(
+ answered, 0,
+ "and it must not be answered with a deny instead of served"
+ );
+ assert_eq!(
+ shard.metrics().partition_frames_rejected_stale_value(),
+ 0,
+ "an absent stamp is not evidence of a prior incarnation"
+ );
+ }
+
+ /// The shard-wide byte budget is a running total maintained at each
mutation
+ /// site rather than rescanned per arriving frame. A total that fails to
debit
+ /// on drain silently wedges the budget: the shard would shed every
namespace's
+ /// frames while nothing is actually parked. Exercise each way frames
leave --
+ /// re-dispatch on materialisation, the age bound, and reclaim -- and
assert the
+ /// total returns to empty.
+ #[compio::test]
+ async fn park_byte_total_returns_to_zero_on_every_drain_path() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-bytes");
+ seed_topic(&mux, 2, 0, "topic-bytes", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 16);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+ // Drain path 1: materialisation re-dispatches.
+ let late = IggyNamespace::new(0, 1, 0);
+ park_one_request(&shard, late).await;
+ assert!(shard.has_parked_partition_frames());
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 3,
+ 0,
+ "topic-bytes-late",
+ vec![assignment(0, 2)],
+ );
+ reconcile_pass(&ctx).await;
+ assert!(
+ !shard.has_parked_partition_frames(),
+ "re-dispatch must debit the parked-byte total"
+ );
+
+ // Drain path 2: the age bound answers the frame.
+ let never = IggyNamespace::new(0, 9, 0);
+ park_one_request(&shard, never).await;
+ assert!(shard.has_parked_partition_frames());
+ for _ in 0..=PARK_MAX_PASSES {
+ shard.age_parked_partition_frames(never);
+ }
+ assert!(
+ !shard.has_parked_partition_frames(),
+ "aging out must debit the parked-byte total"
+ );
+
+ // Drain path 3: an explicit reclaim.
+ park_one_request(&shard, never).await;
+ assert!(shard.has_parked_partition_frames());
+ shard.reclaim_parked_partition_frames(never);
+ assert!(
+ !shard.has_parked_partition_frames(),
+ "reclaim must debit the parked-byte total"
+ );
+
+ drop(inbox);
+ }
+
+ /// The replicated-prepare shape, which no other test covers and where both
+ /// park critical are worst: a prepare has no client, so
`deny_parked_frame`
+ /// no-ops on it and anything that discards it loses committed data
silently,
+ /// with no normal-status repair driver to refetch it.
+ ///
+ /// A backup receives the prepare before its own metadata commits (so the
frame
+ /// parks unstamped), then applies the commit and materialises. The
prepare must
+ /// be re-dispatched, not rejected as a prior incarnation.
+ #[compio::test]
+ async fn unstamped_parked_prepare_is_served_after_materialisation() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-prepare");
+ seed_topic(&mux, 2, 0, "topic-known", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+ // The primary replicates ahead of this node's metadata: topic slab 1
is
+ // not committed here yet, so the prepare parks with `epoch: None`.
+ let lagging = IggyNamespace::new(0, 1, 0);
+ park_one_prepare(&shard, lagging, 7).await;
+ assert_eq!(
+ shard.parked_frame_count(lagging),
+ 1,
+ "a prepare for a namespace this backup has not applied must park"
+ );
+
+ // The metadata commit catches up and the pass materialises the
namespace.
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 3,
+ 0,
+ "topic-late",
+ vec![assignment(0, 2)],
+ );
+ reconcile_pass(&ctx).await;
+
+ let (served, answered) = drain_inbox(&inbox);
+ assert_eq!(
+ served, 1,
+ "the parked prepare must be re-dispatched; discarding it is silent
\
+ committed-data loss, since a prepare has no client to answer"
+ );
+ assert_eq!(answered, 0, "a prepare has no client deny to send");
+ assert_eq!(
+ shard.metrics().partition_frames_rejected_stale_value(),
+ 0,
+ "an unstamped prepare is not a prior incarnation"
+ );
+ }
+
+ /// A parked prepare whose stamp names a DIFFERENT incarnation must still
be
+ /// dropped: applying a dead topic's op into the topic that recycled its
slab
+ /// keys diverges this replica. This is the half of the fence that stays.
+ #[compio::test]
+ async fn stamped_parked_prepare_from_a_prior_incarnation_is_rejected() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-stale");
+ seed_topic(&mux, 2, 0, "topic-first", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ // Parked against the FIRST incarnation, so it carries that revision.
+ park_one_prepare(&shard, ns, 7).await;
+ assert_eq!(shard.parked_frame_count(ns), 1);
+
+ // Delete and recreate: same namespace keys, new committed revision.
+ seed_delete_topic(&shard.plane.metadata().mux_stm, 3, 0, 0);
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 4,
+ 0,
+ "topic-recreated",
+ vec![assignment(0, 2)],
+ );
+ reconcile_pass(&ctx).await;
+
+ let (served, _answered) = drain_inbox(&inbox);
+ assert_eq!(
+ served, 0,
+ "a prepare stamped with the dead incarnation must not be served
against \
+ its replacement"
+ );
+ assert_eq!(
+ shard.metrics().partition_frames_rejected_stale_value(),
+ 1,
+ "and the reject must be counted"
+ );
+ }
+
+ /// Parking does not bump `Streams::revision` and does not wake the
reconciler,
+ /// so a frame that parks in a converged steady state would be held for the
+ /// process lifetime if the revision fast-skip could still fire. A
non-empty
+ /// park buffer must therefore defeat the skip.
+ #[compio::test]
+ async fn park_buffer_defeats_the_revision_fast_skip() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-skip");
+ seed_topic(&mux, 2, 0, "topic-skip", vec![assignment(0, 1)]);
+
+ let (shard, _inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+ // Converge: the first pass materialises, the verify pass after it arms
+ // the skip (same sequence as
`reconcile_fast_skips_when_revision_unchanged`).
+ assert!(reconcile_once(&ctx).await, "first pass runs the full diff");
+ ctx.shard.apply_reconcile_ops();
+ assert!(
+ reconcile_once(&ctx).await,
+ "the verify pass after a working pass must still run"
+ );
+ ctx.shard.apply_reconcile_ops();
+ assert!(
+ !reconcile_once(&ctx).await,
+ "a converged pass with an unchanged revision must fast-skip"
+ );
+
+ // Park a frame for a namespace that is NOT materialised, without
touching
+ // the revision, and the skip must stop firing.
+ let unbuilt = IggyNamespace::new(0, 0, 7);
+ park_one_request(&shard, unbuilt).await;
+ assert!(
+ shard.has_parked_partition_frames(),
+ "the frame must be parked for this test to mean anything"
+ );
+ assert!(
+ reconcile_once(&ctx).await,
+ "a non-empty park buffer must defeat the fast-skip so the sweep
can run"
+ );
+ }
+
+ /// Same hole, reached the other way: the namespace stays committed but
+ /// `build_partition_fresh` keeps failing. The `FailureCause::Add` backoff
+ /// clamps at 60s, twice the client's read timeout, so holding the frames
+ /// cannot help - answer them and let the client re-issue.
+ #[compio::test]
+ async fn parked_frames_are_reclaimed_while_the_build_is_backed_off() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-backoff");
+ seed_topic(&mux, 2, 0, "topic-backoff", vec![assignment(0, 1)]);
+
+ let shard = build_test_shard(0, &config, mux);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ park_one_request(&shard, ns).await;
+ assert_eq!(shard.parked_namespaces(), vec![ns]);
+
+ // Stand in for a failed build (ENOSPC / EPERM): the additions pass
skips
+ // a backed-off namespace, so it stays committed and unmaterialised.
+ ctx.record_failure(ns, FailureCause::Add, Instant::now());
+ reconcile_pass(&ctx).await;
+
+ assert!(
+ !ctx.shard.plane.partitions().contains(&ns),
+ "a backed-off namespace must not have been built"
+ );
+ assert!(
+ shard.parked_namespaces().is_empty(),
+ "frames waiting on a backed-off build must be answered, not held"
+ );
+ }
+
+ /// Delete + recreate recycles the slab keys, so a frame parked against the
+ /// dead incarnation is byte-identical to one for its replacement. Draining
+ /// it into the new partition would land a dead topic's write inside the
live
+ /// one, and the incarnation fence cannot catch it: that compares the
+ /// committed revision against the routing row, both of which describe the
+ /// NEW incarnation. Only the epoch stamped at park time separates them.
+ #[compio::test]
+ async fn
parked_frames_from_a_prior_incarnation_are_not_served_by_its_replacement() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-epoch");
+ seed_topic(&mux, 2, 0, "topic-epoch", vec![assignment(0, 1)]);
+
+ let shard = build_test_shard(0, &config, mux);
+ let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ // Parked against the first incarnation, before any pass builds it.
+ park_one_request(&shard, ns).await;
+ assert_eq!(shard.parked_namespaces(), vec![ns]);
+ assert_eq!(
+ shard.metrics().partition_frames_rejected_stale_value(),
+ 0,
+ "nothing rejected yet"
+ );
+
+ // Recreate the same tuple. The namespace is unchanged; only
+ // `created_revision` moves.
+ seed_delete_topic(&shard.plane.metadata().mux_stm, 3, 0, 0);
+ seed_topic(
+ &shard.plane.metadata().mux_stm,
+ 4,
+ 0,
+ "topic-epoch",
+ vec![assignment(0, 1)],
+ );
+
+ // The pass builds the SECOND incarnation and drains the park entry.
+ reconcile_pass(&ctx).await;
+
+ assert!(
+ shard.plane.partitions().contains(&ns),
+ "the recreated incarnation must materialise"
+ );
+ assert!(
+ shard.parked_namespaces().is_empty(),
+ "the park entry must be drained by the materialisation"
+ );
+ assert_eq!(
+ shard.metrics().partition_frames_rejected_stale_value(),
+ 1,
+ "the frame stamped with the dead incarnation must be rejected, not
\
+ re-dispatched into its replacement"
+ );
+ }
+
+ /// Past the per-namespace cap the frame is gone either way, but a client
+ /// request must still be answered: the transports decode replies in
+ /// lockstep, so a silent shed leaves the connection waiting out its full
+ /// response read-timeout.
+ ///
+ /// Driven against a registered in-process client, so the assertion is
that a
+ /// reply reached a waiter -- not that a counter moved. With no client on
the
+ /// bus every send fails as `ClientNotFound` and a counter bumped before
the
+ /// send reports an answer nobody received, which is the failure this test
+ /// exists to catch.
+ #[compio::test]
+ async fn park_overflow_answers_the_client_instead_of_shedding_silently() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-overflow");
+ seed_topic(&mux, 2, 0, "topic-overflow", vec![assignment(0, 1)]);
+
+ let shard = build_test_shard(0, &config, mux);
+ let ns = IggyNamespace::new(0, 0, 0);
+ let (_slot, reply_rx) = register_waiting_client(&shard);
+
+ // Fill the buffer to its cap, then one more.
+ for _ in 0..PARK_CAP {
+ park_one_request(&shard, ns).await;
+ }
+ assert_eq!(
+ park_overflow_count(&shard),
+ 0,
+ "everything up to the cap parks without shedding"
+ );
+
+ assert_eq!(
+ shard.metrics().partition_requests_denied_transient_value(),
+ 0,
+ "nothing has been answered yet; the parked frames are still
waiting"
+ );
+
+ park_one_request(&shard, ns).await;
+ assert_eq!(
+ park_overflow_count(&shard),
+ 1,
+ "the frame past the cap must be shed and counted, not parked"
+ );
+ assert_eq!(
+ shard.parked_frame_count(ns),
+ PARK_CAP,
+ "the shed frame must not have grown the buffer past its cap"
+ );
+ // The point of the fix: shedding is unavoidable at the cap, silence is
+ // not. Without the deny the connection waits out its whole response
+ // read-timeout on a frame that is already gone.
+ let reply = reply_rx
+ .await
+ .expect("the shed request must reach the waiting client");
+ assert_eq!(
+ reply_status(&reply),
+ iggy_common::IggyError::TransientNotAccepted.as_code(),
+ "the shed request must be answered with a retriable status"
+ );
+ assert_eq!(
+ shard.metrics().partition_requests_denied_transient_value(),
+ 1,
+ "and the counter must credit that delivered answer"
+ );
+ }
+
+ /// The counter must credit only denies the bus delivered. It previously
+ /// incremented before `send_to_client`, so a shard with no client
registered
+ /// still reported the request answered - which blinded
+ /// `park_overflow_answers_the_client_instead_of_shedding_silently`, the
one
+ /// test that asserts the client hears back.
+ #[compio::test]
+ async fn overflow_deny_is_not_counted_when_the_client_is_gone() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-overflow-gone");
+ seed_topic(&mux, 2, 0, "topic-overflow-gone", vec![assignment(0, 1)]);
+
+ // No client registered: every `send_to_client` fails `ClientNotFound`.
+ let shard = build_test_shard(0, &config, mux);
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ for _ in 0..=PARK_CAP {
+ park_one_request(&shard, ns).await;
+ }
+
+ assert_eq!(
+ park_overflow_count(&shard),
+ 1,
+ "the frame past the cap is still shed and counted"
+ );
+ assert_eq!(
+ shard.metrics().partition_requests_denied_transient_value(),
+ 0,
+ "a deny the bus could not deliver must not be counted as an answer"
+ );
+ assert_eq!(
+ shard.metrics().frame_drop_count(
+ shard::metrics::frame_drop_variant::PARTITION,
+ shard::metrics::frame_drop_reason::DELIVERY_FAILED,
+ ),
+ 1,
+ "it must be counted as an undelivered reply instead"
+ );
+ }
+
+ /// A parked prepare that ages out has no client to answer, so nothing is
+ /// staged and `partition_requests_denied_transient_total` stays put. The
op
+ /// is destroyed all the same - the primary retransmits only what has not
+ /// reached quorum - so `park_dropped` is the only record it existed.
+ #[compio::test]
+ async fn aged_out_prepare_is_counted_even_though_nobody_can_be_answered() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-prepare-age");
+ seed_topic(&mux, 2, 0, "topic-prepare-age", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ park_one_prepare(&shard, ns, 7).await;
+ for _ in 0..=PARK_MAX_PASSES {
+ shard.age_parked_partition_frames(ns);
+ }
+
+ assert_eq!(shard.parked_frame_count(ns), 0, "the prepare aged out");
+ assert_eq!(
+ drain_staged_client_sends(&inbox),
+ 0,
+ "a prepare has no client to answer"
+ );
+ assert_eq!(
+ shard.metrics().partition_requests_denied_transient_value(),
+ 0,
+ "and must not be reported as an answered request"
+ );
+ assert_eq!(
+ park_dropped_count(&shard),
+ 1,
+ "the destroyed op must leave a record; silence here is invisible
loss"
+ );
+ }
+
+ /// A frame larger than the per-namespace byte cap used to fail the check
+ /// even against an empty entry, so it could never park on any attempt.
For a
+ /// replicated prepare that is unrecoverable: `retransmit_targets` skips
an op
+ /// that already reached quorum, so the backup stays permanently short of
it.
+ #[compio::test]
+ async fn
a_frame_over_the_namespace_byte_cap_still_parks_into_an_empty_entry() {
+ const OVER_NAMESPACE_CAP: usize = 5 * 1024 * 1024;
+
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-oversize");
+ seed_topic(&mux, 2, 0, "topic-oversize", vec![assignment(0, 1)]);
+
+ let shard = build_test_shard(0, &config, mux);
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ shard
+ .on_message(build_partition_request_sized(ns, OVER_NAMESPACE_CAP))
+ .await;
+ assert_eq!(
+ shard.parked_frame_count(ns),
+ 1,
+ "the first frame of an empty entry must park regardless of the
per-namespace cap"
+ );
+ assert_eq!(
+ park_overflow_count(&shard),
+ 0,
+ "and must not be shed doing it"
+ );
+
+ // The waiver is for the first frame only; the cap still applies after.
+ shard
+ .on_message(build_partition_request_sized(ns, OVER_NAMESPACE_CAP))
+ .await;
+ assert_eq!(
+ shard.parked_frame_count(ns),
+ 1,
+ "a second oversize frame must be shed, or one namespace eats the
shard budget"
+ );
+ assert_eq!(park_overflow_count(&shard), 1);
+ }
+
+ /// A namespace whose build is still in flight keeps its frames -- but not
+ /// forever, or the park buffer grows with a namespace that never
materialises.
+ /// The bound is in reconciler passes so the simulator's virtual clock
governs
+ /// it.
+ ///
+ /// Driven through `age_parked_partition_frames` directly. The sweep calls
it
+ /// once per pass for a namespace still building, and that branch is the
only
+ /// way a committed, non-backed-off namespace reaches the bound - which a
unit
+ /// test cannot stage, since its build completes on the first pass.
+ ///
+ /// Uses a shard with a live inbox: the deny is staged onto the pump, so a
+ /// shard with no sender would report the frame answered while nothing was
+ /// ever handed anywhere.
+ #[compio::test]
+ async fn
parked_frames_are_answered_once_they_outlive_their_admission_window() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-age");
+ seed_topic(&mux, 2, 0, "topic-age", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ns = IggyNamespace::new(0, 0, 0);
+
+ park_one_request(&shard, ns).await;
+ assert_eq!(shard.parked_frame_count(ns), 1);
+
+ // Each pass ages the frame; it survives until it is over the bound.
+ for pass in 0..PARK_MAX_PASSES {
+ assert_eq!(
+ shard.age_parked_partition_frames(ns),
+ 0,
+ "pass {pass} is still inside the admission window"
+ );
+ assert_eq!(shard.parked_frame_count(ns), 1);
+ }
+ assert_eq!(
+ shard.age_parked_partition_frames(ns),
+ 1,
+ "the pass past the bound must answer the frame"
+ );
+ assert_eq!(shard.parked_frame_count(ns), 0);
+ assert_eq!(
+ drain_staged_client_sends(&inbox),
+ 1,
+ "the answer must actually reach the pump, not just the counter"
+ );
+ assert_eq!(
+ shard.metrics().partition_requests_denied_transient_value(),
+ 1,
+ "and it must be answered with a retriable status, not dropped"
+ );
+ }
+
+ /// The counter must credit only denies the pump accepted. It previously
+ /// incremented before the `try_send`, so a shard whose inbox refused the
frame
+ /// (or had no sender at all) still reported the client answered.
+ #[compio::test]
+ async fn transient_deny_is_not_counted_when_the_inbox_cannot_take_it() {
+ let tmp = TempDir::new().expect("tempdir for system path");
+ let config = test_config(&tmp);
+ let mux = TestMux::default();
+ seed_stream(&mux, 1, "stream-deny-drop");
+ seed_topic(&mux, 2, 0, "topic-deny-drop", vec![assignment(0, 1)]);
+
+ let (shard, inbox) = build_test_shard_with_inbox(0, &config, mux, 8);
+ let ns = IggyNamespace::new(0, 0, 0);
+ park_one_request(&shard, ns).await;
+
+ // Kill the pump side, so every staged frame is refused.
+ drop(inbox);
+
+ for _ in 0..=PARK_MAX_PASSES {
+ shard.age_parked_partition_frames(ns);
+ }
+
+ assert_eq!(shard.parked_frame_count(ns), 0, "the frame still ages
out");
+ assert_eq!(
+ shard.metrics().partition_requests_denied_transient_value(),
+ 0,
+ "a deny the inbox refused must not be counted as an answer"
+ );
+ }
+
+ /// The frame cap bounds count, not residency: `Message::into_generic` is a
+ /// retag, so each parked entry keeps its whole buffer -- up to 64 MiB.
With
+ /// only a frame cap, one namespace could pin 128 x 64 MiB and nothing
capped
+ /// the namespace count. The shard-wide byte budget is what actually bounds
+ /// it, so large frames must shed well before the frame cap.
+ #[compio::test]
+ async fn park_byte_budget_sheds_large_frames_before_the_frame_cap() {
Review Comment:
this trips the per-namespace cap, not the shard-wide budget it is named and
asserted for: all frames share one namespace, footprint per 1 MiB body is
1052672, so frame 4 sheds at 4210688 > `MAX_PARKED_BYTES_PER_NAMESPACE` (4 MiB)
- nowhere near 16 MiB. both asserts stay green for the wrong reason (4 < 128).
net effect: `MAX_PARKED_BYTES` is never the binding constraint in any test in
the repo, and its only single-namespace-reachable behaviour is the
first-frame-waiver path where the >16 MiB gap lives.
to actually bind the shard-wide budget: 5 namespaces x 3 frames all fit
(15790080), then the first frame into a 6th namespace crosses 16 MiB while
per-namespace-waived - unambiguously the shard-wide shed. and rename this to
the cap it currently proves.
##########
core/shard/src/lib.rs:
##########
@@ -1781,29 +1958,340 @@ where
/// decode replies in lockstep, so silence wedges the connection until the
/// SDK's response read-timeout.
fn discard_parked_partition_frames(&self, namespace: IggyNamespace) {
- if let Some(frames) = self
- .pending_partition_frames
- .borrow_mut()
- .remove(&namespace)
+ // Bound the borrow to this statement: the guard in an `if let`
+ // scrutinee otherwise lives to the end of the then-block, holding a
+ // shard-global map locked across the outbound sends below.
+ let parked = self.take_parked_partition_frames(namespace);
+ if let Some(frames) = parked
&& !frames.is_empty()
{
+ let total = frames.len();
+ let mut answered = 0;
+ for frame in frames {
+ if self.deny_parked_client_request(frame) {
+ answered += 1;
+ } else {
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::metrics::frame_drop_reason::PARK_DROPPED,
+ );
+ }
+ }
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
- count = frames.len(),
+ answered,
+ dropped = total - answered,
"discarding parked partition frames for removed namespace"
);
- for frame in frames {
- if frame.header().command == Command2::Request
- && let Ok(request) =
frame.try_into_typed::<RequestHeader>()
+ }
+ }
+
+ /// Remove a namespace's park entry, debiting its bytes from
+ /// [`Self::parked_partition_bytes`]. The single place entries leave the
map,
+ /// so the running total and the pending-retry set cannot drift out of step
+ /// with it.
+ fn take_parked_partition_frames(&self, namespace: IggyNamespace) ->
Option<Vec<ParkedFrame>> {
+ self.reparked_partition_namespaces
+ .borrow_mut()
+ .remove(&namespace);
+ let frames = self
+ .pending_partition_frames
+ .borrow_mut()
+ .remove(&namespace)?;
+ let freed: usize = frames
+ .iter()
+ .map(|frame| parked_footprint(frame.message.as_slice().len()))
+ .sum();
+ self.parked_partition_bytes
+ .set(self.parked_partition_bytes.get().saturating_sub(freed));
+ Some(frames)
+ }
+
+ /// Whether any frame is parked. Cheap enough for the reconciler's per-tick
+ /// fast-skip guard: a non-empty buffer means the shard is by definition
not
+ /// converged, so the skip must not fire.
+ #[must_use]
+ pub const fn has_parked_partition_frames(&self) -> bool {
+ self.parked_partition_bytes.get() > 0
+ }
+
+ /// Namespaces currently holding parked frames. The reconciler pairs this
+ /// against committed metadata to find the ones that will never
materialise,
+ /// which no `ConfirmRemove` / `RemoveRouted` can reach: a namespace that
was
+ /// never built is in neither `IggyPartitions` nor the routing table, so
+ /// nothing else names it.
+ #[must_use]
+ pub fn parked_namespaces(&self) -> Vec<IggyNamespace> {
+ self.pending_partition_frames
+ .borrow()
+ .keys()
+ .copied()
+ .collect()
+ }
+
+ /// Re-queue the frames parked for `namespace` now that its partition
exists
+ /// at `epoch`, onto this shard's own inbox so the pump serves them after
the
+ /// current drain.
+ ///
+ /// A frame stamped with a DIFFERENT incarnation never makes it back: the
+ /// namespace is byte-identical across incarnations, so serving it would
land
+ /// a dead topic's write inside the topic that recycled its keys, and the
+ /// downstream fence cannot see it -- that compares the committed revision
+ /// against the routing row, both of which now describe THIS incarnation.
+ ///
+ /// An UNSTAMPED frame (`epoch: None`) is served. `None` means this node's
+ /// metadata held no committed partition for the namespace when the frame
+ /// arrived, which on a metadata-lagging backup is the ordinary case the
park
+ /// buffer exists to absorb -- the partition primary materialises and
+ /// replicates as soon as its own metadata commits, well before a lagging
+ /// backup applies the same commit. Treating that as "prior incarnation"
+ /// destroys live traffic: a replicated prepare has no client to answer, so
+ /// it would be dropped with no recovery until an unrelated view change.
+ /// The residual is unchanged from before the stamp existed -- a frame
parked
+ /// while the namespace was absent, then recreated under a new incarnation,
+ /// is served against the replacement -- and closing it needs a wire-level
+ /// discriminator (see the `TODO(krishna)` in
+ /// `partition_reconciler`'s module docs), not a `None`-means-stale rule.
+ ///
+ /// A frame the inbox refuses is re-parked, not answered. Re-queuing
appends,
+ /// so a pass that materialises many namespaces at once can overrun the
inbox;
+ /// staging a deny there is futile because the deny rides that same sender
+ /// with no await in between, so nothing can have drained a slot.
+ ///
+ /// [`MAX_PARKED_PASSES`] does NOT bound a re-parked frame -- the
reconciler
+ /// sweep ages a namespace only while it is un-materialised, and by here it
+ /// is materialised. [`Self::repark_partition_frames`] arms the pump-side
+ /// retry instead, and the sweep's backstop for an inbox that never drains
is
+ /// `partition_reconciler::reconcile_parked_frames`, which now ages a
+ /// materialised namespace too.
+ fn redispatch_parked_frames(&self, namespace: IggyNamespace, epoch: u64)
+ where
+ B: MessageBus + 'static,
+ {
+ let Some(frames) = self.take_parked_partition_frames(namespace) else {
+ return;
+ };
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ count = frames.len(),
+ epoch,
+ "re-dispatching parked partition frames after materialisation"
+ );
+ let mut refused_frames: Vec<ParkedFrame> = Vec::new();
+ for frame in frames {
+ // Only a stamp that exists and disagrees is evidence of a prior
+ // incarnation; see this function's docs on why `None` is not.
+ if let Some(parked_epoch) = frame.epoch
+ && parked_epoch != epoch
+ {
+ self.reject_stale_parked_frame(namespace, epoch, frame);
+ continue;
+ }
+ let Some(sender) = self.senders.get(self.id as usize) else {
+ continue;
+ };
+ let passes = frame.passes;
+ let parked_epoch = frame.epoch;
+ let Err(error) = sender.try_send(ShardFrame::consensus(self.id,
frame.message)) else {
+ continue;
+ };
+ self.metrics.record_frame_drop(
+ crate::metrics::frame_drop_variant::PARTITION,
+ crate::coordinator::classify_try_send_err(&error),
+ );
+ let (refused, disconnected) = match error {
+ TrySendError::Full(frame) => (frame, false),
+ TrySendError::Disconnected(frame) => (frame, true),
+ };
+ let ShardFrame::Consensus { message, .. } = refused else {
+ continue;
+ };
+ if disconnected {
+ // The pump is gone, so re-parking would hold the frame until
+ // process exit. Answer a client request; a prepare has nothing
+ // left to serve it.
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ "re-dispatch of parked partition frame refused: inbox
disconnected"
+ );
+ if message.header().command == Command2::Request
+ && let Ok(request) =
message.try_into_typed::<RequestHeader>()
{
- // Callers are synchronous (`apply_reconcile_ops`), so the
- // deny rides the pump's outbound lifecycle path instead of
- // an inline bus send.
self.stage_transient_deny(request.header());
}
+ continue;
+ }
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ passes,
+ "re-parking parked partition frame: inbox full"
+ );
+ refused_frames.push(ParkedFrame {
+ epoch: parked_epoch,
+ passes,
+ message,
+ });
+ }
+ if !refused_frames.is_empty() {
+ self.repark_partition_frames(namespace, refused_frames);
+ }
+ }
+
+ /// Put frames back under `namespace` after a refused re-dispatch, keeping
+ /// [`Self::parked_partition_bytes`] in step and arming the pump-side
retry.
+ ///
+ /// Deliberately not budget-checked: these bytes were already counted while
+ /// parked, so re-admitting them cannot grow the total past what it held a
+ /// moment ago, and shedding here would answer a frame the inbox merely
+ /// deferred.
+ ///
+ /// Arming [`Self::reparked_partition_namespaces`] is what makes the
deferral
+ /// a deferral. Every other exit from the park map is closed for a
+ /// materialised namespace: the reconciler sweep only ages one it has not
+ /// built, and `reconcile_additions` never stages a second `InsertOwned`
for
+ /// one already in `IggyPartitions`.
+ fn repark_partition_frames(&self, namespace: IggyNamespace, frames:
Vec<ParkedFrame>) {
Review Comment:
the per-namespace byte total is re-summed over the whole entry at four sites
(park admission, take, repark, age) while the shard-wide total is an
incremental cell. a map value of `ParkEntry { frames: Vec<ParkedFrame>, bytes:
usize }` kills the rescans, and reading emptiness from the map
(`!pending.borrow().is_empty()`) instead of the byte cell removes the
bytes-as-emptiness-oracle invariant - which would also make the "single place
entries leave the map" claim on `take_parked_partition_frames` true (aging
currently removes entries directly and never clears the retry set).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]