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

spetz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new ff2e7481c fix(shard): refuse a frame no consensus plane claims (#4103)
ff2e7481c is described below

commit ff2e7481cd66f70cfca7dcbbcdf4d7282a8b5738
Author: Krishna Vishal <[email protected]>
AuthorDate: Fri Sep 11 20:45:43 2026 +0530

    fix(shard): refuse a frame no consensus plane claims (#4103)
    
    Shard dispatch silently ignores requests that neither consensus plane
    accepts, leaving the client waiting until its read timeout.
    `DeleteSegments` and `NonReplicated` fall into this gap. Released builds
    avoid it because `classify` handles both before dispatch, as checked by
    `classify_pins_probe_order`.
    
    Operation routing is defined separately in `classify`, `route_typed`,
    and the two plane predicates. These definitions already differ, so a
    future operation or caller could reach the silent fallback.
    
    This change adds `is_plane_routable` checks to the request, prepare, and
    prepare-ack branches of `dispatch_message`:
    
    * Requests receive a permanent `InvalidCommand`; retrying cannot fix an
    unsupported operation.
    * Prepares and acks are dropped, counted as
    `frame_drops_total{variant=consensus,reason=unroutable}`, and logged
    with frame details.
    
    The checks stay inside those branches because other messages use
    `Reserved` and have dedicated handlers.
    
    An operation-coverage test checks every known code, rejects overlapping
    plane ownership, and lists the three excluded operations with their
    handling paths. The workload runner also checks `unroutable` and
    `misrouted` counters each tick, reporting the shard and reason
    immediately instead of waiting for the quiescence timeout.
---
 core/binary_protocol/src/consensus/operation.rs |  40 ++++++++
 core/server/src/dispatch/partition.rs           | 118 +++++++++++++++++++++++-
 core/shard/src/lib.rs                           | 105 ++++++++++++++++++++-
 core/shard/src/metrics.rs                       |  43 +++++++++
 core/simulator/src/workload/invariants.rs       |  30 ++++++
 5 files changed, 331 insertions(+), 5 deletions(-)

diff --git a/core/binary_protocol/src/consensus/operation.rs 
b/core/binary_protocol/src/consensus/operation.rs
index 95de1d4ee..8ead254e5 100644
--- a/core/binary_protocol/src/consensus/operation.rs
+++ b/core/binary_protocol/src/consensus/operation.rs
@@ -203,6 +203,14 @@ impl Operation {
         self.is_metadata() || matches!(self, Self::Register | Self::Logout)
     }
 
+    /// Whether a consensus plane claims this operation. Anything else the 
plane
+    /// chain drops in its `()` terminator, so entry paths must refuse it 
first.
+    #[must_use]
+    #[inline]
+    pub const fn is_plane_routable(&self) -> bool {
+        self.is_metadata_plane() || self.is_partition()
+    }
+
     /// Operations clients are allowed to send directly.
     #[must_use]
     #[inline]
@@ -368,4 +376,36 @@ mod tests {
         assert!(Operation::StoreConsumerOffset.is_partition());
         assert!(Operation::DeleteConsumerOffset.is_partition());
     }
+
+    /// Every operation belongs to exactly one plane, or to the short list
+    /// answered before the chain. Walks `is_known_code` so a new variant fails
+    /// here rather than on the first frame carrying it.
+    #[test]
+    fn unroutable_operations_are_listed() {
+        // Answered earlier: `Reserved` by `validate_request_fields`,
+        // `NonReplicated` by the reads router, `DeleteSegments` by resolution 
to
+        // `TruncatePartition` (`server::dispatch::classify`).
+        const UNROUTABLE: [Operation; 3] = [
+            Operation::Reserved,
+            Operation::NonReplicated,
+            Operation::DeleteSegments,
+        ];
+
+        for code in 0..=u8::MAX {
+            if !Operation::is_known_code(code) {
+                continue;
+            }
+            let operation: Operation = bytemuck::checked::cast(code);
+            assert!(
+                !(operation.is_metadata_plane() && operation.is_partition()),
+                "{operation:?} is claimed by both planes; the chain takes the 
first"
+            );
+            assert_eq!(
+                operation.is_plane_routable(),
+                !UNROUTABLE.contains(&operation),
+                "{operation:?}: is_plane_routable={} disagrees with the 
unroutable list",
+                operation.is_plane_routable(),
+            );
+        }
+    }
 }
diff --git a/core/server/src/dispatch/partition.rs 
b/core/server/src/dispatch/partition.rs
index de74599e0..dbed56cfa 100644
--- a/core/server/src/dispatch/partition.rs
+++ b/core/server/src/dispatch/partition.rs
@@ -1411,7 +1411,6 @@ mod tests {
     };
     #[cfg(target_os = "linux")]
     use consensus::Sequencer;
-    use iggy_binary_protocol::ReplyHeader;
     use 
iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
     use 
iggy_binary_protocol::requests::consumer_offsets::DeleteConsumerOffsetRequest;
     use iggy_binary_protocol::requests::messages::SendMessagesHeader;
@@ -1419,6 +1418,7 @@ mod tests {
     use iggy_binary_protocol::requests::topics::{
         CreateTopicRequest, CreateTopicWithAssignmentsRequest,
     };
+    use iggy_binary_protocol::{PrepareOkHeader, ReplyHeader};
     use iggy_binary_protocol::{WireName, WireOptions, WirePartitioning};
     use iggy_common::Identifier;
     use iggy_common::defaults::DEFAULT_ROOT_USER_ID;
@@ -1427,7 +1427,7 @@ mod tests {
     use partitions::{IggyPartitions, PartitionPathLayout, PartitionsConfig};
     use server_common::MessageBag;
     use server_common::sharding::ShardId;
-    use shard::metrics::ShardMetrics;
+    use shard::metrics::{ShardMetrics, frame_drop_reason, frame_drop_variant};
     use shard::shards_table::PapayaShardsTable;
     use shard::{
         LifecycleFrame, PartitionConsensusConfig, ReconcileOp, 
ReplicaTopology, ShardFrame,
@@ -2011,6 +2011,120 @@ mod tests {
         );
     }
 
+    /// A request no plane claims must be denied at the shard boundary, not 
left
+    /// to the chain terminator and the client's read-timeout. `DeleteSegments`
+    /// is the live example: dispatch resolves it to `TruncatePartition`, so it
+    /// satisfies neither plane predicate. The deny is permanent -- a transient
+    /// code is what the SDK replays.
+    #[compio::test]
+    async fn unroutable_operation_must_reply_denied_not_silence() {
+        const TRANSPORT: u128 = 91;
+        const SESSION: u64 = 1;
+        const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
+
+        let bus = SpyBus::default();
+        let metadata = IggyMetadata::new(None, None, None, None, 
TestMux::default(), None);
+        let partitions = IggyPartitions::new(
+            ShardId::new(0),
+            PartitionsConfig {
+                messages_required_to_save: 1,
+                size_of_messages_required_to_save: 
iggy_common::IggyByteSize::from(1024_u64),
+                validate_checksum: true,
+                segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
+                preallocate_segments: false,
+                encryptor: None,
+                path_layout: PartitionPathLayout::default(),
+            },
+        );
+        let shard = Rc::new(TestShard::without_inbox(
+            ShardIdentity::new(0, "unroutable-operation-test".to_string()),
+            bus.clone(),
+            metadata,
+            partitions,
+            PapayaShardsTable::new(),
+            PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1), 
bus.clone()),
+        ));
+
+        let request = request_message(Operation::DeleteSegments, TRANSPORT, 
SESSION, 1, &[]);
+        shard.on_message(MessageBag::Request(request)).await;
+
+        let replies = bus.client_replies.borrow();
+        assert_eq!(
+            replies.len(),
+            1,
+            "a request no plane claims must be answered, not absorbed in 
silence"
+        );
+        let (client, frame) = &replies[0];
+        assert_eq!(*client, TRANSPORT, "reply must target the request's 
client");
+        let status =
+            u32::from_le_bytes(frame[STATUS_OFFSET..STATUS_OFFSET + 
4].try_into().unwrap());
+        assert_eq!(
+            status,
+            IggyError::InvalidCommand.as_code(),
+            "the deny must be permanent; a transient status has the SDK replay 
it"
+        );
+    }
+
+    /// The replicated halves of the same hole. A prepare or an ack whose
+    /// operation no plane claims reaches the same terminator, and there is no
+    /// client to answer, so the counter and the log are the only record. 
Dropping
+    /// it is the only option: nothing journals or acks an operation no plane
+    /// owns.
+    #[compio::test]
+    async fn unroutable_replicated_frames_must_be_dropped_and_counted() {
+        const TRANSPORT: u128 = 91;
+
+        let bus = SpyBus::default();
+        let shard = Rc::new(test_shard(&bus, 0, 1, 1));
+        let unroutable_drops = || {
+            shard
+                .metrics()
+                .frame_drop_count(frame_drop_variant::CONSENSUS, 
frame_drop_reason::UNROUTABLE)
+        };
+
+        let prepare = prepare_message(Operation::DeleteSegments, TRANSPORT, 1, 
&[]);
+        shard.on_message(MessageBag::Prepare(prepare)).await;
+        assert_eq!(
+            unroutable_drops(),
+            1,
+            "a prepare no plane claims must be counted, not absorbed by the 
chain"
+        );
+
+        shard
+            .on_message(MessageBag::PrepareOk(prepare_ok_message(
+                Operation::DeleteSegments,
+                1,
+            )))
+            .await;
+        assert_eq!(
+            unroutable_drops(),
+            2,
+            "an ack no plane claims must be counted, not absorbed by the chain"
+        );
+
+        assert!(
+            bus.client_replies.borrow().is_empty(),
+            "replicated frames answer nobody on this node"
+        );
+    }
+
+    /// Bare `PrepareOk` for the routing guard: only `operation`, `group` and 
`op`
+    /// are read before the plane chain, so the rest stays zeroed.
+    fn prepare_ok_message(operation: Operation, op: u64) -> 
Message<PrepareOkHeader> {
+        let header_size = size_of::<PrepareOkHeader>();
+        let mut msg = Message::<PrepareOkHeader>::new(header_size);
+        let header = bytemuck::checked::try_from_bytes_mut::<PrepareOkHeader>(
+            &mut msg.as_mut_slice()[..header_size],
+        )
+        .expect("zeroed bytes form a valid PrepareOkHeader");
+        header.command = Command::PrepareOk;
+        header.size = u32::try_from(header_size).expect("ack size fits u32");
+        header.operation = operation;
+        header.op = op;
+        header.group = server_common::sharding::METADATA_GROUP;
+        msg
+    }
+
     /// A send parked for a namespace that is torn down before materialising
     /// (create -> delete before the reconciler's `InsertOwned`) is discarded
     /// on `ConfirmRemove`. The discard must stage the same retriable
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index ede938135..2a0be3f47 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -3053,7 +3053,7 @@ where
     /// [`Self::on_message`] carrying the park provenance of a frame the pump 
is
     /// re-delivering, so a second park keeps the stamp and age the first one
     /// derived instead of deriving them again against newer committed state.
-    #[allow(clippy::future_not_send)]
+    #[allow(clippy::future_not_send, clippy::too_many_lines)]
     async fn dispatch_message(&self, message: MessageBag, provenance: 
Option<ParkProvenance>)
     where
         B: MessageBus + 'static,
@@ -3070,6 +3070,12 @@ where
                     let header = request.header();
                     (header.operation, header.group)
                 };
+                // Ahead of the park and the incarnation fence, which both read
+                // the frame as a partition request.
+                if !routing.0.is_plane_routable() {
+                    self.deny_unroutable_request(request.header()).await;
+                    return;
+                }
                 match self
                     .park_if_unmaterialised(request, routing.0, routing.1, 
provenance, &mut None)
                 {
@@ -3098,8 +3104,17 @@ where
             MessageBag::Prepare(prepare) => {
                 let routing = {
                     let header = prepare.header();
-                    (header.operation, header.group)
+                    (header.operation, header.group, header.op)
                 };
+                if !routing.0.is_plane_routable() {
+                    self.drop_unroutable_replicated(
+                        Command::Prepare,
+                        routing.0,
+                        routing.1,
+                        routing.2,
+                    );
+                    return;
+                }
                 // A tombstoned prepare still flows to the plane: replicated
                 // traffic has no client awaiting a reply on this node, and
                 // the plane's own tombstone guard drops it.
@@ -3140,7 +3155,22 @@ where
                     ParkOutcome::Overflow(_) | ParkOutcome::Parked => {}
                 }
             }
-            MessageBag::PrepareOk(prepare_ok) => self.on_ack(prepare_ok).await,
+            MessageBag::PrepareOk(prepare_ok) => {
+                let routing = {
+                    let header = prepare_ok.header();
+                    (header.operation, header.group, header.op)
+                };
+                if !routing.0.is_plane_routable() {
+                    self.drop_unroutable_replicated(
+                        Command::PrepareOk,
+                        routing.0,
+                        routing.1,
+                        routing.2,
+                    );
+                    return;
+                }
+                self.on_ack(prepare_ok).await;
+            }
             MessageBag::StartViewChange(msg) => 
self.on_start_view_change(msg).await,
             MessageBag::DoViewChange(msg) => self.on_do_view_change(msg).await,
             MessageBag::StartView(msg) => self.on_start_view(msg).await,
@@ -3796,6 +3826,75 @@ where
         self.metrics.record_partition_request_denied_transient();
     }
 
