This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 21fc4d849 feat(shard): split the inbox into consensus and client-reply
lanes (#3915)
21fc4d849 is described below
commit 21fc4d849178d85381d8d78716a1e58b8ee6e566
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Thu Aug 20 13:54:35 2026 +0200
feat(shard): split the inbox into consensus and client-reply lanes (#3915)
---
core/configs/src/server_config/sharding.rs | 46 ++++++----
core/server/config.toml | 20 +++--
core/server/src/bootstrap.rs | 21 ++++-
core/server/src/dispatch.rs | 132 +++++++++++++++++++++++++++-
core/server/src/partition_reconciler.rs | 48 ++++++----
core/server/src/server_error.rs | 2 +
core/shard/src/builder.rs | 9 +-
core/shard/src/coordinator.rs | 12 +--
core/shard/src/lib.rs | 135 ++++++++++++++++++++---------
core/shard/src/router.rs | 42 ++++++++-
core/simulator/src/lib.rs | 29 ++++++-
core/simulator/src/replica.rs | 2 +
12 files changed, 401 insertions(+), 97 deletions(-)
diff --git a/core/configs/src/server_config/sharding.rs
b/core/configs/src/server_config/sharding.rs
index b690d45fb..9f4cead92 100644
--- a/core/configs/src/server_config/sharding.rs
+++ b/core/configs/src/server_config/sharding.rs
@@ -83,22 +83,18 @@ pub struct ShardingConfig {
/// `false` drops both the CPU and memory-node bindings (and logs a
/// warning, since NUMA placement without pinning is meaningless).
pub pin_cores: bool,
- /// Per-shard inter-shard inbox channel capacity. Bounded by design.
- /// Drops on full inbox of consensus frames are recovered by VSR
- /// retransmit. Drops of cross-shard client Reply frames are terminal:
- /// the client never receives the reply (no in-protocol retransmit).
- /// Both frame classes share this one channel, so a consensus burst
- /// can starve client-reply forwards: size against the worst-case sum
- /// of consensus working set + peak client-reply fan-out per shard
- /// occurring together.
- ///
- // TODO(hubcio): split into two priority lanes - one bounded queue for
- // consensus frames (drops recovered by VSR retransmit) and one for
- // client `Reply` frames (drops terminal, must be sized for worst-case
- // fan-out). Current single-channel design is the minimum-viable
- // wiring so `frame_drops_total{variant,reason}` surfaces under load
- // and yields real numbers to size the split against.
+ /// Per-shard inter-shard inbox channel capacity (the main lane:
+ /// consensus frames, connection setup, reconcile wakes). Bounded by
+ /// design; drops of consensus frames on a full inbox are recovered by
+ /// VSR retransmit. Size against the consensus working set per shard.
pub inbox_capacity: usize,
+ /// Capacity of the reply lane: the separate bounded channel carrying
+ /// cross-shard client `Reply` forwards, whose drops are terminal (no
+ /// in-protocol retransmit; the client never receives the reply). Split
+ /// from [`Self::inbox_capacity`] so a consensus burst cannot evict
+ /// reply forwards and each lane is sized for its own worst case: this
+ /// one against peak client-reply fan-out per shard.
+ pub reply_inbox_capacity: usize,
/// Wall-clock budget for a single shard's bus drain on shutdown.
/// Drives `IggyMessageBus::shutdown(..)` from the per-shard watchdog
/// and the parallel-join survivor path. Sized larger than typical
@@ -145,6 +141,7 @@ impl Default for ShardingConfig {
cpu_allocation: CpuAllocation::default(),
pin_cores: SERVER_CONFIG.system.sharding.pin_cores,
inbox_capacity: SERVER_CONFIG.system.sharding.inbox_capacity as
usize,
+ reply_inbox_capacity:
SERVER_CONFIG.system.sharding.reply_inbox_capacity as usize,
shutdown_drain_timeout: SERVER_CONFIG
.system
.sharding
@@ -191,6 +188,22 @@ impl Validatable<ConfigurationError> for ShardingConfig {
);
return Err(ConfigurationError::InvalidConfigurationValue);
}
+ if self.reply_inbox_capacity == 0 {
+ eprintln!(
+ "Invalid sharding configuration: reply_inbox_capacity must be
> 0 (crossfire \
+ silently rounds 0 to 1, masking config errors)"
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
+ if self.reply_inbox_capacity > INBOX_CAPACITY_MAX {
+ eprintln!(
+ "Invalid sharding configuration: reply_inbox_capacity {}
exceeds the {} cap \
+ (each shard preallocates a channel of this size; oversizing
here OOMs the \
+ process at boot)",
+ self.reply_inbox_capacity, INBOX_CAPACITY_MAX
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
let drain = self.shutdown_drain_timeout.get_duration();
if drain.is_zero() {
@@ -385,6 +398,7 @@ mod tests {
let sharding = &config.system.sharding;
assert!(sharding.pin_cores);
assert_eq!(sharding.inbox_capacity, 1024);
+ assert_eq!(sharding.reply_inbox_capacity, 1024);
assert_eq!(sharding.shutdown_drain_timeout, "10 s".parse().unwrap());
assert_eq!(sharding.shutdown_poll_interval, "50 ms".parse().unwrap());
assert_eq!(sharding.shutdown_join_timeout, "30 s".parse().unwrap());
@@ -403,6 +417,7 @@ mod tests {
assert!(!sharding.pin_cores);
assert_eq!(sharding.inbox_capacity, 1024);
+ assert_eq!(sharding.reply_inbox_capacity, 1024);
assert_eq!(sharding.shutdown_drain_timeout, "10 s".parse().unwrap());
assert_eq!(sharding.shutdown_poll_interval, "50 ms".parse().unwrap());
assert_eq!(sharding.shutdown_join_timeout, "30 s".parse().unwrap());
@@ -418,6 +433,7 @@ mod tests {
assert!(sharding.pin_cores);
assert_eq!(sharding.inbox_capacity, 1024);
+ assert_eq!(sharding.reply_inbox_capacity, 1024);
assert_eq!(sharding.shutdown_drain_timeout, "10 s".parse().unwrap());
assert_eq!(sharding.shutdown_poll_interval, "50 ms".parse().unwrap());
assert_eq!(sharding.shutdown_join_timeout, "30 s".parse().unwrap());
diff --git a/core/server/config.toml b/core/server/config.toml
index d5046c509..d98098799 100644
--- a/core/server/config.toml
+++ b/core/server/config.toml
@@ -747,15 +747,21 @@ cpu_allocation = "numa:auto"
# process onto the same low-numbered cores.
pin_cores = true
-# Per-shard inter-shard inbox capacity. Bounded by design: consensus-frame
-# drops recover via VSR retransmit, but cross-shard client-reply drops are
-# terminal. Size for the worst-case sum of both: the consensus working set
-# (~ the prepare queue depth of the planes the shard hosts - [metadata] on
-# shard 0, [partition] elsewhere - times replica_count times directions) plus
-# peak client-reply fan-out per shard. Both depths are tunable, so raising
-# either raises the capacity needed here.
+# Per-shard inter-shard inbox capacity (the main lane: consensus frames,
+# connection setup, reconcile wakes). Bounded by design; consensus-frame
+# drops recover via VSR retransmit. Size for the consensus working set:
+# ~ the prepare queue depth of the planes the shard hosts ([metadata] on
+# shard 0, [partition] elsewhere) times replica_count times directions.
+# Both depths are tunable, so raising either raises the capacity needed here.
inbox_capacity = 1024
+# Capacity of the reply lane: a separate bounded channel for cross-shard
+# client-reply forwards, whose drops are terminal (no in-protocol
+# retransmit - the client never receives the reply). Split from
+# inbox_capacity so a consensus burst cannot evict reply forwards; size for
+# peak client-reply fan-out per shard.
+reply_inbox_capacity = 1024
+
# Wall-clock budget for a single shard's bus drain on shutdown. Drives
# the per-shard watchdog and the parallel-join survivor path; sized
# larger than typical TCP RTT times in-flight write-batch so writers
diff --git a/core/server/src/bootstrap.rs b/core/server/src/bootstrap.rs
index f2c5a9d11..3d983f9e6 100644
--- a/core/server/src/bootstrap.rs
+++ b/core/server/src/bootstrap.rs
@@ -685,6 +685,13 @@ fn validate_sharding_runtime_knobs(
max: INBOX_CAPACITY_MAX,
});
}
+ let reply_inbox_capacity = sharding.reply_inbox_capacity;
+ if reply_inbox_capacity == 0 || reply_inbox_capacity > INBOX_CAPACITY_MAX {
+ return Err(ServerError::InvalidReplyInboxCapacity {
+ value: reply_inbox_capacity,
+ max: INBOX_CAPACITY_MAX,
+ });
+ }
let drain_timeout = sharding.shutdown_drain_timeout.get_duration();
if drain_timeout.is_zero() || drain_timeout > SHUTDOWN_DRAIN_TIMEOUT_MAX {
return Err(ServerError::InvalidShutdownDrainTimeout {
@@ -756,9 +763,11 @@ pub fn bootstrap(
// busy-loop every shutdown watchdog on a zero poll cadence, or wedge
// process exit on an unbounded drain budget.
let inbox_capacity = config.system.sharding.inbox_capacity;
+ let reply_inbox_capacity = config.system.sharding.reply_inbox_capacity;
validate_sharding_runtime_knobs(&config.system.sharding)?;
- let (senders, mut inboxes) = shard_mesh_channels(total_shards,
inbox_capacity);
+ let (senders, mut inboxes, mut reply_inboxes) =
+ shard_mesh_channels(total_shards, inbox_capacity,
reply_inbox_capacity);
let shutdown_flag = Arc::new(AtomicBool::new(false));
let config = Arc::new(config);
// One owner table per server process, Arc-cloned into every shard's bus so
@@ -803,6 +812,9 @@ pub fn bootstrap(
let inbox = inboxes[idx]
.take()
.expect("shard_mesh_channels populates every inbox slot exactly
once");
+ let reply_inbox = reply_inboxes[idx]
+ .take()
+ .expect("shard_mesh_channels populates every reply-inbox slot
exactly once");
let senders_for_shard = senders.clone();
let config_for_shard = Arc::clone(&config);
let shutdown_flag_for_shard = Arc::clone(&shutdown_flag);
@@ -838,6 +850,7 @@ pub fn bootstrap(
assignment,
senders_for_shard,
inbox,
+ reply_inbox,
config_for_shard,
shutdown_flag_for_shard,
metadata_handoff_for_shard,
@@ -902,6 +915,7 @@ fn run_shard_thread(
assignment: ShardInfo,
senders: Vec<TaggedSender>,
inbox: ShardReceiver<ShardFrame>,
+ reply_inbox: ShardReceiver<ShardFrame>,
config: Arc<ServerConfig>,
shutdown_flag: Arc<AtomicBool>,
metadata_handoff: MetadataHandoff,
@@ -944,6 +958,7 @@ fn run_shard_thread(
replica_id,
senders,
inbox,
+ reply_inbox,
&config,
shutdown_flag,
metadata_handoff,
@@ -972,6 +987,7 @@ async fn shard_main(
replica_id: Option<u8>,
senders: Vec<TaggedSender>,
inbox: ShardReceiver<ShardFrame>,
+ reply_inbox: ShardReceiver<ShardFrame>,
config: &ServerConfig,
shutdown_flag: Arc<AtomicBool>,
metadata_handoff: MetadataHandoff,
@@ -1170,6 +1186,7 @@ async fn shard_main(
Rc::clone(&bus),
senders,
inbox,
+ reply_inbox,
shard_metrics,
Arc::clone(&metadata_view),
))
@@ -1769,6 +1786,7 @@ async fn build_shard_for_thread(
bus: Rc<IggyMessageBus>,
senders: Vec<TaggedSender>,
inbox: ShardReceiver<ShardFrame>,
+ reply_inbox: ShardReceiver<ShardFrame>,
metrics: ShardMetrics,
metadata_view: Arc<AtomicU64>,
) -> Result<(Rc<ServerShard>, Rc<RefCell<SessionManager>>), ServerError> {
@@ -2034,6 +2052,7 @@ async fn build_shard_for_thread(
partitions,
senders,
inbox,
+ reply_inbox,
shards_table,
PartitionConsensusConfig::new(
topology.cluster_id,
diff --git a/core/server/src/dispatch.rs b/core/server/src/dispatch.rs
index 429c46fd7..5dab8e2fd 100644
--- a/core/server/src/dispatch.rs
+++ b/core/server/src/dispatch.rs
@@ -4454,6 +4454,128 @@ mod tests {
);
}
+ /// A test shard wired to its own lanes (the held sender feeds them),
+ /// for the reply-lane pump tests below.
+ fn reply_lane_test_shard(name: &str) -> (SpyBus, shard::TaggedSender,
Rc<TestShard>) {
+ 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),
+ enforce_fsync: false,
+ validate_checksum: true,
+ segment_size: iggy_common::IggyByteSize::from(1_048_576_u64),
+ preallocate_segments: false,
+ encryptor: None,
+ path_layout: PartitionPathLayout::default(),
+ },
+ );
+ let (sender, inbox_rx, reply_inbox_rx) = shard_channel(0, 16, 16);
+ let lane_sender = sender.clone();
+ let shard = TestShard::new(
+ ShardIdentity::new(0, name.to_string()),
+ bus.clone(),
+ Rc::new(|_, _| {}),
+ Rc::new(|_, _| {}),
+ Rc::new(|_| {}),
+ Rc::new(|_| {}),
+ Rc::new(|_, _, _| {}),
+ metadata,
+ partitions,
+ vec![sender],
+ inbox_rx,
+ reply_inbox_rx,
+ PapayaShardsTable::new(),
+ PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1),
bus.clone()),
+ None,
+ ShardMetrics::for_shard(),
+ )
+ .expect("single-sender ring is canonically ordered");
+ (bus, lane_sender, Rc::new(shard))
+ }
+
+ fn reply_lane_forward(client_id: u128) -> ShardFrame {
+ ShardFrame::lifecycle(LifecycleFrame::ForwardClientSend {
+ client_id,
+ msg:
server_common::iobuf::Owned::<MESSAGE_ALIGN>::zeroed(64).into(),
+ })
+ }
+
+ /// A frame on the reply lane must reach the client through the RUNNING
+ /// pump's reply arm: the lane split moved `ForwardClientSend` off the
+ /// main inbox, so a pump that forgot to service the new lane would
+ /// strand every cross-shard reply while the send sites happily report
+ /// success.
+ #[compio::test]
+ async fn pump_live_arm_delivers_reply_lane_forwards() {
+ const TRANSPORT: u128 = 92;
+ let (bus, lane_sender, shard) =
reply_lane_test_shard("reply-lane-live-arm-test");
+
+ let (stop_tx, stop_rx) = shard::channel::<()>(1);
+ let pump_shard = Rc::clone(&shard);
+ let pump = compio::runtime::spawn(async move {
+ pump_shard.run_message_pump(stop_rx).await;
+ });
+
+ lane_sender
+ .reply_sender()
+ .try_send(reply_lane_forward(TRANSPORT))
+ .expect("reply lane has capacity");
+
+ // The pump is idle on the main lane, so its bottom reply arm must
+ // serve the frame without any main-lane traffic or shutdown drain.
+ let mut delivered = false;
+ for _ in 0..500 {
+ if !bus.client_replies.borrow().is_empty() {
+ delivered = true;
+ break;
+ }
+ compio::time::sleep(std::time::Duration::from_millis(1)).await;
+ }
+ stop_tx.try_send(()).expect("stop channel has capacity");
+ let _ = pump.await;
+
+ assert!(
+ delivered,
+ "the live reply arm must deliver a forward while the pump runs"
+ );
+ let replies = bus.client_replies.borrow();
+ assert_eq!(replies[0].0, TRANSPORT, "forward must reach its client");
+ }
+
+ /// The shutdown path must ALSO deliver reply-lane frames: a forward
+ /// already accepted by the lane when the stop signal wins the biased
+ /// select would otherwise be silently destroyed at teardown.
+ #[compio::test]
+ async fn pump_shutdown_drain_delivers_reply_lane_forwards() {
+ const TRANSPORT: u128 = 93;
+ let (bus, lane_sender, shard) =
reply_lane_test_shard("reply-lane-drain-test");
+
+ lane_sender
+ .reply_sender()
+ .try_send(reply_lane_forward(TRANSPORT))
+ .expect("reply lane has capacity");
+
+ // Pre-armed stop: the pump exits through the biased stop arm and the
+ // post-loop drain must still deliver the reply-lane frame.
+ let (stop_tx, stop_rx) = shard::channel::<()>(1);
+ stop_tx.try_send(()).expect("stop channel has capacity");
+ shard.run_message_pump(stop_rx).await;
+
+ let replies = bus.client_replies.borrow();
+ assert_eq!(
+ replies.len(),
+ 1,
+ "the pump's reply-lane drain must deliver the forwarded reply"
+ );
+ assert_eq!(
+ replies[0].0, TRANSPORT,
+ "the forward must reach the client it was addressed to"
+ );
+ }
+
/// 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
@@ -4482,9 +4604,10 @@ mod tests {
},
);
// Real sender ring so the staged deny is observable: the test holds
- // the receiving end of this shard's own channel.
- let (sender, pump_rx) = shard_channel(0, 16);
- let (_inbox_tx, inbox_rx) = shard_channel(0, 1);
+ // the receiving ends of this shard's own lanes. The deny is a client
+ // Reply forward, so it lands on the REPLY lane.
+ let (sender, _pump_rx, reply_rx) = shard_channel(0, 16, 16);
+ let (_inbox_tx, inbox_rx, reply_inbox_rx) = shard_channel(0, 1, 1);
let shard = TestShard::new(
ShardIdentity::new(0, "discarded-parked-send-test".to_string()),
bus.clone(),
@@ -4497,6 +4620,7 @@ mod tests {
partitions,
vec![sender],
inbox_rx,
+ reply_inbox_rx,
PapayaShardsTable::new(),
PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 1),
bus.clone()),
None,
@@ -4517,7 +4641,7 @@ mod tests {
shard.apply_reconcile_ops();
let mut denies = Vec::new();
- while let Ok(frame) = pump_rx.try_recv() {
+ while let Ok(frame) = reply_rx.try_recv() {
if let ShardFrame::Lifecycle(LifecycleFrame::ForwardClientSend {
client_id, msg }) =
frame
{
diff --git a/core/server/src/partition_reconciler.rs
b/core/server/src/partition_reconciler.rs
index fbf99df6b..4df3e5402 100644
--- a/core/server/src/partition_reconciler.rs
+++ b/core/server/src/partition_reconciler.rs
@@ -1544,19 +1544,30 @@ mod tests {
/// Mesh covers `0..=shard_id` since consumers index `senders[shard_id]`.
/// Peer receivers are dropped, so a misroute fails loudly instead of
landing
/// in this shard's inbox and reading as success.
+ /// Both receiving ends of a test shard's own sender-ring slot: parked
+ /// frames re-dispatch onto the main lane, staged client answers onto the
+ /// reply lane.
+ struct TestLanes {
+ main: shard::Receiver<shard::ShardFrame>,
+ reply: shard::Receiver<shard::ShardFrame>,
+ }
+
fn build_test_shard_with_inbox(
shard_id: u16,
config: &ServerConfig,
mux: TestMux,
capacity: usize,
- ) -> (Rc<TestShard>, shard::Receiver<shard::ShardFrame>) {
+ ) -> (Rc<TestShard>, TestLanes) {
let mut senders = Vec::with_capacity(usize::from(shard_id) + 1);
let mut own_rx = None;
for peer in 0..=shard_id {
- let (tx, rx) = shard::shard_channel(peer, capacity);
+ let (tx, rx, reply_rx) = shard::shard_channel(peer, capacity,
capacity);
senders.push(tx);
if peer == shard_id {
- own_rx = Some(rx);
+ own_rx = Some(TestLanes {
+ main: rx,
+ reply: reply_rx,
+ });
}
}
let mut shard = Rc::into_inner(build_test_shard(shard_id, config, mux))
@@ -1568,27 +1579,32 @@ mod tests {
)
}
- /// Drain a test shard's inbox into `(re-dispatched frames, staged client
+ /// Drain a test shard's lanes into `(re-dispatched frames, staged client
/// sends)`: served parked frames vs answers headed for a client.
- fn drain_inbox(rx: &shard::Receiver<shard::ShardFrame>) -> (usize, usize) {
+ fn drain_inbox(lanes: &TestLanes) -> (usize, usize) {
let mut served = 0;
+ while let Ok(frame) = lanes.main.try_recv() {
+ if matches!(frame, shard::ShardFrame::Consensus { .. }) {
+ served += 1;
+ }
+ }
let mut answered = 0;
- while let Ok(frame) = rx.try_recv() {
- match frame {
- shard::ShardFrame::Consensus { .. } => served += 1,
-
shard::ShardFrame::Lifecycle(shard::LifecycleFrame::ForwardClientSend {
- ..
- }) => answered += 1,
- _ => {}
+ while let Ok(frame) = lanes.reply.try_recv() {
+ if matches!(
+ frame,
+
shard::ShardFrame::Lifecycle(shard::LifecycleFrame::ForwardClientSend { .. })
+ ) {
+ answered += 1;
}
}
(served, answered)
}
- /// Count the `ForwardClientSend` frames sitting in a test shard's inbox:
the
- /// staged transient denies, which is what actually reaches a client.
- fn drain_staged_client_sends(rx: &shard::Receiver<shard::ShardFrame>) ->
usize {
- drain_inbox(rx).1
+ /// Count the `ForwardClientSend` frames sitting in a test shard's reply
+ /// lane: the staged transient denies, which is what actually reaches a
+ /// client.
+ fn drain_staged_client_sends(lanes: &TestLanes) -> usize {
+ drain_inbox(lanes).1
}
fn make_ctx(
diff --git a/core/server/src/server_error.rs b/core/server/src/server_error.rs
index 06c8ec250..34c561632 100644
--- a/core/server/src/server_error.rs
+++ b/core/server/src/server_error.rs
@@ -87,6 +87,8 @@ pub enum ServerError {
},
#[error("system.sharding.inbox_capacity must be in 1..={max}; got
{value}")]
InvalidInboxCapacity { value: usize, max: usize },
+ #[error("system.sharding.reply_inbox_capacity must be in 1..={max}; got
{value}")]
+ InvalidReplyInboxCapacity { value: usize, max: usize },
#[error("system.sharding.shutdown_drain_timeout must be in (0, {max:?}];
got {value:?}")]
InvalidShutdownDrainTimeout {
value: std::time::Duration,
diff --git a/core/shard/src/builder.rs b/core/shard/src/builder.rs
index f171784d9..07363f451 100644
--- a/core/shard/src/builder.rs
+++ b/core/shard/src/builder.rs
@@ -73,6 +73,7 @@ where
partitions: IggyPartitions<B, SB>,
senders: Vec<TaggedSender>,
inbox: Receiver<ShardFrame>,
+ reply_inbox: Receiver<ShardFrame>,
shards_table: T,
partition_consensus: PartitionConsensusConfig<B>,
coord_config: CoordinatorConfig,
@@ -103,6 +104,7 @@ where
partitions: IggyPartitions<B, SB>,
senders: Vec<TaggedSender>,
inbox: Receiver<ShardFrame>,
+ reply_inbox: Receiver<ShardFrame>,
shards_table: T,
partition_consensus: PartitionConsensusConfig<B>,
coord_config: CoordinatorConfig,
@@ -120,6 +122,7 @@ where
partitions,
senders,
inbox,
+ reply_inbox,
shards_table,
partition_consensus,
coord_config,
@@ -190,7 +193,10 @@ where
client_id,
msg,
});
- sender.try_send(frame).map_err(|err| {
+ // Reply lane: a forwarded client reply is terminal on
+ // drop, so it must not compete with consensus frames
+ // for main-lane capacity.
+ sender.reply_sender().try_send(frame).map_err(|err| {
metrics_for_client.record_frame_drop(
frame_drop_variant::FORWARD_CLIENT_SEND,
classify_try_send_err(&err),
@@ -227,6 +233,7 @@ where
self.partitions,
self.senders,
self.inbox,
+ self.reply_inbox,
self.shards_table,
self.partition_consensus,
coordinator,
diff --git a/core/shard/src/coordinator.rs b/core/shard/src/coordinator.rs
index f85daba69..72de53324 100644
--- a/core/shard/src/coordinator.rs
+++ b/core/shard/src/coordinator.rs
@@ -417,7 +417,7 @@ mod tests {
fn build_senders(total: u16) -> Rc<Vec<TaggedSender>> {
let mut senders = Vec::with_capacity(total as usize);
for shard_id in 0..total {
- let (tx, _rx) = crate::shard_channel(shard_id, 16);
+ let (tx, _rx, _reply_rx) = crate::shard_channel(shard_id, 16, 16);
senders.push(tx);
}
Rc::new(senders)
@@ -429,7 +429,7 @@ mod tests {
let mut senders = Vec::with_capacity(total as usize);
let mut receivers = Vec::with_capacity(total as usize);
for shard_id in 0..total {
- let (tx, rx) = crate::shard_channel(shard_id, 16);
+ let (tx, rx, _reply_rx) = crate::shard_channel(shard_id, 16, 16);
senders.push(tx);
receivers.push(rx);
}
@@ -440,10 +440,10 @@ mod tests {
fn ctor_rejects_permuted_sender_vec() {
// Build senders in correct order, then swap two entries so the
// indexed position no longer matches the tagged shard id.
- let (tx0, _rx0) = crate::shard_channel(0, 16);
- let (tx1, _rx1) = crate::shard_channel(1, 16);
- let (tx2, _rx2) = crate::shard_channel(2, 16);
- let (tx3, _rx3) = crate::shard_channel(3, 16);
+ let (tx0, _rx0, _reply_rx0) = crate::shard_channel(0, 16, 16);
+ let (tx1, _rx1, _reply_rx1) = crate::shard_channel(1, 16, 16);
+ let (tx2, _rx2, _reply_rx2) = crate::shard_channel(2, 16, 16);
+ let (tx3, _rx3, _reply_rx3) = crate::shard_channel(3, 16, 16);
let permuted = Rc::new(vec![tx0, tx2, tx1, tx3]);
let result = ShardZeroCoordinator::new(
permuted,
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index eda20d4dd..0725798a4 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -400,44 +400,60 @@ where
}
}
-/// Create a bounded inter-shard channel whose sender is tagged with the
-/// owning shard.
+/// Create the bounded inter-shard channel pair (main lane + reply lane)
+/// whose sender is tagged with the owning shard.
///
/// Bootstrap uses this to build the per-shard sender `Vec` such that
-/// `vec[i]` necessarily reaches shard `i`.
+/// `vec[i]` necessarily reaches shard `i`. The second receiver is the reply
+/// lane: cross-shard client `Reply` forwards, whose drops are terminal, ride
+/// a channel of their own so a consensus burst filling the main lane cannot
+/// evict them (see `[system.sharding] reply_inbox_capacity`).
#[must_use]
-pub fn shard_channel(owner_shard: u16, capacity: usize) -> (TaggedSender,
Receiver<ShardFrame>) {
+pub fn shard_channel(
+ owner_shard: u16,
+ capacity: usize,
+ reply_capacity: usize,
+) -> (TaggedSender, Receiver<ShardFrame>, Receiver<ShardFrame>) {
let (tx, rx) = channel::<ShardFrame>(capacity);
- (TaggedSender::new(owner_shard, tx), rx)
+ let (reply_tx, reply_rx) = channel::<ShardFrame>(reply_capacity);
+ (TaggedSender::new(owner_shard, tx, reply_tx), rx, reply_rx)
}
-/// Build canonical-ordered `(senders, inboxes)` pair for an N-shard mesh.
+/// Build canonical-ordered `(senders, inboxes, reply_inboxes)` for an
+/// N-shard mesh.
///
-/// Each `inboxes[i]` drains exclusively on the runtime owning shard `i`. The
-/// returned `senders` Vec satisfies `senders[i].shard_id() == i` by
-/// construction; clone it into every shard before spawning so all shards
-/// share the same mesh.
+/// Each `inboxes[i]` / `reply_inboxes[i]` drains exclusively on the runtime
+/// owning shard `i`. The returned `senders` Vec satisfies
+/// `senders[i].shard_id() == i` by construction; clone it into every shard
+/// before spawning so all shards share the same mesh.
///
/// Receivers are wrapped in `Option` because [`Receiver`] (crossfire
-/// `AsyncRx`) is non-cloneable on purpose; bootstrap takes the slot for
+/// `AsyncRx`) is non-cloneable on purpose; bootstrap takes the slots for
/// shard `i` exactly once when spawning the owning thread.
#[must_use]
-pub fn shard_mesh_channels(
- total_shards: u16,
- capacity: usize,
-) -> (Vec<TaggedSender>, Vec<Option<Receiver<ShardFrame>>>) {
+pub fn shard_mesh_channels(total_shards: u16, capacity: usize, reply_capacity:
usize) -> ShardMesh {
let mut senders = Vec::with_capacity(total_shards as usize);
let mut inboxes = Vec::with_capacity(total_shards as usize);
+ let mut reply_inboxes = Vec::with_capacity(total_shards as usize);
for shard_id in 0..total_shards {
- let (tx, rx) = shard_channel(shard_id, capacity);
+ let (tx, rx, reply_rx) = shard_channel(shard_id, capacity,
reply_capacity);
senders.push(tx);
inboxes.push(Some(rx));
+ reply_inboxes.push(Some(reply_rx));
}
- (senders, inboxes)
+ (senders, inboxes, reply_inboxes)
}
-/// A [`Sender`] annotated with the id of the shard whose paired receiver it
-/// feeds.
+/// The canonical N-shard mesh: lane senders plus the per-shard receivers
+/// (`inboxes[i]` / `reply_inboxes[i]` drain on the runtime owning shard `i`).
+pub type ShardMesh = (
+ Vec<TaggedSender>,
+ Vec<Option<Receiver<ShardFrame>>>,
+ Vec<Option<Receiver<ShardFrame>>>,
+);
+
+/// The pair of lane [`Sender`]s annotated with the id of the shard whose
+/// paired receivers they feed.
///
/// Inter-shard routing indexes `senders[i]` with `i == target_shard`. The
/// plain `Sender` form has no way to verify that invariant at runtime, so a
@@ -446,25 +462,41 @@ pub fn shard_mesh_channels(
/// [`TaggedSender::new`]) at the channel-creation site; the coordinator and
/// [`IggyShard`] ctors then validate `senders[i].shard_id() == i`,
/// returning [`ShardCtorError`] if violated.
+///
+/// `Deref` targets the main lane; [`Self::reply_sender`] exposes the reply
+/// lane (cross-shard client `Reply` forwards, terminal on drop).
pub struct TaggedSender {
shard_id: u16,
inner: Sender<ShardFrame>,
+ reply: Sender<ShardFrame>,
}
impl TaggedSender {
- /// Wrap an already-constructed sender with the id of the shard whose
- /// paired receiver drains it. Prefer [`shard_channel`] unless an
- /// existing sender is being re-tagged (e.g., tests that build senders
- /// manually and know the ordering is correct).
+ /// Wrap already-constructed lane senders with the id of the shard whose
+ /// paired receivers drain them. Prefer [`shard_channel`] unless existing
+ /// senders are being re-tagged (e.g., tests that build senders manually
+ /// and know the ordering is correct).
#[must_use]
- pub const fn new(shard_id: u16, inner: Sender<ShardFrame>) -> Self {
- Self { shard_id, inner }
+ pub const fn new(shard_id: u16, inner: Sender<ShardFrame>, reply:
Sender<ShardFrame>) -> Self {
+ Self {
+ shard_id,
+ inner,
+ reply,
+ }
}
#[must_use]
pub const fn shard_id(&self) -> u16 {
self.shard_id
}
+
+ /// The reply lane's sender. Client `Reply` forwards go here so the main
+ /// lane's consensus traffic cannot evict them; everything else stays on
+ /// the main lane via `Deref`.
+ #[must_use]
+ pub const fn reply_sender(&self) -> &Sender<ShardFrame> {
+ &self.reply
+ }
}
impl Clone for TaggedSender {
@@ -472,6 +504,7 @@ impl Clone for TaggedSender {
Self {
shard_id: self.shard_id,
inner: self.inner.clone(),
+ reply: self.reply.clone(),
}
}
}
@@ -552,17 +585,19 @@ fn forward_nonce_seed<B: MessageBus>(consensus:
Option<&VsrConsensus<B>>) -> u64
/// Lifecycle frame variants.
///
/// Connection setup and cross-shard forwards: every frame the inter-shard
-/// channel carries that is NOT a consensus protocol message lives here.
+/// channels carry that is NOT a consensus protocol message lives here.
/// Splitting these out from [`ShardFrame::Consensus`] keeps the consensus
-/// dispatch path hot and cache-tight while leaving lifecycle traffic on
-/// the same single channel (preserving relative ordering between consensus
-/// and lifecycle frames at near-zero cost).
+/// dispatch path hot and cache-tight.
///
-/// Trade-off: consensus and lifecycle traffic compete for one bounded
-/// inbox. A consensus burst or retransmit storm can fill it exactly when
-/// a terminal-drop [`LifecycleFrame::ForwardClientSend`] needs the space;
-/// `inbox_capacity` is a single knob and cannot isolate the two frame
-/// classes.
+/// Lane placement: every variant rides the main inbox EXCEPT
+/// [`LifecycleFrame::ForwardClientSend`], which rides the dedicated reply
+/// lane (`reply_inbox_capacity`) because its drops are terminal while every
+/// main-lane variant's loss is recovered by some retry (VSR retransmit,
+/// reconnect sweep, periodic tick). The two lanes are independent queues:
+/// there is NO relative ordering between a consensus frame and a client
+/// reply forward, which is safe because a reply forward never
+/// order-couples with consensus traffic (it requires a served request,
+/// and per-client reply order is preserved within the reply lane itself).
#[non_exhaustive]
pub enum LifecycleFrame {
/// Shard 0 distributes an inbound replica TCP connection fd to the
@@ -1296,6 +1331,12 @@ where
/// messages here via the corresponding sender.
inbox: Receiver<ShardFrame>,
+ /// Receiver end of this shard's reply lane: cross-shard client `Reply`
+ /// forwards, split off the main inbox because their drops are terminal
+ /// (no in-protocol retransmit) while a consensus burst can legitimately
+ /// fill the main lane. Fed via [`TaggedSender::reply_sender`].
+ reply_inbox: Receiver<ShardFrame>,
+
/// Partition namespace -> owning shard lookup.
shards_table: T,
@@ -1444,6 +1485,15 @@ where
self.inbox.len()
}
+ /// [`Self::inbox_len`] for the reply lane, so the simulator's lost-wakeup
+ /// tripwire covers both queues: a frame stranded in either lane at
+ /// quiescence is a missed wake.
+ #[cfg(any(test, feature = "simulator"))]
+ #[must_use]
+ pub fn reply_inbox_len(&self) -> usize {
+ self.reply_inbox.len()
+ }
+
/// Create a new shard with channel links and a shards table.
///
/// * `bus` - shard-local bus handle (kept alongside the buses owned
@@ -1454,6 +1504,8 @@ where
/// construction time so every sender carries the id of the shard
/// whose receiver drains it.
/// * `inbox` - the receiver that this shard drains in its message pump.
+ /// * `reply_inbox` - the reply lane's receiver, drained by the same
+ /// pump (client `Reply` forwards only; see
[`TaggedSender::reply_sender`]).
/// * `shards_table` - namespace -> shard routing table.
/// * `coordinator` - `Some` on shard 0 (supplied by the builder when
/// `is_shard_zero`), `None` everywhere else. Immutable post-ctor:
@@ -1483,6 +1535,7 @@ where
partitions: IggyPartitions<B, SB>,
senders: Vec<TaggedSender>,
inbox: Receiver<ShardFrame>,
+ reply_inbox: Receiver<ShardFrame>,
shards_table: T,
partition_consensus: PartitionConsensusConfig<B>,
coordinator: Option<Rc<crate::coordinator::ShardZeroCoordinator>>,
@@ -1509,6 +1562,7 @@ where
senders,
shard_count,
inbox,
+ reply_inbox,
shards_table,
partition_consensus,
coordinator,
@@ -1808,12 +1862,14 @@ where
shards_table: T,
partition_consensus: PartitionConsensusConfig<B>,
) -> Self {
- // Placeholder inbox: the simulator delivers frames straight to
+ // Placeholder lanes: the simulator delivers frames straight to
// `on_message` (see the `shard_count` note below), so nothing ever
- // sends here and capacity 1 exists only to satisfy the field. The
- // real inbox is bounded on purpose (`inbox_capacity` is the shard's
- // backpressure), so no unbounded variant is wanted here either.
+ // sends here and capacity 1 exists only to satisfy the fields. The
+ // real lanes are bounded on purpose (`inbox_capacity` /
+ // `reply_inbox_capacity` are the shard's backpressure), so no
+ // unbounded variant is wanted here either.
let (_tx, inbox) = channel(1);
+ let (_reply_tx, reply_inbox) = channel(1);
let nonce_seed = forward_nonce_seed(metadata.consensus.as_ref());
let plane = MuxPlane::new(variadic!(metadata, partitions));
let ShardIdentity { id, name } = identity;
@@ -1837,6 +1893,7 @@ where
// through, while preserving single-shard routing semantics.
shard_count: 1,
inbox,
+ reply_inbox,
shards_table,
partition_consensus,
metrics: crate::metrics::ShardMetrics::for_shard(),
@@ -3285,7 +3342,7 @@ where
// Count only what was actually handed to the pump: crediting before
the
// send reports an answer to a client that never received one, which is
// the opposite of what this counter is read for.
- if let Err(error) = sender.try_send(frame) {
+ if let Err(error) = sender.reply_sender().try_send(frame) {
self.metrics.record_frame_drop(
crate::metrics::frame_drop_variant::PARTITION,
crate::coordinator::classify_try_send_err(&error),
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index 0432ecc22..4340761a0 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -261,9 +261,10 @@ where
_ = stop.recv().fuse() => break,
() = consensus_tick.as_mut() => {
// Sharing the pump task is what keeps `tick_partitions`
- // borrow-safe, but it bounds the tick's worst-case delay
to
- // one frame body's longest `.await` (replication append +
- // commit_journal fsync/rotate + reply).
+ // borrow-safe, but it bounds the tick's worst-case delay
+ // to one main frame body's longest `.await` (replication
+ // append + commit_journal fsync/rotate + reply) plus the
+ // one reply-lane bus send drained per main frame.
// TODO(hubcio): if a load test shows tick starvation,
// make `tick_partitions` borrow-free so the tick can be
// decoupled from the pump again without reintroducing the
@@ -303,6 +304,32 @@ where
// Tail drain catches reconcile ops whose
marker was dropped.
self.apply_reconcile_ops();
}
+ // Guaranteed reply-lane service: `select_biased!`
+ // polls the main lane first, so a saturated main
+ // lane would otherwise starve the reply arm below
+ // indefinitely. Taking at most ONE reply per main
+ // frame makes the worst case a deterministic 1:1
+ // interleave - replies keep flowing under a
+ // consensus storm, and consensus keeps flowing
+ // under a reply flood.
+ if let Ok(reply) = self.reply_inbox.try_recv()
+ && self.accept_frame_for_self(&reply)
+ {
+ self.process_frame(reply).await;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ frame = self.reply_inbox.recv().fuse() => {
+ // Reached only while the main lane is quiet (biased
order):
+ // serve forwarded client replies without waiting for the
+ // next main frame or tick.
+ match frame {
+ Ok(frame) => {
+ if self.accept_frame_for_self(&frame) {
+ self.process_frame(frame).await;
+ }
}
Err(_) => break,
}
@@ -310,7 +337,9 @@ where
}
}
- // Drain remaining frames so in-flight requests get a response.
+ // Drain remaining frames so in-flight requests get a response, and
+ // the reply lane so already-forwarded replies still reach their
+ // clients before the bus tears down.
while let Ok(frame) = self.inbox.try_recv() {
if self.accept_frame_for_self(&frame) {
self.process_frame(frame).await;
@@ -319,6 +348,11 @@ where
self.apply_reconcile_ops();
}
}
+ while let Ok(frame) = self.reply_inbox.try_recv() {
+ if self.accept_frame_for_self(&frame) {
+ self.process_frame(frame).await;
+ }
+ }
// Final flush: committed messages still resident in the in-memory
// journal must reach segment storage before the process exits, or a
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 17f24a620..1662ea6a1 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -270,8 +270,11 @@ impl Simulator {
// One crossfire mesh per replica; every shard gets a clone of
// the canonical senders vec and exclusively takes its inbox.
- let (senders, mut inboxes) =
- shard::shard_mesh_channels(shards_per_replica,
SIM_INBOX_CAPACITY);
+ let (senders, mut inboxes, mut reply_inboxes) =
shard::shard_mesh_channels(
+ shards_per_replica,
+ SIM_INBOX_CAPACITY,
+ SIM_INBOX_CAPACITY,
+ );
let mut shards =
Vec::with_capacity(usize::from(shards_per_replica));
let mut stop_txs =
Vec::with_capacity(usize::from(shards_per_replica));
@@ -286,6 +289,9 @@ impl Simulator {
let inbox = inboxes[usize::from(shard_idx)]
.take()
.expect("mesh yields exactly one inbox per shard");
+ let reply_inbox = reply_inboxes[usize::from(shard_idx)]
+ .take()
+ .expect("mesh yields exactly one reply inbox per shard");
// Only shard 0 owns metadata consensus, so only it carries the
// superblock. Peer shards persist nothing.
let shard_superblock = if shard_idx == 0 {
@@ -302,6 +308,7 @@ impl Simulator {
rc,
senders.clone(),
inbox,
+ reply_inbox,
consensus_clock.clone(),
shell,
metadata_bundle.clone(),
@@ -609,6 +616,16 @@ impl Simulator {
self.seed,
self.executor.schedule_hash(),
);
+ let pending_replies = shard.reply_inbox_len();
+ assert_eq!(
+ pending_replies,
+ 0,
+ "lost wakeup: replica {replica_id} shard {} reply lane
holds \
+ {pending_replies} frame(s) at quiescence (seed {:#x},
schedule hash {:#x})",
+ shard.id,
+ self.seed,
+ self.executor.schedule_hash(),
+ );
}
}
}
@@ -755,8 +772,8 @@ impl Simulator {
let consensus_clock =
ConsensusClock::new(Rc::new(SimClock::new(self.executor.timer())));
let outbox = Rc::clone(&self.outboxes[idx]);
- let (senders, mut inboxes) =
- shard::shard_mesh_channels(shards_per_replica, SIM_INBOX_CAPACITY);
+ let (senders, mut inboxes, mut reply_inboxes) =
+ shard::shard_mesh_channels(shards_per_replica, SIM_INBOX_CAPACITY,
SIM_INBOX_CAPACITY);
let mut shards = Vec::with_capacity(usize::from(shards_per_replica));
let mut stop_txs = Vec::with_capacity(usize::from(shards_per_replica));
@@ -766,6 +783,9 @@ impl Simulator {
let inbox = inboxes[usize::from(shard_idx)]
.take()
.expect("mesh yields exactly one inbox per shard");
+ let reply_inbox = reply_inboxes[usize::from(shard_idx)]
+ .take()
+ .expect("mesh yields exactly one reply inbox per shard");
let shard_superblock = if shard_idx == 0 {
Some(superblock.clone())
} else {
@@ -780,6 +800,7 @@ impl Simulator {
self.replica_count,
senders.clone(),
inbox,
+ reply_inbox,
consensus_clock.clone(),
self.shell,
metadata_bundle.clone(),
diff --git a/core/simulator/src/replica.rs b/core/simulator/src/replica.rs
index 362f3a144..cbcffc671 100644
--- a/core/simulator/src/replica.rs
+++ b/core/simulator/src/replica.rs
@@ -118,6 +118,7 @@ pub fn new_shard(
replica_count: u8,
senders: Vec<shard::TaggedSender>,
inbox: shard::Receiver<shard::ShardFrame>,
+ reply_inbox: shard::Receiver<shard::ShardFrame>,
clock: ConsensusClock,
shell: bool,
reader_bundle: Option<SimMetadataBundle>,
@@ -339,6 +340,7 @@ pub fn new_shard(
partitions,
senders,
inbox,
+ reply_inbox,
PapayaShardsTable::new(),
shard::PartitionConsensusConfig::with_clock(
CLUSTER_ID,