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 f0bd70283 perf(message_bus): batch small replica frames per socket
read (#4224)
f0bd70283 is described below
commit f0bd7028319f281502c2c8eab668e5d2b653ccb6
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Mon Sep 21 20:19:10 2026 +0200
perf(message_bus): batch small replica frames per socket read (#4224)
The link shard that owns a peer's replica socket burns a full
core under sustained replicated writes. Nothing batches its reads:
read_message issues one io_uring read for the 256-byte header and
a second for the body. Bytes already sitting in the kernel receive
queue wait for the next call.
A read-ahead buffer sized by message_bus.replica_read_buffer_size
lets one socket read serve a whole burst. It adds no latency, because
the reader never waits for the buffer to fill. One fill delivers
at most one buffer, so a body above that size crosses the buffer in
buffer-sized pieces and pays one extra pass over the payload and one
read per piece. A read that large goes straight into the frame's own
buffer instead.
Zero keeps the unbuffered path, so the A/B baseline is a config change
and not a separate build. The plaintext writer already coalesces into
one writev, so it is left alone.
replica_socket_reads_total and replica_inbound_frames_total carry the
evidence on the real workload: their ratio is the batching factor,
and only link shards bump them. A read counts once it completes,
so a link torn down mid-read cannot inflate the ratio.
---
core/configs/src/server_config/defaults.rs | 1 +
core/configs/src/server_config/displays.rs | 8 +-
core/configs/src/server_config/message_bus.rs | 131 ++++++++-
core/integration/tests/cluster/mod.rs | 1 +
.../tests/cluster/replica_read_batching.rs | 150 +++++++++++
core/message_bus/src/config.rs | 8 +
core/message_bus/src/framing.rs | 95 ++++++-
core/message_bus/src/installer/replica.rs | 12 +-
core/message_bus/src/lib.rs | 83 ++++++
core/message_bus/src/transports/tcp.rs | 296 ++++++++++++++++++++-
core/server/config.toml | 19 ++
core/shard/src/lib.rs | 2 +
core/shard/src/metrics.rs | 27 ++
13 files changed, 809 insertions(+), 24 deletions(-)
diff --git a/core/configs/src/server_config/defaults.rs
b/core/configs/src/server_config/defaults.rs
index 6019a8b0d..d96b274ac 100644
--- a/core/configs/src/server_config/defaults.rs
+++ b/core/configs/src/server_config/defaults.rs
@@ -296,6 +296,7 @@ impl Default for MessageBusConfig {
MessageBusConfig {
max_batch: bus.max_batch as usize,
max_message_size: bus.max_message_size.parse().unwrap(),
+ replica_read_buffer_size:
bus.replica_read_buffer_size.parse().unwrap(),
peer_queue_capacity: bus.peer_queue_capacity as usize,
client_queue_capacity: bus.client_queue_capacity as usize,
reconnect_period: bus.reconnect_period.parse().unwrap(),
diff --git a/core/configs/src/server_config/displays.rs
b/core/configs/src/server_config/displays.rs
index ee96a0bbe..06e2b50ad 100644
--- a/core/configs/src/server_config/displays.rs
+++ b/core/configs/src/server_config/displays.rs
@@ -96,12 +96,14 @@ impl Display for MessageBusConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
- "{{ max_batch: {}, max_message_size: {}, peer_queue_capacity: {}, \
- reconnect_period: {}, close_peer_timeout: {}, close_grace: {}, \
- handshake_grace: {} }}",
+ "{{ max_batch: {}, max_message_size: {}, replica_read_buffer_size:
{}, \
+ peer_queue_capacity: {}, client_queue_capacity: {},
reconnect_period: {}, \
+ close_peer_timeout: {}, close_grace: {}, handshake_grace: {} }}",
self.max_batch,
self.max_message_size,
+ self.replica_read_buffer_size,
self.peer_queue_capacity,
+ self.client_queue_capacity,
self.reconnect_period,
self.close_peer_timeout,
self.close_grace,
diff --git a/core/configs/src/server_config/message_bus.rs
b/core/configs/src/server_config/message_bus.rs
index 8ce3ea57c..9ec1aa5b7 100644
--- a/core/configs/src/server_config/message_bus.rs
+++ b/core/configs/src/server_config/message_bus.rs
@@ -52,6 +52,7 @@
//! [`super::server::ServerConfig::load`].
use super::COMPONENT;
+use super::defaults::SERVER_CONFIG;
use crate::ConfigurationError;
use configs::ConfigEnv;
use iggy_common::{IggyByteSize, IggyDuration, MAX_MESSAGE_SIZE_UPPER_BYTES,
Validatable};
@@ -70,7 +71,15 @@ use serde_with::{DisplayFromStr, serde_as};
/// failure until both are reconciled.
pub const IOV_MAX_LIMIT: usize = 512;
-const DEFAULT_CLIENT_QUEUE_CAPACITY: usize = 256;
+/// Floor for a nonzero [`MessageBusConfig::replica_read_buffer_size`].
+/// A buffer below one page costs more reads and copies than no buffer.
+const MIN_REPLICA_READ_BUFFER_BYTES: u64 = 4 * 1024;
+
+/// Ceiling for a nonzero [`MessageBusConfig::replica_read_buffer_size`].
+/// The buffer is allocated per replica link on every shard, and a fill
+/// cannot take more than the socket receive queue holds, so a value above
+/// this is a typo rather than a tuning choice.
+const MAX_REPLICA_READ_BUFFER_BYTES: u64 = 16 * 1024 * 1024;
/// Tunables for the message bus that ships consensus traffic between
/// replicas and SDK-client traffic between shards.
@@ -88,6 +97,19 @@ pub struct MessageBusConfig {
#[config_env(leaf)]
pub max_message_size: IggyByteSize,
+ /// Read-ahead buffer per plaintext replica link. Costs one buffer of
+ /// this size per installed connection. The framing layer decodes
+ /// every complete frame a fill delivered, so a burst costs one read
+ /// instead of one or two per frame. A read at least this size goes
+ /// straight to the socket, so a body larger than the buffer crosses
+ /// it only for the part the header's fill already pulled in. Zero
+ /// keeps the unbuffered path. No effect under `cluster.tls`, whose
+ /// reader buffers inside compio's `SyncStream` adapter, nor on the
+ /// client plane, which never wraps its read half.
+ #[serde(default = "default_replica_read_buffer_size")]
+ #[config_env(leaf)]
+ pub replica_read_buffer_size: IggyByteSize,
+
/// Bound on each replica peer's mpsc queue. Writer task drains; the
/// `send_to_*` path enqueues. Too small drops under burst; too
/// large delays backpressure signalling.
@@ -145,6 +167,21 @@ impl Validatable<ConfigurationError> for MessageBusConfig {
);
return Err(ConfigurationError::InvalidConfigurationValue);
}
+ // The one key in this section where zero is legal: it selects the
+ // unbuffered read path, so the A/B baseline is a config change
+ // rather than a separate build.
+ let replica_read_buffer = self.replica_read_buffer_size.as_bytes_u64();
+ if replica_read_buffer != 0
+ && !(MIN_REPLICA_READ_BUFFER_BYTES..=MAX_REPLICA_READ_BUFFER_BYTES)
+ .contains(&replica_read_buffer)
+ {
+ eprintln!(
+ "{COMPONENT} message_bus.replica_read_buffer_size
({replica_read_buffer}) must be \
+ 0 (unbuffered) or between {MIN_REPLICA_READ_BUFFER_BYTES} and
\
+ {MAX_REPLICA_READ_BUFFER_BYTES} bytes"
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
if self.peer_queue_capacity == 0 {
eprintln!("{COMPONENT} message_bus.peer_queue_capacity must be >
0");
return Err(ConfigurationError::InvalidConfigurationValue);
@@ -187,8 +224,19 @@ impl Validatable<ConfigurationError> for MessageBusConfig {
}
}
-const fn default_client_queue_capacity() -> usize {
- DEFAULT_CLIENT_QUEUE_CAPACITY
+/// Zero fails validation, so the value comes from the embedded `config.toml`.
+fn default_client_queue_capacity() -> usize {
+ SERVER_CONFIG.message_bus.client_queue_capacity as usize
+}
+
+/// [`IggyByteSize`]'s own `Default` is 0 bytes, which turns the read-ahead
+/// buffer off, so the value comes from the embedded `config.toml`.
+fn default_replica_read_buffer_size() -> IggyByteSize {
+ SERVER_CONFIG
+ .message_bus
+ .replica_read_buffer_size
+ .parse()
+ .expect("message_bus.replica_read_buffer_size is a byte size")
}
#[cfg(test)]
@@ -241,7 +289,10 @@ mod tests {
.remove("client_queue_capacity");
config["peer_queue_capacity"] = serde_json::json!(8192);
let decoded: MessageBusConfig =
serde_json::from_value(config).unwrap();
- assert_eq!(decoded.client_queue_capacity,
DEFAULT_CLIENT_QUEUE_CAPACITY);
+ assert_eq!(
+ decoded.client_queue_capacity,
+ baseline().client_queue_capacity
+ );
assert_eq!(decoded.peer_queue_capacity, 8192);
decoded.validate().unwrap();
}
@@ -253,6 +304,78 @@ mod tests {
assert!(config.validate().is_err());
}
+ #[test]
+ fn default_replica_read_buffer_is_32_kib() {
+ assert_eq!(
+ baseline().replica_read_buffer_size.as_bytes_u64(),
+ 32 * 1024
+ );
+ }
+
+ /// Zero is the unbuffered path, not a misconfiguration: it is the
+ /// A/B baseline arm and must survive validation.
+ #[test]
+ fn accepts_zero_replica_read_buffer() {
+ let mut c = baseline();
+ c.replica_read_buffer_size = IggyByteSize::from(0_u64);
+ assert!(c.validate().is_ok());
+ }
+
+ #[test]
+ fn accepts_replica_read_buffer_at_floor() {
+ let mut c = baseline();
+ c.replica_read_buffer_size =
IggyByteSize::from(MIN_REPLICA_READ_BUFFER_BYTES);
+ assert!(c.validate().is_ok());
+ }
+
+ #[test]
+ fn rejects_replica_read_buffer_below_floor() {
+ let mut c = baseline();
+ c.replica_read_buffer_size =
IggyByteSize::from(MIN_REPLICA_READ_BUFFER_BYTES - 1);
+ assert!(c.validate().is_err());
+ }
+
+ #[test]
+ fn accepts_replica_read_buffer_at_ceiling() {
+ let mut c = baseline();
+ c.replica_read_buffer_size =
IggyByteSize::from(MAX_REPLICA_READ_BUFFER_BYTES);
+ assert!(c.validate().is_ok());
+ }
+
+ /// A mistyped size allocates per link at connect time, long after
+ /// boot, so validation is the only place that can still refuse it.
+ #[test]
+ fn rejects_replica_read_buffer_above_ceiling() {
+ let mut c = baseline();
+ c.replica_read_buffer_size =
IggyByteSize::from(MAX_REPLICA_READ_BUFFER_BYTES + 1);
+ assert!(c.validate().is_err());
+ }
+
+ #[test]
+ fn replica_read_buffer_parses_byte_size_strings() {
+ for (text, expected) in [("0", 0_u64), ("64 KiB", 64 * 1024)] {
+ let mut config = serde_json::to_value(baseline()).unwrap();
+ config["replica_read_buffer_size"] = serde_json::json!(text);
+ let decoded: MessageBusConfig =
serde_json::from_value(config).unwrap();
+ assert_eq!(decoded.replica_read_buffer_size.as_bytes_u64(),
expected);
+ decoded.validate().unwrap();
+ }
+ }
+
+ #[test]
+ fn missing_replica_read_buffer_keeps_config_default() {
+ let mut config = serde_json::to_value(baseline()).unwrap();
+ config
+ .as_object_mut()
+ .unwrap()
+ .remove("replica_read_buffer_size");
+ let decoded: MessageBusConfig =
serde_json::from_value(config).unwrap();
+ assert_eq!(
+ decoded.replica_read_buffer_size,
+ baseline().replica_read_buffer_size
+ );
+ }
+
#[test]
fn rejects_zero_max_message_size() {
let mut c = baseline();
diff --git a/core/integration/tests/cluster/mod.rs
b/core/integration/tests/cluster/mod.rs
index e9f81fb93..c4d183fb0 100644
--- a/core/integration/tests/cluster/mod.rs
+++ b/core/integration/tests/cluster/mod.rs
@@ -31,4 +31,5 @@ mod partition_dedup;
mod partition_primary_routing;
mod partition_state_transfer;
mod register_forwarding;
+mod replica_read_batching;
mod staggered_bootstrap;
diff --git a/core/integration/tests/cluster/replica_read_batching.rs
b/core/integration/tests/cluster/replica_read_batching.rs
new file mode 100644
index 000000000..1f212ca37
--- /dev/null
+++ b/core/integration/tests/cluster/replica_read_batching.rs
@@ -0,0 +1,150 @@
+// 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.
+
+//! The plaintext replica plane counts what it reads.
+//!
+//! `replica_socket_reads_total` and `replica_inbound_frames_total` are the
+//! direct evidence for `message_bus.replica_read_buffer_size`: their ratio is
+//! the batching factor the read-ahead buffer exists to raise. Only a shard
+//! that owns a replica socket bumps them, so a nonzero pair also names the
+//! link shard.
+//!
+//! One shard per node, so shard 0 is that link shard and the assertion needs
+//! no search. What is pinned here is that both counters reach the scrape at
+//! all: the `Rc` reaches the reader task, the take in `tick_partitions` runs,
+//! and the two `ShardMetrics` counters are registered. The ratio itself is
+//! only logged. Frame arrival timing on a live link is not controlled, so a
+//! threshold on it would be a flaky test;
+//! `framing::tests::buffered_read_batches_many_frames_per_socket_read` asserts
+//! the factor where the traffic shape is fixed.
+//!
+//! `frames >= reads` is deliberately not asserted: a body split across two
+//! segments, or one straddling the end of the buffer, costs two reads for one
+//! frame.
+
+use iggy::prelude::*;
+use integration::iggy_harness;
+use std::time::Duration;
+use tokio::time::{Instant, sleep};
+
+use crate::server::http_client::HttpClient;
+
+const STREAM: &str = "replica-read-stream";
+const TOPIC: &str = "replica-read-topic";
+const PARTITION_ID: u32 = 0;
+const MESSAGES: u32 = 64;
+
+/// The counters land on the scrape through the shard's partition tick, so the
+/// first scrape after a produce can still read zero.
+const COUNTER_BUDGET: Duration = Duration::from_secs(10);
+const COUNTER_POLL: Duration = Duration::from_millis(250);
+
+/// One shard per node, so this is the only shard and it owns both replica
+/// links.
+const LINK_SHARD: u16 = 0;
+
+/// Read one `shard`-labelled counter out of the Prometheus text exposition.
+///
+/// The sub-registry label comes first in the label set, and these two counters
+/// carry no others, so the series name plus the label is an exact line prefix.
+fn shard_counter(metrics: &str, name: &str, shard: u16) -> Option<u64> {
+ let prefix = format!("{name}{{shard=\"{shard}\"}} ");
+ metrics
+ .lines()
+ .find_map(|line| line.strip_prefix(&prefix)?.trim().parse().ok())
+}
+
+async fn scrape(http: &HttpClient) -> String {
+ http.client
+ .get(http.url("/metrics"))
+ .bearer_auth(&http.token)
+ .send()
+ .await
+ .expect("metrics response")
+ .text()
+ .await
+ .expect("metrics text")
+}
+
+#[iggy_harness(cluster_nodes = 3, server(sharding.cpu_allocation = "0..1"))]
+async fn
given_a_replicating_cluster_when_scraping_should_report_replica_reads_and_frames(
+ 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();
+ let topic = Identifier::named(TOPIC).unwrap();
+ client.create_stream(STREAM).await.unwrap();
+ client
+ .create_topic(
+ &stream,
+ TOPIC,
+ &TopicCreateOptions {
+ partitions_count: Some(1),
+ message_expiry: Some(IggyExpiry::NeverExpire),
+ ..TopicCreateOptions::default()
+ },
+ )
+ .await
+ .expect("create topic");
+
+ for index in 0..MESSAGES {
+ let mut messages = vec![
+ IggyMessage::builder()
+ .payload(format!("payload-{index}").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 {index}: {error}"));
+ }
+
+ let http = HttpClient::login_root(harness).await;
+ let deadline = Instant::now() + COUNTER_BUDGET;
+ loop {
+ let metrics = scrape(&http).await;
+ let reads = shard_counter(&metrics, "replica_socket_reads_total",
LINK_SHARD);
+ let frames = shard_counter(&metrics, "replica_inbound_frames_total",
LINK_SHARD);
+ if let (Some(reads), Some(frames)) = (reads, frames)
+ && reads > 0
+ && frames > 0
+ {
+ println!(
+ "link shard {LINK_SHARD}: {frames} frames over {reads} socket
reads, \
+ {:.2} frames per read",
+ frames as f64 / reads as f64
+ );
+ return;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "link shard {LINK_SHARD} reported reads={reads:?}
frames={frames:?} within \
+ {COUNTER_BUDGET:?}; both must be nonzero once replica traffic has
flowed"
+ );
+ sleep(COUNTER_POLL).await;
+ }
+}
diff --git a/core/message_bus/src/config.rs b/core/message_bus/src/config.rs
index 19731b4f7..aefaf5624 100644
--- a/core/message_bus/src/config.rs
+++ b/core/message_bus/src/config.rs
@@ -138,6 +138,12 @@ pub struct MessageBusConfig {
/// validator; undersize or oversize frames are rejected.
pub max_message_size: usize,
+ /// Read-ahead buffer per plaintext replica link, in bytes. Threaded
+ /// into `TcpTransportConn::with_replica_read` by the replica
+ /// installer; zero selects the unbuffered read path. The client
+ /// plane and the TLS-family transports ignore it.
+ pub replica_read_buffer_size: usize,
+
/// Bound on each replica peer's mpsc queue. The writer task drains; the
/// `send_to_*` path enqueues. Too small drops under burst; too
/// large delays backpressure signalling.
@@ -216,6 +222,8 @@ impl From<&ServerConfig> for MessageBusConfig {
max_batch: bus.max_batch,
max_message_size:
usize::try_from(bus.max_message_size.as_bytes_u64())
.expect("message_bus.max_message_size fits usize on supported
targets"),
+ replica_read_buffer_size:
usize::try_from(bus.replica_read_buffer_size.as_bytes_u64())
+ .expect("message_bus.replica_read_buffer_size fits usize on
supported targets"),
peer_queue_capacity: bus.peer_queue_capacity,
client_queue_capacity: bus.client_queue_capacity,
reconnect_period: bus.reconnect_period.get_duration(),
diff --git a/core/message_bus/src/framing.rs b/core/message_bus/src/framing.rs
index ab626c65e..76ae9b7e3 100644
--- a/core/message_bus/src/framing.rs
+++ b/core/message_bus/src/framing.rs
@@ -130,7 +130,10 @@ pub async fn read_message<S: AsyncReadExt>(
// Stage 2: grow the same `Owned` in place and fill the tail via a
// slice read. Total allocations for a body frame: ONE
// (`Owned::with_capacity(HEADER_SIZE)` plus one in-place realloc of
- // the backing AVec). Zero memcpys of the data.
+ // the backing AVec). The body lands in this `Owned` in one pass. A
+ // buffered read half skips the buffer only on an empty buffer and a
+ // caller slice of at least one buffer, so a body of one to two buffers
+ // crosses the buffer whole.
let mut owned = owned;
// The `.map_err` arm is unreachable today: `Owned<MESSAGE_ALIGN>` is
// the only `IoBufMut` ever fed here, and its `reserve_exact` impl
@@ -174,8 +177,11 @@ fn to_read_error(e: &std::io::Error) -> IggyError {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::ReplicaReadStats;
+ use crate::transports::tcp::{CountingRead, ReadAhead};
use compio::net::{TcpListener, TcpStream};
use iggy_binary_protocol::{Command, SIZE_FIELD_OFFSET};
+ use std::rc::Rc;
#[allow(clippy::cast_possible_truncation)]
fn make_header_only(command: Command) -> Message<GenericHeader> {
@@ -224,6 +230,93 @@ mod tests {
assert!(matches!(res, Err(IggyError::InvalidCommand)));
}
+ /// The one place the batching factor is asserted: here the traffic
+ /// shape is controlled, so many frames provably land in one socket
+ /// read. On a live link the ratio depends on arrival timing.
+ #[compio::test]
+ #[allow(clippy::future_not_send)]
+ async fn buffered_read_batches_many_frames_per_socket_read() {
+ const FRAMES: usize = 64;
+ const CAPACITY: usize = 64 * 1024;
+
+ let (mut a, b) = local_pair().await;
+ let batch: Vec<_> = (0..FRAMES)
+ .map(|_| make_header_only(Command::Ping).into_frozen())
+ .collect();
+ a.write_vectored_all(batch).await.0.unwrap();
+
+ let stats = Rc::new(ReplicaReadStats::default());
+ let mut reader = ReadAhead::new(CAPACITY, CountingRead::new(b,
Rc::clone(&stats)));
+ for _ in 0..FRAMES {
+ let read = read_message(&mut reader,
MAX_MESSAGE_SIZE).await.unwrap();
+ assert_eq!(read.header().command, Command::Ping);
+ }
+
+ let reads = stats.take().reads;
+ assert!(
+ (1..FRAMES as u64 / 8).contains(&reads),
+ "{FRAMES} frames of one write cost {reads} reads; unbuffered would
cost {FRAMES}"
+ );
+ }
+
+ /// A frame several times the buffer decodes intact, and does not
+ /// cross the buffer in buffer-sized pieces: a read at least one
+ /// buffer long goes straight to the socket.
+ #[compio::test]
+ #[allow(clippy::future_not_send, clippy::cast_possible_truncation)]
+ async fn buffered_read_decodes_frame_larger_than_buffer() {
+ const CAPACITY: usize = 4 * 1024;
+ const MULTIPLE: usize = 8;
+ let frame_len = MULTIPLE * CAPACITY;
+
+ let mut msg =
Message::<GenericHeader>::new(frame_len).transmute_header(
+ |_, h: &mut GenericHeader| {
+ h.command = Command::Ping;
+ h.size = frame_len as u32;
+ },
+ );
+ for (at, byte) in
msg.as_mut_slice()[HEADER_SIZE..].iter_mut().enumerate() {
+ *byte = (at % 251) as u8;
+ }
+ let expected = msg.as_slice().to_vec();
+
+ let (mut a, b) = local_pair().await;
+ let stats = Rc::new(ReplicaReadStats::default());
+ let mut reader = ReadAhead::new(CAPACITY, CountingRead::new(b,
Rc::clone(&stats)));
+ write_message(&mut a, msg).await.unwrap();
+
+ let read = read_message(&mut reader, MAX_MESSAGE_SIZE).await.unwrap();
+ assert_eq!(read.as_slice(), expected.as_slice());
+ let reads = stats.take().reads;
+ assert!(
+ reads >= 2,
+ "a frame {MULTIPLE}x the buffer cannot arrive in one read"
+ );
+ assert!(
+ reads < MULTIPLE as u64,
+ "{MULTIPLE} buffers of body cost {reads} reads; copied through the
buffer \
+ it cannot cost fewer than {MULTIPLE}"
+ );
+ }
+
+ /// EOF partway through a header stays a closed connection rather than
+ /// a framing error once the read half is buffered.
+ #[compio::test]
+ #[allow(clippy::future_not_send)]
+ async fn buffered_read_reports_eof_after_partial_header() {
+ use compio::io::AsyncWriteExt;
+ let (mut a, b) = local_pair().await;
+ a.write_all(vec![0u8; HEADER_SIZE / 2]).await.0.unwrap();
+ drop(a);
+
+ let mut reader = ReadAhead::new(
+ 4 * 1024,
+ CountingRead::new(b, Rc::new(ReplicaReadStats::default())),
+ );
+ let res = read_message(&mut reader, MAX_MESSAGE_SIZE).await;
+ assert!(matches!(res, Err(IggyError::ConnectionClosed)));
+ }
+
#[compio::test]
#[allow(clippy::future_not_send)]
async fn read_rejects_undersize_size_field() {
diff --git a/core/message_bus/src/installer/replica.rs
b/core/message_bus/src/installer/replica.rs
index 5bb4556fb..172c52173 100644
--- a/core/message_bus/src/installer/replica.rs
+++ b/core/message_bus/src/installer/replica.rs
@@ -123,12 +123,16 @@ pub fn install_replica_inbound(
async move {
match ctx.tls.clone() {
None => {
+ let read_buffer = bus.config().replica_read_buffer_size;
+ let read_stats = bus.replica_read_stats();
accept_and_install(
&bus,
stream,
&ctx,
ChannelBinding::Plaintext,
- TcpTransportConn::new,
+ move |stream| {
+
TcpTransportConn::new(stream).with_replica_read(read_buffer, read_stats)
+ },
on_message,
&peer,
)
@@ -232,13 +236,17 @@ pub fn install_replica_outbound(
async move {
match ctx.tls.clone() {
None => {
+ let read_buffer = bus.config().replica_read_buffer_size;
+ let read_stats = bus.replica_read_stats();
dial_and_install(
&bus,
stream,
&ctx,
peer_id,
ChannelBinding::Plaintext,
- TcpTransportConn::new,
+ move |stream| {
+
TcpTransportConn::new(stream).with_replica_read(read_buffer, read_stats)
+ },
on_message,
)
.await
diff --git a/core/message_bus/src/lib.rs b/core/message_bus/src/lib.rs
index dc2348daa..8bd2a3fe2 100644
--- a/core/message_bus/src/lib.rs
+++ b/core/message_bus/src/lib.rs
@@ -432,6 +432,59 @@ pub type AcceptedWssClientFn = std::rc::Rc<dyn
Fn(compio::net::TcpStream, Shared
/// clear the replica mapping and re-dial.
pub type ConnectionLostFn = std::rc::Rc<dyn Fn(u8)>;
+/// Socket-read accounting for this shard's plaintext replica links.
+///
+/// One instance covers every such link the shard owns, so the ratio of
+/// decoded frames to completed socket reads is their aggregate batching
+/// factor, which is what the read-ahead buffer exists to raise. Only link
+/// shards read replica sockets, so a nonzero value also identifies the
+/// link shard.
+///
+/// `Cell` rather than atomics: the reader task that bumps these and the
+/// shard sweep that drains them run on the same compio thread. Shared
+/// with the reader task through an `Rc`, on the precedent of
+/// `installer::replica`'s `install_aborted`.
+#[derive(Debug, Default)]
+pub struct ReplicaReadStats {
+ reads: Cell<u64>,
+ frames: Cell<u64>,
+}
+
+impl ReplicaReadStats {
+ /// One completed socket read on the replica read half.
+ pub fn record_read(&self) {
+ self.reads.set(self.reads.get() + 1);
+ }
+
+ /// One frame decoded out of the replica read half.
+ pub fn record_frame(&self) {
+ self.frames.set(self.frames.get() + 1);
+ }
+
+ /// Drain both counts, resetting them to zero.
+ ///
+ /// Take semantics because the caller feeds the result to a cumulative
+ /// Prometheus counter's `inc_by`: handing back running totals would
+ /// double count every sweep.
+ pub const fn take(&self) -> ReplicaReadMetrics {
+ ReplicaReadMetrics {
+ reads: self.reads.replace(0),
+ frames: self.frames.replace(0),
+ }
+ }
+}
+
+/// Deltas since the previous [`ReplicaReadStats::take`].
+///
+/// `frames >= reads` is not an invariant: a body that arrives in two
+/// segments, or one that straddles the end of the read-ahead buffer,
+/// costs two reads for one frame.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct ReplicaReadMetrics {
+ pub reads: u64,
+ pub frames: u64,
+}
+
/// Point-to-point message delivery between consensus participants.
///
/// `Ok(())` means "accepted for delivery" - NOT "delivered to peer".
@@ -627,6 +680,16 @@ pub trait MessageBus {
fn realtime_micros(&self) -> u64 {
iggy_common::IggyTimestamp::now().as_micros()
}
+
+ /// Drain this bus's plaintext replica socket-read counters.
+ ///
+ /// The provided default returns zeros so the simulator bus and the
+ /// test buses need no override: they own no replica socket. The
+ /// production bus takes from its own [`ReplicaReadStats`], which the
+ /// reader task of every plaintext replica link shares.
+ fn take_replica_read_stats(&self) -> ReplicaReadMetrics {
+ ReplicaReadMetrics::default()
+ }
}
/// Production message bus backed by real TCP connections.
@@ -707,6 +770,11 @@ pub struct IggyMessageBus {
/// pending; entries expire lazily inside [`Self::check_dial_pending`].
/// Only shard 0's bus ever populates this.
pending_dials: RefCell<ahash::AHashMap<u8, Instant>>,
+ /// Socket-read counters shared by the reader task of every plaintext
+ /// replica link this shard owns. Drained through
+ /// [`MessageBus::take_replica_read_stats`] by the shard's tick;
+ /// stays at zero on a shard that owns no such link.
+ replica_read_stats: Rc<ReplicaReadStats>,
}
impl IggyMessageBus {
@@ -819,6 +887,7 @@ impl IggyMessageBus {
replica_handshake_slots: RefCell::new(ahash::AHashMap::new()),
replica_slot_seq: Cell::new(0),
pending_dials: RefCell::new(ahash::AHashMap::new()),
+ replica_read_stats: Rc::new(ReplicaReadStats::default()),
}
}
@@ -1109,6 +1178,12 @@ impl IggyMessageBus {
&self.config
}
+ /// Handle every plaintext replica reader task on this shard bumps.
+ #[must_use]
+ pub fn replica_read_stats(&self) -> Rc<ReplicaReadStats> {
+ Rc::clone(&self.replica_read_stats)
+ }
+
/// Cheap clone of the root shutdown token.
///
/// Handed to accept loops, read tasks, writer tasks, and periodic tasks
@@ -1296,6 +1371,10 @@ impl<T: MessageBus + ?Sized> MessageBus for
std::rc::Rc<T> {
fn realtime_micros(&self) -> u64 {
(**self).realtime_micros()
}
+
+ fn take_replica_read_stats(&self) -> ReplicaReadMetrics {
+ (**self).take_replica_read_stats()
+ }
}
#[allow(clippy::future_not_send)]
@@ -1394,6 +1473,10 @@ impl MessageBus for IggyMessageBus {
fn track_background(&self, handle: JoinHandle<()>) {
Self::track_background(self, handle);
}
+
+ fn take_replica_read_stats(&self) -> ReplicaReadMetrics {
+ self.replica_read_stats.take()
+ }
}
/// Extract the owning shard from a client id.
diff --git a/core/message_bus/src/transports/tcp.rs
b/core/message_bus/src/transports/tcp.rs
index 53845b41e..01eabd842 100644
--- a/core/message_bus/src/transports/tcp.rs
+++ b/core/message_bus/src/transports/tcp.rs
@@ -27,11 +27,20 @@
//! and emits them via `compio::io::AsyncWriteExt::write_vectored_all`
//! (one writev in the common case, looping on short writes; zero
//! intermediate copies of `Frozen`).
+//!
+//! The replica plane wraps its read half through
+//! `TcpTransportConn::with_replica_read`: socket reads are counted on
+//! every link, and above a nonzero `replica_read_buffer_size` one read
+//! fills a buffer the framing layer then decodes many frames out of. The
+//! client plane keeps the bare read half.
use super::{ActorContext, TransportConn, TransportListener};
+use crate::ReplicaReadStats;
use crate::framing;
use crate::lifecycle::BusMessage;
-use compio::io::AsyncWriteExt;
+use compio::BufResult;
+use compio::buf::IoBufMut;
+use compio::io::{AsyncRead, AsyncWriteExt};
use compio::net::{TcpListener, TcpStream};
use compio::runtime::fd::PollFd;
use futures::FutureExt;
@@ -41,6 +50,7 @@ use std::io;
use std::mem;
use std::net::SocketAddr;
use std::os::fd::AsRawFd;
+use std::rc::Rc;
use tracing::{debug, error, trace};
/// Inbound TCP listener wrapper.
@@ -88,18 +98,44 @@ impl TransportListener for TcpTransportListener {
/// reader and writer tasks internally.
pub(crate) struct TcpTransportConn {
stream: TcpStream,
+ /// Replica plane only: read-ahead capacity in bytes (0 selects the
+ /// unbuffered path) paired with the link's socket-read counters.
+ /// `None` on the client plane, which never wraps its read half.
+ replica_read: Option<(usize, Rc<ReplicaReadStats>)>,
}
impl TcpTransportConn {
#[must_use]
pub(crate) const fn new(stream: TcpStream) -> Self {
- Self { stream }
+ Self {
+ stream,
+ replica_read: None,
+ }
+ }
+
+ /// Count socket reads on this link, and read ahead into a buffer of
+ /// `capacity` bytes when that is nonzero.
+ ///
+ /// Called only from the replica installer. The client installer
+ /// stays on [`Self::new`], so no client connection pays for either.
+ #[must_use]
+ pub(crate) fn with_replica_read(
+ mut self,
+ capacity: usize,
+ stats: Rc<ReplicaReadStats>,
+ ) -> Self {
+ self.replica_read = Some((capacity, stats));
+ self
}
}
impl TransportConn for TcpTransportConn {
#[allow(clippy::future_not_send)]
async fn run(self, ctx: ActorContext) {
+ let Self {
+ stream,
+ replica_read,
+ } = self;
// Capture a refcounted poll fd BEFORE `into_split` so the
// shutdown watchdog can wake the reader's parked io_uring read
// SQE via `libc::shutdown(SHUT_RD)`. No `select!` over a TCP
@@ -108,8 +144,8 @@ impl TransportConn for TcpTransportConn {
// io_uring read is not cancel-safe in the protocol sense).
// compio 0.19 dropped `TcpStream::to_shared_fd`; `to_poll_fd`
// gives an equivalently refcounted fd handle (PollFd: AsRawFd).
- let poll_fd = self.stream.to_poll_fd().ok();
- let (read_half, write_half) = self.stream.into_split();
+ let poll_fd = stream.to_poll_fd().ok();
+ let (read_half, write_half) = stream.into_split();
let ActorContext {
in_tx,
rx,
@@ -127,13 +163,24 @@ impl TransportConn for TcpTransportConn {
let writer_shutdown = shutdown;
let reader_peer = peer.clone();
- let reader_handle = compio::runtime::spawn(reader_loop(
- read_half,
- in_tx,
- max_message_size,
- label,
- reader_peer,
- ));
+ let reader_handle = match replica_read {
+ Some((capacity, stats)) => compio::runtime::spawn(reader_loop(
+ ReadAhead::new(capacity, CountingRead::new(read_half,
Rc::clone(&stats))),
+ in_tx,
+ max_message_size,
+ Some(stats),
+ label,
+ reader_peer,
+ )),
+ None => compio::runtime::spawn(reader_loop(
+ read_half,
+ in_tx,
+ max_message_size,
+ None,
+ label,
+ reader_peer,
+ )),
+ };
let writer_handle = compio::runtime::spawn(writer_loop(
write_half,
rx,
@@ -187,26 +234,140 @@ fn spawn_shutdown_watchdog(
.detach();
}
+/// [`AsyncRead`] adapter that counts completed reads of the inner
+/// source.
+///
+/// Sits under the [`ReadAhead`] buffer, so it sees socket reads rather
+/// than the buffer hits the framing layer makes against the buffer.
+/// Wraps the replica read half even when read-ahead is off, so the
+/// unbuffered arm of an A/B run reports a ratio rather than zeros.
+pub(crate) struct CountingRead<R> {
+ inner: R,
+ stats: Rc<ReplicaReadStats>,
+}
+
+impl<R> CountingRead<R> {
+ pub(crate) const fn new(inner: R, stats: Rc<ReplicaReadStats>) -> Self {
+ Self { inner, stats }
+ }
+}
+
+impl<R: AsyncRead> AsyncRead for CountingRead<R> {
+ #[allow(clippy::future_not_send)]
+ async fn read<B: IoBufMut>(&mut self, buf: B) -> BufResult<usize, B> {
+ let res = self.inner.read(buf).await;
+ // Counted regardless of outcome: an error and an `Ok(0)` each
+ // cost the same submit and completion as a full read. Recorded
+ // after the read so a link force-cancelled at teardown does not
+ // count a read that never completed.
+ self.stats.record_read();
+ res
+ }
+}
+
+/// Replica read half that reads ahead into a buffer while keeping large
+/// reads off it.
+///
+/// Socket reads land in the buffer, so the framing layer decodes every
+/// complete frame one fill delivered: a burst of small frames costs one
+/// read instead of one or two per frame. A read at least one buffer long
+/// goes straight to the socket instead, because a fill cannot deliver
+/// more than `capacity`: a larger frame would cross the buffer in
+/// buffer-sized pieces, which costs one extra pass over the payload and
+/// one socket read per piece. `capacity == 0` buffers nothing, because
+/// every read is then at least one buffer long.
+///
+/// A fill moves the buffer into the inner read and restores it when that
+/// read completes, so a fill future dropped mid-read leaves the buffer
+/// empty rather than half-filled. Only tearing the peer down drops a
+/// fill, and that drops the whole read half with it.
+pub(crate) struct ReadAhead<R> {
+ inner: R,
+ capacity: usize,
+ buf: Vec<u8>,
+ /// Bytes of `buf` already handed to the caller.
+ pos: usize,
+}
+
+impl<R> ReadAhead<R> {
+ pub(crate) fn new(capacity: usize, inner: R) -> Self {
+ Self {
+ inner,
+ capacity,
+ buf: Vec::with_capacity(capacity),
+ pos: 0,
+ }
+ }
+}
+
+impl<R: AsyncRead> ReadAhead<R> {
+ /// Refill the buffer from the socket. A short read is normal and
+ /// means the socket held nothing more.
+ #[allow(clippy::future_not_send)]
+ async fn fill(&mut self) -> io::Result<()> {
+ self.buf.clear();
+ self.pos = 0;
+ // No-op unless a dropped fill left the buffer without capacity.
+ self.buf.reserve_exact(self.capacity);
+ let buf = mem::take(&mut self.buf);
+ let BufResult(res, buf) = self.inner.read(buf).await;
+ self.buf = buf;
+ res.map(|_| ())
+ }
+}
+
+impl<R: AsyncRead> AsyncRead for ReadAhead<R> {
+ #[allow(clippy::future_not_send)]
+ async fn read<B: IoBufMut>(&mut self, mut buf: B) -> BufResult<usize, B> {
+ if self.pos == self.buf.len() {
+ // The caller left room for a whole buffer or more, so the
+ // buffer would only add a copy on the way through.
+ if buf.buf_capacity() >= self.capacity {
+ return self.inner.read(buf).await;
+ }
+ if let Err(e) = self.fill().await {
+ return BufResult(Err(e), buf);
+ }
+ }
+ let mut src: &[u8] = &self.buf[self.pos..];
+ let BufResult(res, buf) = src.read(buf).await;
+ if let Ok(n) = res {
+ self.pos += n;
+ }
+ BufResult(res, buf)
+ }
+}
+
/// Read framed consensus messages off the wire and forward each to
/// [`ActorContext::in_tx`]. Exits on EOF, framing error, or send-side
/// closure.
///
+/// Generic over the read half so the replica plane can hand in a
+/// [`ReadAhead`] wrapping [`CountingRead`] while the client plane hands
+/// in the bare `TcpStream`. `stats` is `Some` on every replica link and
+/// counts decoded frames against the socket reads `CountingRead` counts.
+///
/// No `select!` over the TCP read. Cooperative shutdown is delivered
/// via [`spawn_shutdown_watchdog`], which calls `libc::shutdown(SHUT_RD)`
/// when the bus token fires; the in-flight read returns `Ok(0)` and
/// `framing::read_message` surfaces it as an EOF error on the next
-/// iteration.
+/// iteration. Frames already complete in the read-ahead buffer are still
+/// decoded and handed to the dispatcher before that happens.
#[allow(clippy::future_not_send)]
-async fn reader_loop(
- mut read_half: TcpStream,
+async fn reader_loop<R: AsyncRead>(
+ mut read_half: R,
in_tx:
async_channel::Sender<server_common::Message<iggy_binary_protocol::GenericHeader>>,
max_message_size: usize,
+ stats: Option<Rc<ReplicaReadStats>>,
label: &'static str,
peer: String,
) {
loop {
match framing::read_message(&mut read_half, max_message_size).await {
Ok(msg) => {
+ if let Some(stats) = &stats {
+ stats.record_frame();
+ }
if in_tx.send(msg).await.is_err() {
debug!(%label, %peer, "tcp reader: inbound queue dropped");
return;
@@ -365,6 +526,12 @@ mod tests {
(client_res.unwrap(), server)
}
+ /// The shipped default, so a bump in `config.toml` is exercised here
+ /// rather than pinned to a stale literal.
+ fn default_replica_read_buffer() -> usize {
+ crate::MessageBusConfig::default().replica_read_buffer_size
+ }
+
#[allow(clippy::future_not_send)]
fn drive(
conn: TcpTransportConn,
@@ -439,6 +606,91 @@ mod tests {
let _ = server_handle.await;
}
+ /// Same contract as the plaintext twin above, with the replica
+ /// plane's read-ahead buffer at the configured default, plus the
+ /// counters the A/B run reads.
+ #[compio::test]
+ #[allow(clippy::future_not_send)]
+ async fn run_pumps_three_frames_through_buffered_reader() {
+ let (client, server) = local_pair().await;
+ let stats = Rc::new(ReplicaReadStats::default());
+ let (client_out, _client_in, client_shutdown, client_handle) =
+ drive(TcpTransportConn::new(client));
+ let (_server_out, server_in, server_shutdown, server_handle) = drive(
+ TcpTransportConn::new(server)
+ .with_replica_read(default_replica_read_buffer(),
Rc::clone(&stats)),
+ );
+
+ for cmd in [Command::Ping, Command::Prepare, Command::Request] {
+ client_out.send(header_only(cmd).into()).await.unwrap();
+ }
+
+ let recv_with_timeout = |rx:
&async_channel::Receiver<Message<GenericHeader>>| {
+ let rx = rx.clone();
+ async move {
+ compio::time::timeout(Duration::from_secs(2), rx.recv())
+ .await
+ .expect("recv within 2s")
+ .expect("ok")
+ }
+ };
+ let a = recv_with_timeout(&server_in).await;
+ let b = recv_with_timeout(&server_in).await;
+ let c = recv_with_timeout(&server_in).await;
+ assert_eq!(a.header().command, Command::Ping);
+ assert_eq!(b.header().command, Command::Prepare);
+ assert_eq!(c.header().command, Command::Request);
+
+ let counts = stats.take();
+ assert_eq!(counts.frames, 3);
+ assert!(
+ counts.reads >= 1,
+ "three frames cannot arrive in zero reads"
+ );
+
+ client_shutdown.trigger();
+ server_shutdown.trigger();
+ let _ = client_handle.await;
+ let _ = server_handle.await;
+ }
+
+ /// Capacity 0 keeps the unbuffered read path and still counts, so the
+ /// baseline arm of an A/B run reports the same ratio as the buffered
+ /// arms instead of zeros.
+ #[compio::test]
+ #[allow(clippy::future_not_send)]
+ async fn unbuffered_replica_read_still_counts() {
+ let (client, server) = local_pair().await;
+ let stats = Rc::new(ReplicaReadStats::default());
+ let (client_out, _client_in, client_shutdown, client_handle) =
+ drive(TcpTransportConn::new(client));
+ let (_server_out, server_in, server_shutdown, server_handle) =
+ drive(TcpTransportConn::new(server).with_replica_read(0,
Rc::clone(&stats)));
+
+ for cmd in [Command::Ping, Command::Prepare, Command::Request] {
+ client_out.send(header_only(cmd).into()).await.unwrap();
+ let read = compio::time::timeout(Duration::from_secs(2),
server_in.recv())
+ .await
+ .expect("recv within 2s")
+ .expect("ok");
+ assert_eq!(read.header().command, cmd);
+ }
+
+ let counts = stats.take();
+ assert_eq!(counts.frames, 3);
+ assert!(
+ counts.reads >= counts.frames,
+ "the unbuffered path costs at least one read per frame, got {} for
{}",
+ counts.reads,
+ counts.frames
+ );
+
+ client_shutdown.trigger();
+ server_shutdown.trigger();
+ let _ = client_handle.await;
+ let _ = server_handle.await;
+ }
+
/// A frame fragmented to `IOV_MAX` or past it must reach the peer whole
/// and in order: `sendmsg` rejects more than `IOV_MAX` iovecs with
/// `EMSGSIZE`, so the writer has to chunk the flattened batch instead of
@@ -534,6 +786,22 @@ mod tests {
assert!(res.is_ok(), "run must exit within 2s of shutdown");
}
+ /// The `SHUT_RD` watchdog still tears the reader down when its read
+ /// half sits under a `ReadAhead`.
+ #[compio::test]
+ #[allow(clippy::future_not_send)]
+ async fn buffered_run_exits_on_shutdown_signal() {
+ let (client, _server) = local_pair().await;
+ let conn = TcpTransportConn::new(client).with_replica_read(
+ default_replica_read_buffer(),
+ Rc::new(ReplicaReadStats::default()),
+ );
+ let (_out_tx, _in_rx, shutdown, handle) = drive(conn);
+ shutdown.trigger();
+ let res = compio::time::timeout(Duration::from_secs(2), handle).await;
+ assert!(res.is_ok(), "run must exit within 2s of shutdown");
+ }
+
#[compio::test]
#[allow(clippy::future_not_send)]
async fn run_reports_oversize_frame_and_exits() {
diff --git a/core/server/config.toml b/core/server/config.toml
index b32988b0f..2332d6a30 100644
--- a/core/server/config.toml
+++ b/core/server/config.toml
@@ -1057,6 +1057,25 @@ max_batch = 256
# also breaks Go clients, whose frame cap is a hard 64 MiB constant.
max_message_size = "64 MiB"
+# Read-ahead buffer per plaintext replica link. The framing layer decodes every
+# complete frame a fill delivered, and a fill takes what the socket holds
rather
+# than waiting for a full buffer. 0 selects the unbuffered path (one read per
+# header, one per body), and so do unlimited and none, which the byte-size
parser
+# reads as 0. No-op under cluster.tls.
+#
+# A read at least this size that starts on an empty buffer goes straight to the
+# socket. The bypass needs an empty buffer, so a body between one and two
buffers
+# crosses the buffer whole, and a body of two buffers or more crosses it for
about
+# one buffer.
+#
+# 32 KiB holds 128 PrepareOks or 8 average Prepares of one burst. It sits
below the
+# kernel's default receive buffer (tcp_rmem[1], 128 KiB), which autotuning
grows
+# further once the reader falls behind: a backlog then costs several reads
rather
+# than one. That is the trade this value sets, fewer bytes copied per Prepare
+# against a lower batching factor under backlog. Boot rejects a nonzero value
+# outside 4 KiB..16 MiB.
+replica_read_buffer_size = "32 KiB"
+
# Bound on each replica peer's mpsc queue. The writer task drains; the
# send_to_* path enqueues.
peer_queue_capacity = 4096
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 803547fba..287b747b8 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -7635,6 +7635,8 @@ where
}
}
self.metrics.record_persistence(&persistence_metrics);
+ self.metrics
+ .record_replica_reads(&self.bus.take_replica_read_stats());
self.metrics
.set_repair_ring(repair_ring_entries, repair_ring_bytes);
diff --git a/core/shard/src/metrics.rs b/core/shard/src/metrics.rs
index 4caf701f2..f16de0c2d 100644
--- a/core/shard/src/metrics.rs
+++ b/core/shard/src/metrics.rs
@@ -44,6 +44,7 @@ use prometheus_client::registry::Registry;
use std::sync::{Arc, OnceLock};
use iggy_common::ConsumerKind;
+use message_bus::ReplicaReadMetrics;
/// Label for `frame_drops_total`.
///
@@ -246,6 +247,8 @@ pub struct ShardMetrics {
metadata_prepare_gap_drops_total: Counter,
metadata_read_frontier_refusals_total: Counter,
client_requests_denied_queue_full_total: Counter,
+ replica_socket_reads_total: Counter,
+ replica_inbound_frames_total: Counter,
partition_consumer_offsets_denied_total: Family<ConsumerOffsetKindLabel,
Counter>,
consumer_offset_denied_counters: [Counter; 2],
partition_consumer_offsets_stranded: Family<ConsumerOffsetKindLabel,
Gauge>,
@@ -320,6 +323,8 @@ impl ShardMetrics {
metadata_prepare_gap_drops_total: Counter::default(),
metadata_read_frontier_refusals_total: Counter::default(),
client_requests_denied_queue_full_total: Counter::default(),
+ replica_socket_reads_total: Counter::default(),
+ replica_inbound_frames_total: Counter::default(),
partition_consumer_offsets_denied_total,
consumer_offset_denied_counters,
partition_consumer_offsets_stranded,
@@ -347,6 +352,18 @@ impl ShardMetrics {
self.partition_wal_errors.inc_by(metrics.failed_writes);
}
+ /// Fold one sweep's worth of replica socket-read deltas in.
+ ///
+ /// Deltas, never running totals: [`ReplicaReadMetrics`] comes from a
+ /// take that resets the source, so feeding cumulative values here
+ /// would double count every sweep. Both counters stay at zero on a
+ /// shard that owns no plaintext replica link, which is what makes
+ /// them identify the link shard.
+ pub fn record_replica_reads(&self, metrics: &ReplicaReadMetrics) {
+ self.replica_socket_reads_total.inc_by(metrics.reads);
+ self.replica_inbound_frames_total.inc_by(metrics.frames);
+ }
+
fn register_persistence(&self, registry: &mut Registry) {
registry.register(
"partition_wal_disk_bytes",
@@ -823,6 +840,16 @@ impl ShardMetrics {
"replicated prepares dropped out of order by a backup's gap check",
self.partition_prepare_gap_drops_total.clone(),
);
+ registry.register(
+ "replica_socket_reads",
+ "completed socket reads on this shard's plaintext replica links",
+ self.replica_socket_reads_total.clone(),
+ );
+ registry.register(
+ "replica_inbound_frames",
+ "frames decoded off this shard's plaintext replica links",
+ self.replica_inbound_frames_total.clone(),
+ );
registry.register(
"metadata_prepare_gap_drops",
"replicated metadata prepares dropped out of order by a backup's
gap check",