+    /// Deny a request no consensus plane claims, and count the frame it drops.
+    /// `InvalidCommand` because no retry makes an operation routable. Counted 
as
+    /// a drop even though the client is answered: nothing was routed or
+    /// journaled, and `unroutable` is the counter a simulator run asserts on.
+    #[allow(clippy::future_not_send)]
+    async fn deny_unroutable_request(&self, request_header: 
&RoutedRequestHeader) {
+        self.metrics.record_frame_drop(
+            crate::metrics::frame_drop_variant::CONSENSUS,
+            crate::metrics::frame_drop_reason::UNROUTABLE,
+        );
+        tracing::error!(
+            shard = self.id,
+            client = request_header.client,
+            operation = ?request_header.operation,
+            namespace_raw = request_header.group,
+            "request operation is claimed by no consensus plane; denying it"
+        );
+        let reply = build_deny_reply_from_request_header(
+            request_header,
+            IggyError::InvalidCommand.as_code(),
+        );
+        if let Err(error) = self
+            .bus
+            .send_to_client(request_header.client, 
reply.into_generic().into_frozen())
+            .await
+        {
+            self.metrics.record_frame_drop(
+                crate::metrics::frame_drop_variant::CONSENSUS,
+                crate::metrics::frame_drop_reason::DELIVERY_FAILED,
+            );
+            tracing::warn!(
+                shard = self.id,
+                client = request_header.client,
+                operation = ?request_header.operation,
+                error = %error,
+                "failed to send deny for unroutable request"
+            );
+        }
+    }
+
+    /// Drop a replicated frame no consensus plane claims, and count it.
+    ///
+    /// No reply, unlike [`Self::deny_unroutable_request`]: a prepare or an ack
+    /// has no client waiting on this node. Terminal for the frame's group 
here,
+    /// as the unknown-discriminant drop in [`Self::dispatch`] is: nothing
+    /// journals or acks an operation no plane owns, so every later op in that
+    /// group waits behind the gap while quorum hides it. Nothing fences the
+    /// sending peer, so the counter and this log are the whole signal.
+    fn drop_unroutable_replicated(
+        &self,
+        command: Command,
+        operation: Operation,
+        namespace_raw: u64,
+        op: u64,
+    ) {
+        self.metrics.record_frame_drop(
+            crate::metrics::frame_drop_variant::CONSENSUS,
+            crate::metrics::frame_drop_reason::UNROUTABLE,
+        );
+        tracing::error!(
+            shard = self.id,
+            command = ?command,
+            operation = ?operation,
+            namespace_raw,
+            op,
+            "replicated frame operation is claimed by no consensus plane; 
dropping it"
+        );
+    }
+
     /// [`Self::deny_partition_request_transient`] for synchronous callers:
     /// hand the deny to this shard's own pump as a
     /// [`LifecycleFrame::ForwardClientSend`], whose handler performs the bus
diff --git a/core/shard/src/metrics.rs b/core/shard/src/metrics.rs
index 01569de1b..2ac87c6be 100644
--- a/core/shard/src/metrics.rs
+++ b/core/shard/src/metrics.rs
@@ -562,6 +562,25 @@ impl ShardMetrics {
             .sum()
     }
 
+    /// One `reason` summed over every variant, for the assertions that are 
about
+    /// the reason alone: `unroutable` and `misrouted` must stay at zero 
however
+    /// the drop was classified, while `full` and `disconnected` are what a
+    /// crashed inbox produces and cannot be asserted on. Listing variants at 
the
+    /// call site would let a new drop site under an unlisted one escape.
+    /// Unknown reasons read as zero, as in [`Self::frame_drop_count`].
+    #[cfg(any(test, feature = "simulator"))]
+    #[must_use]
+    pub fn frame_drop_count_for_reason(&self, reason: &'static str) -> u64 {
+        let Some(reason_idx) = reason_index(reason) else {
+            return 0;
+        };
+        self.cached_counters
+            .iter()
+            .filter_map(|variant| variant[reason_idx].get())
+            .map(prometheus_client::metrics::counter::Counter::get)
+            .sum()
+    }
+
     /// Snapshot of `partitions_materialised_total`. Test-only accessor;
     /// production scrape goes through the prometheus registry.
     #[cfg(test)]
@@ -844,6 +863,30 @@ mod tests {
         );
     }
 
+    #[test]
+    fn frame_drop_count_for_reason_sums_one_reason_across_variants() {
+        let metrics = ShardMetrics::for_shard();
+        metrics.record_frame_drop(frame_drop_variant::CONSENSUS, 
frame_drop_reason::UNROUTABLE);
+        metrics.record_frame_drop(frame_drop_variant::PARTITION, 
frame_drop_reason::UNROUTABLE);
+        metrics.record_frame_drop(frame_drop_variant::CONSENSUS, 
frame_drop_reason::FULL);
+
+        assert_eq!(
+            metrics.frame_drop_count_for_reason(frame_drop_reason::UNROUTABLE),
+            2,
+            "one reason sums across every variant that recorded it",
+        );
+        assert_eq!(
+            metrics.frame_drop_count_for_reason(frame_drop_reason::MISROUTED),
+            0,
+            "an unproduced reason reads as zero, not as the total",
+        );
+        assert_eq!(
+            metrics.frame_drops_value(),
+            3,
+            "the per-reason view must not disturb the total",
+        );
+    }
+
     #[test]
     fn persistence_scrape_distinguishes_retained_budget_from_wal_file_bytes() {
         let metrics = ShardMetrics::for_shard();
diff --git a/core/simulator/src/workload/invariants.rs 
b/core/simulator/src/workload/invariants.rs
index d2011bcb5..a51103e97 100644
--- a/core/simulator/src/workload/invariants.rs
+++ b/core/simulator/src/workload/invariants.rs
@@ -28,6 +28,7 @@ use crate::workload::{CLIENT_REQUEST_QUEUE_MAX, Workload};
 use crate::{CommitHoldKind, CommitPrefixHole, Simulator};
 use consensus::Consensus;
 use server_common::sharding::IggyNamespace;
+use shard::metrics::frame_drop_reason;
 use std::collections::HashMap;
 
 /// Ticks a `Normal` metadata primary may sit behind its own recovery barrier
@@ -38,6 +39,12 @@ use std::collections::HashMap;
 /// of slack: only a barrier nothing will ever lower trips it.
 const RECOVERY_BARRIER_WEDGE_TICKS: u32 = 2_000;
 
+/// Frame-drop reasons that name a bug rather than a fault. Crashes produce
+/// `full` / `disconnected` / `delivery_failed` routinely; nothing the injector
+/// does can misplace a frame.
+const BUG_ONLY_FRAME_DROP_REASONS: [&str; 2] =
+    [frame_drop_reason::UNROUTABLE, frame_drop_reason::MISROUTED];
+
 /// Ticks a replica may hold its commit walk below a MISSING op.
 ///
 /// A promotion holds here legitimately while the journal walk clears its apply
@@ -85,6 +92,7 @@ impl Invariants {
     ///
     /// Globally:
     /// - total in-flight requests stay within the per-client queue ceiling,
+    /// - no shard has shed a frame for an `unroutable` or `misrouted` reason,
     /// - live replicas agree on every committed metadata op they share, and 
the
     ///   committed chain stays hash-linked (see [`StateChecker`]).
     ///
@@ -145,6 +153,7 @@ impl Invariants {
              (client_count={}, queue_max={CLIENT_REQUEST_QUEUE_MAX}) 
(seed={seed:#x})",
             workload.options.client_count,
         );
+        assert_no_bug_frame_drops(sim, seed);
 
         self.state_checker.check(sim, seed);
     }
@@ -273,6 +282,27 @@ impl Invariants {
     }
 }
 
+/// Panic if any shard has shed a frame for a reason no fault can produce.
+///
+/// Every tick rather than at quiesce, crashed replicas included: a restart
+/// rebuilds the shard with zero counters, so a drop is only visible on the 
ticks
+/// between it and the next crash. Miss it and the run instead fails tens of
+/// thousands of ticks later as a drain that never completed.
+fn assert_no_bug_frame_drops(sim: &Simulator, seed: u64) {
+    for (replica_idx, replica) in sim.replicas.iter().enumerate() {
+        for (shard_idx, shard) in replica.shards.iter().enumerate() {
+            for reason in BUG_ONLY_FRAME_DROP_REASONS {
+                let dropped = 
shard.metrics().frame_drop_count_for_reason(reason);
+                assert_eq!(
+                    dropped, 0,
+                    "replica {replica_idx} shard {shard_idx} shed {dropped} 
frame(s) with \
+                     reason={reason}; no injected fault produces that 
(seed={seed:#x})"
+                );
+            }
+        }
+    }
+}
+
 /// Panic if `cur < prev`. Pure so the catch logic is unit-testable without a
 /// full simulator; shared by the `commit_offset` and `view` checks.
 fn assert_no_regression(

Reply via email to