blob: 42857e723a1e94e383b5623ed82bf9ff33365fee [file]
// 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.
pub mod builder;
pub mod config;
pub mod coordinator;
pub mod metrics;
mod router;
pub mod shards_table;
pub use config::CoordinatorConfig;
#[cfg(any(test, feature = "simulator"))]
use consensus::LocalPipeline;
use consensus::{
CommitOutcome, Consensus, MetadataHandle, MuxPlane, PartitionsHandle, Pipeline, Plane,
PlaneKind, Sequencer, VsrAction, VsrConsensus,
};
use iggy_binary_protocol::{
Command2, CommitHeader, DoViewChangeHeader, GenericHeader, Operation, PrepareHeader,
PrepareOkHeader, RequestHeader, StartViewChangeHeader, StartViewHeader,
};
#[cfg(any(test, feature = "simulator"))]
use iggy_common::PartitionStats;
use iggy_common::variadic;
use iggy_common::{IggyExpiry, IggyTimestamp};
use journal::{Journal, JournalHandle};
use message_bus::MessageBus;
use message_bus::client_listener::RequestHandler;
use message_bus::fd_transfer::DupedFd;
use message_bus::installer::conn_info::{ClientConnMeta, ClientTransportKind};
use message_bus::replica::listener::MessageHandler;
use metadata::IggyMetadata;
use metadata::impls::metadata::StreamsFrontend;
use metadata::stm::StateMachine;
use partitions::{IggyPartition, IggyPartitions, PollFragments, PollingArgs, PollingConsumer};
use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
use server_common::{MESSAGE_ALIGN, Message, MessageBag, iobuf::Frozen};
use shards_table::ShardsTable;
use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
#[cfg(any(test, feature = "simulator"))]
use std::sync::Arc;
pub type ShardPlane<B, J, S, M> =
MuxPlane<variadic!(IggyMetadata<VsrConsensus<B>, J, S, M>, IggyPartitions<B>)>;
pub struct ShardIdentity {
pub id: u16,
pub name: String,
}
impl ShardIdentity {
#[must_use]
pub const fn new(id: u16, name: String) -> Self {
Self { id, name }
}
}
pub struct PartitionConsensusConfig<B>
where
B: MessageBus,
{
pub cluster_id: u128,
/// Cluster-wide VSR replica id; independent of `IggyShard::id`.
pub self_replica_id: u8,
pub replica_count: u8,
pub bus: B,
}
/// Replica id + count bundle.
///
/// Adjacent `u8` params (`self_replica_id`, `replica_count`) were a
/// silent-swap hazard at the call site; the named struct gives the type
/// system a chance to catch a misorder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplicaTopology {
pub self_replica_id: u8,
pub replica_count: u8,
}
impl ReplicaTopology {
#[must_use]
pub const fn new(self_replica_id: u8, replica_count: u8) -> Self {
Self {
self_replica_id,
replica_count,
}
}
}
impl<B> PartitionConsensusConfig<B>
where
B: MessageBus,
{
#[must_use]
pub const fn new(cluster_id: u128, topology: ReplicaTopology, bus: B) -> Self {
Self {
cluster_id,
self_replica_id: topology.self_replica_id,
replica_count: topology.replica_count,
bus,
}
}
}
/// Bounded mpsc channel sender (blocking send).
pub type Sender<T> = crossfire::MTx<crossfire::mpsc::Array<T>>;
/// Bounded mpsc channel receiver (async recv).
pub type Receiver<T> = crossfire::AsyncRx<crossfire::mpsc::Array<T>>;
/// Create a bounded mpsc channel with a blocking sender and async receiver.
#[must_use]
pub fn channel<T: Send + 'static>(capacity: usize) -> (Sender<T>, Receiver<T>) {
crossfire::mpsc::bounded_blocking_async(capacity)
}
/// Cross-shard metadata consensus submit.
///
/// The metadata consensus group lives only on shard 0. When a client
/// connection homes on a peer shard, that shard verifies credentials and
/// owns the session locally, but the consensus proposal (`Register` /
/// `Logout`) must execute on shard 0. The peer hands just that step here
/// and awaits the committed op number over `reply` (`None` = transient
/// submit failure; all `MetadataSubmitError` variants are transient by
/// contract, so the caller retries rather than distinguishing them).
pub enum MetadataSubmit {
Register {
vsr_client_id: u128,
user_id: u32,
reply: Sender<Option<u64>>,
},
Logout {
vsr_client_id: u128,
session: u64,
request: u64,
reply: Sender<Option<u64>>,
},
/// A peer (home) shard relays a client's replicated request to shard 0
/// and awaits the committed reply over `reply` (`None` on a transient
/// submit failure). The home shard then writes the reply to the
/// originating socket -- it owns the connection and the
/// `vsr -> transport` mapping, which shard 0 cannot reconstruct from the
/// consensus client id.
ClientRequest {
request: Message<GenericHeader>,
reply: Sender<Option<Message<GenericHeader>>>,
},
/// A shard's partition reconciler asks shard 0 to complete a cooperative
/// consumer-group revocation (the source drained the partition or it timed
/// out). Server-originated: shard 0 proposes it through metadata consensus
/// with no client session. Fire-and-forget + idempotent -- `reply` carries
/// the commit op (or `None` on a transient submit failure) for logging only.
CompleteRevocation {
stream_id: u32,
topic_id: u32,
group_id: u64,
source_client_id: u128,
partition_id: u32,
reply: Sender<Option<u64>>,
},
}
/// Handler shard 0 runs for an inbound [`MetadataSubmit`].
///
/// server-ng wires it to `submit_register_in_process` /
/// `submit_logout_in_process` / `submit_request_in_process` and sends the
/// result back over the frame's `reply` sender. A peer shard (no consensus)
/// must never receive this frame.
pub type MetadataSubmitHandler = Rc<dyn Fn(MetadataSubmit)>;
/// One connected client's identity, as seen by the shard that homes it.
///
/// Gathered from every shard for `get_clients` (shared-nothing: each shard
/// knows only its own connections, so the full list requires a broadcast
/// -- see [`IggyShard::list_all_clients`]).
#[derive(Debug, Clone)]
pub struct ConnectedClientInfo {
/// Transport (coordinator-minted) client id; top 16 bits are the home
/// shard. The wire `client_id` is the `u32` seq tail.
pub client_id: u128,
/// Bound VSR client id, if the connection completed register. Keys the
/// connection to its consumer-group memberships (stored by VSR id, not
/// transport id).
pub vsr_client_id: Option<u128>,
pub user_id: Option<u32>,
pub transport: ClientTransportKind,
pub address: std::net::SocketAddr,
/// SDK identity from the login version prefix; `None` pre-login.
/// In-memory only: the `get_clients` wire response is shared with the
/// legacy server, so exposing these on the wire is a follow-up.
pub sdk_name: Option<String>,
pub sdk_version: Option<String>,
/// Packed protocol version, see `iggy_binary_protocol::ProtocolVersion`.
pub protocol_version: Option<u32>,
}
/// Handler each shard runs for an inbound [`LifecycleFrame::ListClients`].
/// server-ng wires it to read the shard's `SessionManager` and push its
/// connected clients back over the carried reply sender.
pub type ListClientsHandler = Rc<dyn Fn(Sender<Vec<ConnectedClientInfo>>)>;
/// Per-shard reply budget for the `list_all_clients` gather. A shard that
/// doesn't answer within this window is skipped (partial result) so one
/// wedged shard can't hang the read.
const LIST_CLIENTS_GATHER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// A read executed on the shard that owns a partition: a message poll or a
/// consumer-offset lookup. Carried by [`LifecycleFrame::PartitionRead`];
/// see [`IggyShard::partition_read`].
#[derive(Debug)]
pub enum PartitionRead {
Poll {
consumer: PollingConsumer,
args: PollingArgs,
},
ConsumerOffset {
consumer: PollingConsumer,
},
/// Cooperative-rebalance classification: the group's last-polled and
/// committed offsets on this partition, so the join enrichment can tell an
/// in-flight partition (committed < last-polled) from a never-polled/drained
/// one. `group_id` is the monotonic consumer-group id (offset key).
GroupOffsetState {
group_id: u64,
},
/// Drop the group's ephemeral `last_polled` mark on this partition. The
/// join-time gather issues this when it finds an uncommitted `last_polled`
/// for a partition no live member owns: the residue of a since-removed
/// member (reconnect). Clearing it stops a later join in the same restart
/// from misreading the dead mark as a live in-flight hold. `group_id` is the
/// monotonic consumer-group id (offset key).
ClearGroupLastPolled {
group_id: u64,
},
/// Resolve a client `DeleteSegments` count into a concrete truncation
/// offset: the `end_offset` of the `count`-th oldest sealed segment. Run on
/// the owning shard, which alone holds the partition's segment state.
ResolveSegmentDeleteOffset {
count: u32,
},
}
/// Reply to a [`PartitionRead`].
#[derive(Debug)]
pub enum PartitionReadReply {
Poll {
fragments: PollFragments,
current_offset: u64,
},
ConsumerOffset {
stored: Option<u64>,
current_offset: u64,
},
/// Reply to [`PartitionRead::GroupOffsetState`]: the group's last-polled and
/// committed offsets on this partition (each `None` if absent).
GroupOffsetState {
last_polled: Option<u64>,
committed: Option<u64>,
},
/// Acknowledges a [`PartitionRead::ClearGroupLastPolled`].
Ack,
/// Reply to [`PartitionRead::ResolveSegmentDeleteOffset`]: the resolved
/// truncation offset, or `None` when the partition has no sealed segments
/// to delete. `lagging` means this replica has not converged on the
/// replicated log (follower, mid-view-change, or `commit_min` behind
/// `commit_max`): a `None` offset is then transient rather than a settled
/// no-op, since sealed segments may exist that this replica has not
/// learned about. A converged replica's committed-but-unflushed resident
/// tail does NOT make the no-op transient.
SegmentDeleteOffset {
up_to_offset: Option<u64>,
lagging: bool,
},
/// The owning shard has no materialised partition for the namespace
/// (unknown, tombstoned, or mid-reconcile). Callers surface an error
/// instead of an empty result.
NotFound,
}
/// Handler the owning shard runs for an inbound
/// [`LifecycleFrame::PartitionRead`]. server-ng wires it to its partitions
/// plane; the handler pushes the result back over the carried reply sender.
pub type PartitionReadHandler =
Rc<dyn Fn(IggyNamespace, PartitionRead, Sender<PartitionReadReply>)>;
/// Reply budget for a cross-shard [`IggyShard::partition_read`]. Bounds a
/// wedged owning shard; the caller maps expiry to a client-visible error.
///
/// 10s, not lower: a disk poll over tiny segments opens one file per
/// segment, so a 1024-message read can legitimately take several seconds
/// on an oversubscribed host (8 parallel test clusters). Expiry is masked
/// as an empty poll downstream while the abandoned walk keeps running, so
/// a too-small budget turns slow reads into missing data plus duplicated
/// walks from client retries. Must stay below the SDK's 30s request
/// deadline.
const PARTITION_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Create a bounded inter-shard channel 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`.
#[must_use]
pub fn shard_channel(owner_shard: u16, capacity: usize) -> (TaggedSender, Receiver<ShardFrame>) {
let (tx, rx) = channel::<ShardFrame>(capacity);
(TaggedSender::new(owner_shard, tx), rx)
}
/// Build canonical-ordered `(senders, inboxes)` pair 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.
///
/// Receivers are wrapped in `Option` because [`Receiver`] (crossfire
/// `AsyncRx`) is non-cloneable on purpose; bootstrap takes the slot 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>>>) {
let mut senders = Vec::with_capacity(total_shards as usize);
let mut inboxes = Vec::with_capacity(total_shards as usize);
for shard_id in 0..total_shards {
let (tx, rx) = shard_channel(shard_id, capacity);
senders.push(tx);
inboxes.push(Some(rx));
}
(senders, inboxes)
}
/// A [`Sender`] annotated with the id of the shard whose paired receiver it
/// feeds.
///
/// 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
/// permuted `Vec<Sender<_>>` would silently misroute every setup, mapping,
/// and forward frame. Construct senders through [`shard_channel`] (or
/// [`TaggedSender::new`]) at the channel-creation site; the coordinator and
/// [`IggyShard`] ctors then validate `senders[i].shard_id() == i`,
/// returning [`ShardCtorError`] if violated.
pub struct TaggedSender {
shard_id: u16,
inner: 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).
#[must_use]
pub const fn new(shard_id: u16, inner: Sender<ShardFrame>) -> Self {
Self { shard_id, inner }
}
#[must_use]
pub const fn shard_id(&self) -> u16 {
self.shard_id
}
}
impl Clone for TaggedSender {
fn clone(&self) -> Self {
Self {
shard_id: self.shard_id,
inner: self.inner.clone(),
}
}
}
impl std::ops::Deref for TaggedSender {
type Target = Sender<ShardFrame>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
/// Error returned by [`IggyShard::new`] and the shard builder when ctor
/// preconditions are violated.
///
/// Both are bootstrap programming errors: the surrounding crate either
/// built the `senders` vec out of canonical order, or produced more
/// shards than the inter-shard addressing scheme supports. Surfaced as
/// `Err` instead of panicking so the host process can log and abort with
/// a typed error.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ShardCtorError {
#[error(
"senders[{index}] carries shard_id {actual}; inter-shard vec must be in canonical \
order (senders[i].shard_id() == i)"
)]
SenderOrderingInvalid {
index: usize,
expected: u16,
actual: u16,
},
#[error("shard count {count} does not fit in u16; inter-shard frame addressing is u16-indexed")]
ShardCountOverflow { count: usize },
#[error(
"shard-0 coordinator senders length {senders} does not match total_shards {total_shards} \
(total_shards must be >= 1 and equal senders.len())"
)]
CoordinatorSendersMismatch { senders: usize, total_shards: u16 },
}
/// Validate the canonical ordering `senders[i].shard_id() == i`.
/// Returns `Err` for the first index that violates the invariant.
pub(crate) fn validate_sender_ordering(senders: &[TaggedSender]) -> Result<(), ShardCtorError> {
for (idx, sender) in senders.iter().enumerate() {
let expected = u16::try_from(idx).map_err(|_| ShardCtorError::ShardCountOverflow {
count: senders.len(),
})?;
let actual = sender.shard_id();
if actual != expected {
return Err(ShardCtorError::SenderOrderingInvalid {
index: idx,
expected,
actual,
});
}
}
Ok(())
}
/// 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.
/// 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).
///
/// 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.
#[non_exhaustive]
pub enum LifecycleFrame {
/// Shard 0 distributes an inbound replica TCP connection fd to the
/// owning shard BEFORE any byte is read (blind delegation - the peer
/// id is unknown until the `ReplicaHello` is read). The receiving
/// shard wraps the fd, runs the acceptor handshake in its own
/// spawned task (`message_bus::replica::handshake`), installs the
/// connection on success, and answers shard 0 with
/// [`LifecycleFrame::ReplicaInboundHandshakeDone`] echoing `slot`.
/// The `fd` is an owning [`DupedFd`] so that a frame dropped
/// unprocessed (shutdown, pump drain abort, router panic before
/// `install_*_fd`) closes the dup instead of leaking it.
ReplicaInboundSetup { fd: DupedFd, slot: u64 },
/// Shard 0 dialed the higher-id peer `replica_id` and delegates the
/// raw connection; the receiving shard runs the dialer handshake
/// half, installs on success, and answers shard 0 with
/// [`LifecycleFrame::ReplicaOutboundHandshakeDone`] so the
/// pending-dial entry clears and the reconnect sweep may redial on
/// failure.
ReplicaOutboundSetup { fd: DupedFd, replica_id: u8 },
/// Owning shard -> shard 0: a delegated inbound handshake finished
/// (any outcome). Releases the global in-flight cap slot. Lost acks
/// are covered by the slot's deadline expiry on shard 0.
ReplicaInboundHandshakeDone { slot: u64 },
/// Owning shard -> shard 0: a delegated outbound handshake finished
/// (any outcome). Clears the pending-dial entry for `replica_id`.
/// Lost acks are covered by the entry's deadline expiry on shard 0.
ReplicaOutboundHandshakeDone { replica_id: u8 },
/// Shard 0 distributes an inbound SDK client TCP connection fd to the
/// owning shard. The receiving shard wraps the fd and installs client
/// reader / writer tasks locally. The owning shard is encoded in the top
/// 16 bits of `meta.client_id`.
ClientConnectionSetup { fd: DupedFd, meta: ClientConnMeta },
/// Shard 0 distributes an inbound SDK WebSocket client's pre-upgrade
/// TCP connection fd to the owning shard. The HTTP-Upgrade handshake
/// has NOT run yet at this point: the fd is plain TCP, the dup is
/// safe (cross-shard fd-delegation only happens for plain TCP), and
/// `compio_ws::WebSocketStream<TcpStream>`'s `!Send` constraint
/// (compio `Rc<...>` driver state, post-upgrade) does not apply.
/// The receiving shard wraps the fd, runs `compio_ws::accept_async`,
/// then installs client reader / writer tasks locally via
/// `message_bus::installer::install_client_ws_fd`. Owning shard is
/// encoded in the top 16 bits of `meta.client_id`.
///
/// QUIC clients deliberately do NOT get an analog variant: a
/// `compio_quic::Endpoint` binds one UDP socket and demuxes incoming
/// packets to per-connection `quinn-proto::Connection` objects by
/// Connection ID. Per-connection TLS / packet-number / congestion
/// state is non-serialisable and tied to the endpoint's reactor.
/// Shard 0 therefore terminates QUIC locally and uses the existing
/// `ForwardClientSend` variant for outbound traffic.
ClientWsConnectionSetup { fd: DupedFd, meta: ClientConnMeta },
/// A non-owning shard forwards a replica send to the owning shard's
/// local bus; the owning shard then takes the fast path.
ForwardReplicaSend {
replica_id: u8,
msg: Frozen<MESSAGE_ALIGN>,
},
/// A shard that doesn't hold the client's TCP connection forwards a
/// client send to the owning shard (top 16 bits of `client_id`).
ForwardClientSend {
client_id: u128,
msg: Frozen<MESSAGE_ALIGN>,
},
/// A peer shard hands a metadata consensus submit (login/logout) to
/// shard 0, the metadata consensus owner. The committed op returns over
/// the `reply` sender carried in [`MetadataSubmit`]. Always addressed to
/// shard 0; processing it on a peer is a routing bug.
MetadataSubmit(MetadataSubmit),
/// Broadcast query for `get_clients`: every shard replies with the
/// clients whose connections it homes, over `reply`. Unlike
/// [`MetadataSubmit`] this is sent to ALL shards (shared-nothing: each
/// shard knows only its own connections). See
/// [`IggyShard::list_all_clients`].
ListClients {
reply: Sender<Vec<ConnectedClientInfo>>,
},
/// Execute a partition read (message poll / consumer-offset lookup) on
/// the shard that owns `namespace` and push the result back over
/// `reply`. See [`IggyShard::partition_read`].
PartitionRead {
namespace: IggyNamespace,
read: PartitionRead,
reply: Sender<PartitionReadReply>,
},
/// Shard 0 broadcasts after a partition-shaped metadata commit; wakes
/// the per-shard reconciler. No payload: reconciler re-reads target
/// state. Drops covered by the periodic safety tick.
MetadataCommitTick,
/// Wake marker for the reconciler-to-pump funnel. Pump drains the
/// shard's `reconcile_queue` on receipt; tail drain on every frame
/// catches dropped markers.
ReconcileApply,
/// Per-shard segment-cleaner request: delete expired / over-budget sealed
/// segments of `namespace` on the pump, serialized with reads. The timer
/// task resolves `message_expiry` / `max_bytes` from metadata and stamps
/// `now`; the pump only mutates. Local and unreplicated — each replica
/// trims its own log (divergence is invisible: reads hit the primary).
CleanPartition {
namespace: IggyNamespace,
now: IggyTimestamp,
message_expiry: IggyExpiry,
max_bytes: Option<u64>,
},
/// Reconciler-staged enforcement of a committed `TruncatePartition`
/// watermark: delete sealed segments up to `up_to_offset` on the pump,
/// serialized with reads. Each replica applies the committed offset
/// locally and idempotently.
TruncatePartition {
namespace: IggyNamespace,
up_to_offset: u64,
},
/// Reconciler-staged enforcement of a committed `PurgeTopic`: reset the
/// partition to a single empty segment at offset 0 and clear consumer
/// offsets on the pump, serialized with reads. `generation` is the
/// committed purge generation; the pump no-ops if the partition already
/// applied it, so a redundant reconcile pass never re-wipes live data.
PurgePartition {
namespace: IggyNamespace,
generation: u64,
},
}
/// Reconciler-staged partition mutation.
///
/// Funnelling through the pump keeps `IggyPartitions` single-writer:
/// without it the cooperative `.await` scheduler would race
/// `insert` / `remove` against the pump's live `&mut IggyPartition` (UB).
pub enum ReconcileOp<B>
where
B: MessageBus,
{
/// Materialise an owned partition. Boxed to keep variants size-balanced
/// (`clippy::large_enum_variant`). `epoch` is the committed
/// `Partition::created_revision`, stored on the routing row so a later
/// reconcile pass can detect a slab-key-reused stale partition.
InsertOwned {
namespace: IggyNamespace,
partition: Box<IggyPartition<B>>,
epoch: u64,
},
/// Seed a routing row for a partition owned by a peer shard.
InsertRouted {
namespace: IggyNamespace,
owner: ShardId,
epoch: u64,
},
/// Final phase of teardown: drop the `IggyPartition` value and clear
/// the tombstone. The reconciler sets the tombstone + removes the
/// `shards_table` row synchronously *before* awaiting the disk delete
/// (writers are fenced via [`IggyPartitions::is_tombstoned`]), so by
/// the time this op runs the disk hierarchy is already gone.
ConfirmRemove { namespace: IggyNamespace },
/// Drop a routing row (peer's partition gone from committed metadata).
RemoveRouted { namespace: IggyNamespace },
}
/// Inter-shard channel envelope.
///
/// Concrete enum; no generic. Consensus dispatches are fire-and-forget by
/// VSR design (replies travel as their own wire-level messages: `Reply`
/// to clients, `PrepareOk` to the primary), so no response channel rides
/// in the frame.
#[non_exhaustive]
pub enum ShardFrame {
/// A consensus protocol message (Request / Prepare / `PrepareOk` /
/// view-change family / Commit). Fire-and-forget. Drops on full inbox
/// are recovered by VSR retransmit timers.
///
/// `target_shard` is stamped by the sender at enqueue time, so the
/// receiving pump never re-derives routing in release builds. The
/// receiver still validates `target_shard == self.id` and drops
/// frames stamped for the wrong shard (`MISROUTED`) to preserve the
/// single-pump invariant under any caller bug.
Consensus {
target_shard: u16,
message: Message<GenericHeader>,
},
/// A connection setup or cross-shard forward frame. Drop recovery
/// depends on the frame class: [`LifecycleFrame::ForwardReplicaSend`]
/// is VSR-covered, connection-setup frames are recovered by the
/// connector's periodic reconnect sweep, but
/// [`LifecycleFrame::ForwardClientSend`] is terminal - no retransmit,
/// the client never receives the reply.
Lifecycle(LifecycleFrame),
}
impl ShardFrame {
/// Create a consensus frame addressed to `target_shard`. The sender
/// is the routing authority; `accept_frame_for_self` compares this
/// stamp against the receiving shard id in O(1).
#[must_use]
pub const fn consensus(target_shard: u16, message: Message<GenericHeader>) -> Self {
Self::Consensus {
target_shard,
message,
}
}
/// Create a lifecycle frame.
#[must_use]
pub const fn lifecycle(payload: LifecycleFrame) -> Self {
Self::Lifecycle(payload)
}
}
pub struct IggyShard<B, MJ, S, M, T = ()>
where
B: MessageBus,
{
pub id: u16,
pub name: String,
pub plane: ShardPlane<B, MJ, S, M>,
/// Handle to the local bus. Retained alongside the bus owned by every
/// consensus plane so the router can reach the `ConnectionInstaller`
/// surface without going through consensus.
pub bus: B,
/// Callback attached to every delegated replica connection installed
/// on this shard. The bus' reader task invokes this for each inbound
/// consensus message; the callback is typically `|_, msg| shard.dispatch(msg)`.
on_replica_message: MessageHandler,
/// Callback attached to every delegated client connection installed on
/// this shard. Invoked for each inbound `Request` frame.
on_client_request: RequestHandler,
/// Handler for inbound [`MetadataSubmit`] frames. Only shard 0 receives
/// these (it owns the metadata consensus group); peers send them here
/// via [`Self::forward_metadata_submit`]. Defaults to a no-op for the
/// simulator stub ctor.
on_metadata_submit: MetadataSubmitHandler,
/// Handler for inbound [`LifecycleFrame::ListClients`] broadcast
/// queries. Every shard receives these (not just shard 0); server-ng
/// wires it to its per-shard `SessionManager`. Defaults to a no-op for
/// the simulator stub ctor.
on_list_clients: ListClientsHandler,
/// Handler for inbound [`LifecycleFrame::PartitionRead`] queries.
/// server-ng wires it to this shard's partitions plane. Defaults to a
/// no-op for the simulator stub ctor.
on_partition_read: PartitionReadHandler,
/// Channel senders to every shard, indexed by shard id.
/// Includes a sender to self so that local routing goes through the
/// same channel path as remote routing.
///
/// [`assert_sender_ordering`] is invoked in the ctor so `senders[i]`
/// is guaranteed to feed the shard whose `id == i`. Call sites can
/// therefore index by `target_shard` without re-checking.
senders: Vec<TaggedSender>,
/// Total shard count, cached from `senders.len()` at construction.
/// `senders` is immutable post-ctor, so consensus routing reads this
/// rather than recomputing the `usize -> u32` conversion per frame.
shard_count: u32,
/// Receiver end of this shard's inbox. Peer shards (and self) send
/// messages here via the corresponding sender.
inbox: Receiver<ShardFrame>,
/// Partition namespace -> owning shard lookup.
shards_table: T,
/// Stored for `init_partition` (simulator-only). Production materialises
/// VSR replicas through `partition_helpers::build_partition_fresh`, which
/// passes the topology + cluster id directly.
#[cfg_attr(not(any(test, feature = "simulator")), allow(dead_code))]
partition_consensus: PartitionConsensusConfig<B>,
/// Shard 0 coordinator, supplied at construction. Holds round-robin
/// state for replica and client delegation. `None` on non-zero shards
/// and in single-shard tests that bypass the coordinator.
coordinator: Option<Rc<crate::coordinator::ShardZeroCoordinator>>,
/// Per-shard observability counters. Cloned at metric increment sites,
/// so cheap (`Arc` clone) regardless of label cardinality.
metrics: crate::metrics::ShardMetrics,
/// Late-bound `MetadataCommitTick` handler. `None` until reconciler
/// wires it; pre-wire ticks drop with a metric bump.
metadata_tick_handler: RefCell<Option<Rc<dyn Fn()>>>,
/// Reconciler → pump funnel. Borrow discipline: every push / drain
/// runs without `.await` inside the borrow.
reconcile_queue: RefCell<VecDeque<ReconcileOp<B>>>,
/// Partition-plane frames that arrived before this shard's reconciler
/// materialised the namespace (post-`CreateTopic` convergence window).
/// Parked here instead of dropped -- there is no consensus retransmit
/// driver in production yet -- and re-dispatched when the matching
/// `ReconcileOp::InsertOwned` lands. Bounded per namespace; overflow
/// drops the frame (at-least-once: client/primary retries recover).
pending_partition_frames: RefCell<HashMap<IggyNamespace, Vec<Message<GenericHeader>>>>,
}
impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
where
B: MessageBus + 'static,
T: ShardsTable,
{
/// Create a new shard with channel links and a shards table.
///
/// * `bus` - shard-local bus handle (kept alongside the buses owned
/// by the consensus planes so the router can reach the
/// `ConnectionInstaller` surface directly).
/// * `senders` - one [`TaggedSender`] per shard. The ctor asserts
/// `senders[i].shard_id() == i`; use [`shard_channel`] at
/// 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.
/// * `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:
/// the coordinator is injected at construction time so an
/// `IggyShard` cannot appear half-wired to a reader.
/// * `metrics` - per-shard observability handle; currently the
/// `frame_drops_total` counter.
///
/// # Errors
///
/// Returns [`ShardCtorError::SenderOrderingInvalid`] if `senders` is
/// not in canonical order (any `senders[i].shard_id() != i`) and
/// [`ShardCtorError::ShardCountOverflow`] if `senders.len()` does not
/// fit in `u16`. Both are bootstrap programming errors: the
/// permutation would silently misroute every inter-shard frame, or
/// addressing space (u16) would wrap.
#[allow(clippy::too_many_arguments)]
pub fn new(
identity: ShardIdentity,
bus: B,
on_replica_message: MessageHandler,
on_client_request: RequestHandler,
on_metadata_submit: MetadataSubmitHandler,
on_list_clients: ListClientsHandler,
on_partition_read: PartitionReadHandler,
metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
partitions: IggyPartitions<B>,
senders: Vec<TaggedSender>,
inbox: Receiver<ShardFrame>,
shards_table: T,
partition_consensus: PartitionConsensusConfig<B>,
coordinator: Option<Rc<crate::coordinator::ShardZeroCoordinator>>,
metrics: crate::metrics::ShardMetrics,
) -> Result<Self, ShardCtorError> {
validate_sender_ordering(&senders)?;
let shard_count =
u32::try_from(senders.len()).map_err(|_| ShardCtorError::ShardCountOverflow {
count: senders.len(),
})?;
let plane = MuxPlane::new(variadic!(metadata, partitions));
let ShardIdentity { id, name } = identity;
Ok(Self {
id,
name,
plane,
bus,
on_replica_message,
on_client_request,
on_metadata_submit,
on_list_clients,
on_partition_read,
senders,
shard_count,
inbox,
shards_table,
partition_consensus,
coordinator,
metrics,
metadata_tick_handler: RefCell::new(None),
reconcile_queue: RefCell::new(VecDeque::new()),
pending_partition_frames: RefCell::new(HashMap::new()),
})
}
/// Hand a metadata consensus submit (login/logout) to shard 0.
///
/// Sends a [`LifecycleFrame::MetadataSubmit`] into shard 0's inbox. The
/// caller owns the matching [`Receiver`] (paired with the `reply` sender
/// inside `submit`) and awaits the committed op there. On a full /
/// disconnected shard-0 inbox the frame is dropped; the dropped `reply`
/// sender then surfaces as a recv error the caller maps to a transient
/// failure.
pub fn forward_metadata_submit(&self, submit: MetadataSubmit) {
let frame = ShardFrame::lifecycle(LifecycleFrame::MetadataSubmit(submit));
if let Err(error) = self.senders[0].try_send(frame) {
self.metrics.record_frame_drop(
crate::metrics::frame_drop_variant::CONSENSUS,
crate::coordinator::classify_try_send_err(&error),
);
tracing::warn!(
shard = self.id,
"forward_metadata_submit: shard-0 inbox rejected frame: {error:?}"
);
}
}
/// Gather every shard's connected clients (the `get_clients`
/// scatter-gather). Broadcasts [`LifecycleFrame::ListClients`] to all
/// shards -- including self, so the local shard answers over the same
/// channel path -- and collects their replies.
///
/// Bounded: a shard that doesn't reply within
/// [`LIST_CLIENTS_GATHER_TIMEOUT`] is skipped and the partial result is
/// logged, so one wedged shard cannot hang the read. Callers should
/// treat the result as best-effort-complete.
#[allow(clippy::future_not_send)]
pub async fn list_all_clients(&self) -> Vec<ConnectedClientInfo> {
let shard_count = self.shard_count as usize;
let (reply_tx, reply_rx) = channel::<Vec<ConnectedClientInfo>>(shard_count.max(1));
let mut expected = 0usize;
for sender in &self.senders {
let frame = ShardFrame::lifecycle(LifecycleFrame::ListClients {
reply: reply_tx.clone(),
});
if let Err(error) = sender.try_send(frame) {
tracing::warn!(
shard = self.id,
target = sender.shard_id(),
"list_all_clients: inbox rejected ListClients frame: {error:?}"
);
} else {
expected += 1;
}
}
// Drop the local handle so `recv` returns `Err` once every shard's
// reply sender is dropped (defensive; we also bound by count).
drop(reply_tx);
let mut clients = Vec::new();
let mut received = 0usize;
// One deadline across the whole gather: each `recv` waits only the
// remaining budget, so total wall time is bounded by
// LIST_CLIENTS_GATHER_TIMEOUT (not expected * timeout), while the
// partial results gathered so far are still returned on expiry.
let deadline = std::time::Instant::now() + LIST_CLIENTS_GATHER_TIMEOUT;
while received < expected {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
tracing::warn!(
shard = self.id,
received,
expected,
"list_all_clients: gather budget exhausted; returning partial result"
);
break;
}
match compio::time::timeout(remaining, reply_rx.recv()).await {
Ok(Ok(batch)) => {
clients.extend(batch);
received += 1;
}
Ok(Err(_)) => break, // all reply senders dropped
Err(_) => {
tracing::warn!(
shard = self.id,
received,
expected,
"list_all_clients: gather timed out; returning partial result"
);
break;
}
}
}
clients
}
/// Run a partition read (message poll / consumer-offset lookup) on the
/// shard owning `namespace` and await the reply.
///
/// Routes a [`LifecycleFrame::PartitionRead`] through the shards table
/// (self-sends included, so a locally-owned partition takes the same
/// path). `None` = unroutable namespace, full owning-shard inbox,
/// dropped reply sender, or [`PARTITION_READ_TIMEOUT`] expiry; the
/// caller maps it to a client-visible error.
#[allow(clippy::future_not_send)]
pub async fn partition_read(
&self,
namespace: IggyNamespace,
read: PartitionRead,
) -> Option<PartitionReadReply> {
let Some(target) = self.shards_table.shard_for(namespace) else {
tracing::warn!(
shard = self.id,
namespace_raw = namespace.inner(),
"partition_read: namespace not routable (not materialised yet or deleted)"
);
return None;
};
let (reply_tx, reply_rx) = channel::<PartitionReadReply>(1);
let frame = ShardFrame::lifecycle(LifecycleFrame::PartitionRead {
namespace,
read,
reply: reply_tx,
});
let sender = self.senders.get(target as usize)?;
if let Err(error) = sender.try_send(frame) {
tracing::warn!(
shard = self.id,
target,
"partition_read: inbox rejected PartitionRead frame: {error:?}"
);
return None;
}
match compio::time::timeout(PARTITION_READ_TIMEOUT, reply_rx.recv()).await {
Ok(Ok(reply)) => Some(reply),
Ok(Err(_)) => {
tracing::warn!(
shard = self.id,
target,
"partition_read: reply sender dropped (handler not wired / shutdown)"
);
None
}
Err(_) => {
tracing::warn!(
shard = self.id,
target,
"partition_read: owning shard did not reply within budget"
);
None
}
}
}
/// Return a clone of the shard-0 coordinator handle, if attached.
/// Bootstrap uses this to wire the listener accept callbacks
/// (replica + client) to coordinator-driven fd-delegation instead
/// of installing connections locally on shard 0.
#[must_use]
pub fn coordinator(&self) -> Option<Rc<crate::coordinator::ShardZeroCoordinator>> {
self.coordinator.clone()
}
/// Create a shard without inter-shard channels or delegated connections.
///
/// Useful for the simulator where inbound messages are delivered
/// directly via [`on_message`](Self::on_message) instead of the TCP /
/// fd-transfer path. Installs no-op connection handlers because the
/// simulator never receives a replica connection-setup frame.
#[must_use]
pub fn without_inbox(
identity: ShardIdentity,
bus: B,
metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
partitions: IggyPartitions<B>,
shards_table: T,
partition_consensus: PartitionConsensusConfig<B>,
) -> Self {
// TODO(hubcio): crossfire's Flavor trait blocks unbounded channels
// with the current type setup; revisit when crossfire grows an
// unbounded variant or we replace it.
let (_tx, inbox) = channel(1);
let plane = MuxPlane::new(variadic!(metadata, partitions));
let ShardIdentity { id, name } = identity;
Self {
id,
name,
bus,
on_replica_message: std::rc::Rc::new(|_, _| {}),
on_client_request: std::rc::Rc::new(|_, _| {}),
on_metadata_submit: std::rc::Rc::new(|_| {}),
on_list_clients: std::rc::Rc::new(|_| {}),
on_partition_read: std::rc::Rc::new(|_, _, _| {}),
plane,
coordinator: None,
senders: Vec::new(),
// The simulator delivers inbound messages straight to
// `on_message`, bypassing the inter-shard router. The router's
// `shard_count` should therefore never be read on this path,
// but `pub fn dispatch` is still reachable; pinning to 1 keeps
// `% shard_count` from panicking if a future caller slips
// through, while preserving single-shard routing semantics.
shard_count: 1,
inbox,
shards_table,
partition_consensus,
metrics: crate::metrics::ShardMetrics::for_shard(),
metadata_tick_handler: RefCell::new(None),
reconcile_queue: RefCell::new(VecDeque::new()),
pending_partition_frames: RefCell::new(HashMap::new()),
}
}
#[must_use]
pub const fn shards_table(&self) -> &T {
&self.shards_table
}
#[must_use]
pub const fn metrics(&self) -> &crate::metrics::ShardMetrics {
&self.metrics
}
/// `None` removes the handler; subsequent ticks drop with a metric bump.
pub fn set_metadata_tick_handler(&self, handler: Option<Rc<dyn Fn()>>) {
*self.metadata_tick_handler.borrow_mut() = handler;
}
/// Returns `true` if a handler ran. Pump bumps the drop metric on `false`.
pub fn dispatch_metadata_commit_tick(&self) -> bool {
self.signal_reconcile_wake()
}
/// Internal: invoke the installed wake handler (same channel the
/// metadata commit tick uses). Called from `ConfirmRemove` so the
/// reconciler re-runs immediately after the pump drops a tombstoned
/// partition, tightening the delete-recreate-same-ns latency window
/// from one `reconcile_periodic_interval` to one pump-iter.
fn signal_reconcile_wake(&self) -> bool {
let handler = self.metadata_tick_handler.borrow().clone();
handler.is_some_and(|handler| {
handler();
true
})
}
/// Stage a partition mutation for the pump.
///
/// Marker `try_send` is best-effort; the pump's tail drain on every
/// frame catches dropped markers, so the queue never strands ops.
pub fn enqueue_reconcile_op(&self, op: ReconcileOp<B>) {
self.reconcile_queue.borrow_mut().push_back(op);
let Some(sender) = self.senders.get(self.id as usize) else {
return;
};
let _ = sender.try_send(ShardFrame::lifecycle(LifecycleFrame::ReconcileApply));
}
/// Stage a segment-cleaner pass for `namespace` on this shard's pump. The
/// timer task resolves retention config off-pump and stamps `now`; the pump
/// is the single writer of partition state, so the deletion runs there,
/// serialized with reads.
pub fn request_clean_partition(
&self,
namespace: IggyNamespace,
now: IggyTimestamp,
message_expiry: IggyExpiry,
max_bytes: Option<u64>,
) {
let Some(sender) = self.senders.get(self.id as usize) else {
return;
};
let _ = sender.try_send(ShardFrame::lifecycle(LifecycleFrame::CleanPartition {
namespace,
now,
message_expiry,
max_bytes,
}));
}
/// Stage a `TruncatePartition` enforcement for `namespace` on this shard's
/// pump: delete sealed segments up to `up_to_offset`. The reconciler calls
/// this after observing a committed delete watermark for an owned partition.
pub fn request_truncate_partition(&self, namespace: IggyNamespace, up_to_offset: u64) {
let Some(sender) = self.senders.get(self.id as usize) else {
return;
};
let _ = sender.try_send(ShardFrame::lifecycle(LifecycleFrame::TruncatePartition {
namespace,
up_to_offset,
}));
}
/// Stage a `PurgePartition` enforcement for `namespace` on this shard's
/// pump: reset the partition to empty at offset 0 and clear consumer
/// offsets. The reconciler calls this after observing a committed purge
/// generation newer than the partition's locally applied one.
pub fn request_purge_partition(&self, namespace: IggyNamespace, generation: u64) {
let Some(sender) = self.senders.get(self.id as usize) else {
return;
};
let _ = sender.try_send(ShardFrame::lifecycle(LifecycleFrame::PurgePartition {
namespace,
generation,
}));
}
/// Drain and apply staged [`ReconcileOp`]s on the pump task.
/// Synchronous: every arm is in-memory only. `ConfirmRemove`'s fsync +
/// blocking close is offloaded to a detached task so the pump doesn't
/// stall on bulk teardown.
pub fn apply_reconcile_ops(&self)
where
B: MessageBus + 'static,
{
let staged: Vec<ReconcileOp<B>> = {
let mut q = self.reconcile_queue.borrow_mut();
if q.is_empty() {
return;
}
q.drain(..).collect()
};
let self_shard_id = self.id;
let partitions = self.plane.partitions();
let mut confirmed_remove = false;
for op in staged {
match op {
ReconcileOp::InsertOwned {
namespace,
partition,
epoch,
} => {
// Idempotent apply, mirroring `ConfirmRemove` (idempotent
// via `remove`'s `None` early-return). The reconciler
// stages this from a task separate from the pump, so under
// a commit burst two passes can each observe
// `!contains(ns)` and build the same namespace before
// either drains here. A second unconditional `insert`
// would push a duplicate partition and overwrite the
// `ns -> idx` entry, orphaning the first (its VSR group +
// segment writers leak and `len` inflates). The discarded
// build is a fresh empty incarnation over the same on-disk
// path the kept one owns, so dropping it just closes a few
// fds.
if partitions.contains(&namespace) {
drop(partition);
continue;
}
partitions.insert(namespace, *partition);
self.shards_table.insert(
namespace,
PartitionLocation::new(ShardId::new(self_shard_id), epoch),
);
self.metrics.record_partition_materialised();
// Re-dispatch frames that arrived before this partition
// materialised (see `park_if_unmaterialised`). `dispatch`
// re-routes them onto our own inbox, so the pump
// processes them after this drain completes.
let parked = self
.pending_partition_frames
.borrow_mut()
.remove(&namespace);
if let Some(frames) = parked {
tracing::debug!(
shard = self_shard_id,
namespace_raw = namespace.inner(),
count = frames.len(),
"re-dispatching parked partition frames after materialisation"
);
for frame in frames {
if let Some(sender) = self.senders.get(self_shard_id as usize)
&& let Err(error) =
sender.try_send(ShardFrame::consensus(self_shard_id, frame))
{
tracing::warn!(
shard = self_shard_id,
namespace_raw = namespace.inner(),
"dropping parked partition frame: inbox rejected: {error:?}"
);
}
}
}
}
ReconcileOp::InsertRouted {
namespace,
owner,
epoch,
} => {
self.shards_table
.insert(namespace, PartitionLocation::new(owner, epoch));
}
ReconcileOp::ConfirmRemove { namespace } => {
// Tombstone bit set + shards_table row removed synchronously
// by the reconciler before this op was enqueued, so no
// in-flight frame can reach the partition between `remove`
// and the drop here. Teardown already unlinked the on-disk
// hierarchy via `delete_partitions_from_disk`, so the
// partition drops inline: its compio file handles close
// through io_uring without blocking, and no fsync is wanted
// on data that is already gone.
let removed = partitions.remove(&namespace);
partitions.untombstone(&namespace);
// A topic created then deleted before its `InsertOwned`
// pass never drains parked frames the normal way; reclaim
// them here so they cannot leak across many create-delete
// races (the partition is gone, so the frames are moot).
self.discard_parked_partition_frames(namespace);
self.metrics.record_partition_removed();
confirmed_remove = true;
if removed.is_none() {
tracing::trace!(
shard = self_shard_id,
namespace_raw = namespace.inner(),
"ConfirmRemove with no in-memory partition (retry after disk-delete failure)"
);
}
}
ReconcileOp::RemoveRouted { namespace } => {
self.shards_table.remove(&namespace);
self.discard_parked_partition_frames(namespace);
}
}
}
if confirmed_remove {
// Re-wake the reconciler once per drain batch so a delete→recreate
// of a namespace that landed in STM while the unlink was in-flight
// materialises within one pump-iter, not one
// `reconcile_periodic_interval`. The wake channel is capacity-1, so
// a per-op wake would coalesce anyway; firing once avoids K
// redundant handler borrows on a bulk DeleteStream.
self.signal_reconcile_wake();
}
}
}
/// Local message processing — these methods handle messages that have been
/// routed to this shard via the message pump.
impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
where
B: MessageBus,
{
/// Dispatch an incoming network message to the appropriate consensus plane.
///
/// Routes requests, replication messages, and acks to either the metadata
/// plane or the partitions plane based on `PlaneIdentity::is_applicable`.
//
// TODO(hubcio): perf - this `MessageBag::try_from` is the second parse of
// the same frame; the first ran in `IggyShard::dispatch` (router.rs ~85)
// to extract (operation, namespace) for routing. The work here re-runs
// `bytemuck::checked::try_from_bytes` + per-header `validate()` on bytes
// already validated upstream. See the matching TODO in router.rs for the
// fix: thread the classified `MessageBag` through `ShardFrame::Consensus`
// so this function takes the bag directly and the match below dispatches
// without re-parsing.
#[allow(clippy::future_not_send)]
pub async fn on_message(&self, message: Message<GenericHeader>)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
> + StreamsFrontend,
{
match MessageBag::try_from(message) {
Ok(MessageBag::Request(request)) => {
let routing = (request.header().operation, request.header().namespace);
if let Some(request) = self.park_if_unmaterialised(request, routing.0, routing.1) {
self.on_request(request).await;
}
}
Ok(MessageBag::Prepare(prepare)) => {
let routing = (prepare.header().operation, prepare.header().namespace);
if let Some(prepare) = self.park_if_unmaterialised(prepare, routing.0, routing.1) {
self.on_replicate(prepare).await;
// A follower learns the cluster commit point from the
// commit_max piggybacked on each prepare; the Commit
// heartbeat carries commit_min and stops advancing
// commit_max once the piggyback has raced ahead, so
// on_commit alone never drains a follower's journal. Drive
// it here off the prepare, as the metadata plane does inside
// its own on_replicate.
if routing.0.is_partition() {
let planes = self.plane.inner();
let config = planes.1.0.config();
let namespace = IggyNamespace::from_raw(routing.1);
if let Some(partition) = planes.1.0.get_mut_by_ns(&namespace)
&& partition.consensus().is_follower()
{
partition.commit_journal(config).await;
}
}
}
}
Ok(MessageBag::PrepareOk(prepare_ok)) => self.on_ack(prepare_ok).await,
Ok(MessageBag::StartViewChange(msg)) => self.on_start_view_change(msg).await,
Ok(MessageBag::DoViewChange(msg)) => self.on_do_view_change(msg).await,
Ok(MessageBag::StartView(msg)) => self.on_start_view(msg).await,
Ok(MessageBag::Commit(ref msg)) => self.on_commit(msg).await,
Err(e) => {
tracing::warn!(shard = self.id, error = %e, "dropping message with invalid command");
}
}
}
/// Drop parked frames for a namespace that will never materialise (it was
/// removed before its `ReconcileOp::InsertOwned`), so the pending entry is
/// reclaimed instead of leaking until process exit.
fn discard_parked_partition_frames(&self, namespace: IggyNamespace) {
if let Some(frames) = self
.pending_partition_frames
.borrow_mut()
.remove(&namespace)
&& !frames.is_empty()
{
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
count = frames.len(),
"discarding parked partition frames for removed namespace"
);
}
}
/// Park a partition-plane frame whose namespace this shard has not yet
/// materialised (post-`CreateTopic` convergence window: the metadata
/// commit precedes the reconciler pass that builds the local replica).
///
/// Returns `Some(message)` when the frame should be processed normally:
/// non-partition operation, namespace materialised, or namespace
/// tombstoned (the plane's own tombstone guard handles the drop).
/// Returns `None` when the frame was parked (or dropped on overflow);
/// [`Self::apply_reconcile_ops`] re-dispatches parked frames once the
/// matching `ReconcileOp::InsertOwned` lands.
fn park_if_unmaterialised<H>(
&self,
message: Message<H>,
operation: Operation,
namespace_raw: u64,
) -> Option<Message<H>>
where
H: iggy_binary_protocol::ConsensusHeader,
{
const MAX_PARKED_PER_NAMESPACE: usize = 128;
if !operation.is_partition() {
return Some(message);
}
let namespace = IggyNamespace::from_raw(namespace_raw);
let partitions = self.plane.partitions();
if partitions.contains(&namespace) || partitions.is_tombstoned(&namespace) {
return Some(message);
}
let mut pending = self.pending_partition_frames.borrow_mut();
let parked = pending.entry(namespace).or_default();
if parked.len() >= MAX_PARKED_PER_NAMESPACE {
tracing::warn!(
shard = self.id,
namespace_raw = namespace.inner(),
"parked-frame buffer full; dropping partition frame"
);
return None;
}
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
operation = ?operation,
"parking partition frame until namespace materialises"
);
parked.push(message.into_generic());
None
}
#[allow(clippy::future_not_send)]
pub async fn on_request(&self, request: Message<RequestHeader>)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
> + StreamsFrontend,
{
self.plane.on_request(request).await;
}
#[allow(clippy::future_not_send)]
pub async fn on_replicate(&self, prepare: Message<PrepareHeader>)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
> + StreamsFrontend,
{
self.plane.on_replicate(prepare).await;
}
#[allow(clippy::future_not_send)]
pub async fn on_ack(&self, prepare_ok: Message<PrepareOkHeader>)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
> + StreamsFrontend,
{
self.plane.on_ack(prepare_ok).await;
}
/// Drain and dispatch loopback messages for each consensus plane.
///
/// Each plane's loopback is dispatched directly to that plane's `on_ack`,
/// avoiding a flat merge that would require re-routing through `on_message`.
///
/// Invariant: planes do not produce loopback messages FOR EACH OTHER.
/// `on_ack` never pushes to another plane's loopback, so draining
/// metadata before partitions is order-independent. Within its own
/// plane, `on_ack` CAN push loopback entries (a metadata commit promotes
/// buffered requests, and each promoted prepare self-acks through
/// `send_or_loopback(self)`) -- `repair_primary_self_acks` drains those
/// residuals itself; see its interleaved drain.
///
/// # Panics
/// Panics if a loopback message is not a valid `PrepareOk` message.
#[allow(clippy::future_not_send)]
pub async fn process_loopback(
&self,
buf: &mut Vec<Message<GenericHeader>>,
namespace_scratch: &mut Vec<IggyNamespace>,
) -> usize
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
> + StreamsFrontend,
{
debug_assert!(buf.is_empty(), "buf must be empty on entry");
debug_assert!(
namespace_scratch.is_empty(),
"namespace_scratch must be empty on entry",
);
let mut total = 0;
let planes = self.plane.inner();
if let Some(ref consensus) = planes.0.consensus {
consensus.drain_loopback_into(buf);
let count = buf.len();
total += count;
for msg in buf.drain(..) {
let typed: Message<PrepareOkHeader> = msg
.try_into_typed()
.expect("loopback queue must only contain PrepareOk messages");
planes.0.on_ack(typed).await;
}
}
namespace_scratch.extend(planes.1.0.namespaces().copied());
for namespace in namespace_scratch.drain(..) {
// `get_by_ns` returns `None` for tombstoned namespaces: skip
// draining their loopback queue so we don't surface PrepareOk
// frames targeting a partition the reconciler is tearing down.
let Some(partition) = planes.1.0.get_by_ns(&namespace) else {
continue;
};
partition.consensus().drain_loopback_into(buf);
}
let count = buf.len();
total += count;
for msg in buf.drain(..) {
let typed: Message<PrepareOkHeader> = msg
.try_into_typed()
.expect("loopback queue must only contain PrepareOk messages");
planes.1.0.on_ack(typed).await;
}
total
}
/// Simulator-only. Mutates `IggyPartitions` off the pump task,
/// bypassing the reconciler's `ReconcileOp::InsertOwned` funnel (the
/// production runtime path; bootstrap recovery uses `load_partition`),
/// so it must never run in production. VSR replica id comes from
/// `PartitionConsensusConfig`, not `self.id` (the local shard index). A
/// `-p iggy-server-ng` build excludes the `simulator` feature and this
/// method; `cargo build --workspace` compiles it in but with no
/// production caller.
#[cfg(any(test, feature = "simulator"))]
pub fn init_partition(&self, namespace: IggyNamespace)
where
B: MessageBus + Clone,
{
let partitions = self.plane.partitions();
if partitions.contains(&namespace) {
return;
}
let consensus = VsrConsensus::new(
self.partition_consensus.cluster_id,
self.partition_consensus.self_replica_id,
self.partition_consensus.replica_count,
namespace.inner(),
self.partition_consensus.bus.clone(),
LocalPipeline::new(),
);
consensus.init();
let stats = Arc::new(PartitionStats::default());
let partition = IggyPartition::with_in_memory_storage(
stats,
consensus,
partitions.config().segment_size,
partitions.config().enforce_fsync,
);
partitions.insert(namespace, partition);
}
/// Resolve the single partition a VSR control frame addresses, keyed by
/// `header.namespace`. Warns and returns `None` when the namespace matches
/// neither metadata nor a live partition consensus. Returns `&mut` because
/// `on_do_view_change` / `on_commit` need it for `commit_journal`; the read-
/// only callers reborrow `&`. Pump-only (sole mutator), so the `&mut` formed
/// here via interior mutability cannot alias a concurrent reconcile.
#[allow(clippy::mut_from_ref)]
fn resolve_partition_target<'a>(
&self,
partitions: &'a IggyPartitions<B>,
namespace: u64,
view: u32,
replica: u8,
frame: &'static str,
) -> Option<&'a mut IggyPartition<B>>
where
B: MessageBus,
{
let Some(partition) = partitions.get_mut_by_ns(&IggyNamespace::from_raw(namespace)) else {
tracing::warn!(
shard = self.id,
namespace,
view,
replica,
frame,
"dropping VSR control frame: namespace matches neither metadata nor partition consensus"
);
return None;
};
debug_assert_eq!(
partition.consensus().namespace(),
namespace,
"keyed partition lookup must match the frame namespace"
);
Some(partition)
}
/// Handle an incoming VSR control frame. A metadata frame uses the metadata
/// consensus; a partition frame addresses exactly one partition, resolved by
/// [`Self::resolve_partition_target`].
#[allow(clippy::future_not_send)]
async fn on_start_view_change(&self, msg: Message<StartViewChangeHeader>)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
{
let header = *msg.header();
let planes = self.plane.inner();
if let Some(ref consensus) = planes.0.consensus
&& consensus.namespace() == header.namespace
{
let actions = consensus.handle_start_view_change(PlaneKind::Metadata, &header);
dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
return;
}
let Some(partition) = self.resolve_partition_target(
&planes.1.0,
header.namespace,
header.view,
header.replica,
"StartViewChange",
) else {
return;
};
let consensus = partition.consensus();
let actions = consensus.handle_start_view_change(PlaneKind::Partitions, &header);
dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
dispatch_partition_journal_actions(consensus, partition, &actions).await;
}
#[allow(clippy::future_not_send)]
async fn on_do_view_change(&self, msg: Message<DoViewChangeHeader>)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StreamsFrontend
+ StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
>,
{
let header = *msg.header();
let planes = self.plane.inner();
if let Some(ref consensus) = planes.0.consensus
&& consensus.namespace() == header.namespace
{
let actions = consensus.handle_do_view_change(PlaneKind::Metadata, &header);
dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
if actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
{
planes.0.commit_journal().await;
}
return;
}
let config = planes.1.0.config();
let Some(partition) = self.resolve_partition_target(
&planes.1.0,
header.namespace,
header.view,
header.replica,
"DoViewChange",
) else {
return;
};
let consensus = partition.consensus();
let actions = consensus.handle_do_view_change(PlaneKind::Partitions, &header);
dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
dispatch_partition_journal_actions(consensus, partition, &actions).await;
if actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
{
partition.commit_journal(config).await;
}
}
#[allow(clippy::future_not_send)]
async fn on_start_view(&self, msg: Message<StartViewHeader>)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StreamsFrontend
+ StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
>,
{
let header = *msg.header();
let planes = self.plane.inner();
if let Some(ref consensus) = planes.0.consensus
&& consensus.namespace() == header.namespace
{
let actions = consensus.handle_start_view(PlaneKind::Metadata, &header);
dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
// `dispatch_vsr_actions` deliberately no-ops `CommitJournal` (it
// needs the plane); without this the ops a StartView marks
// committed stay journaled-but-unapplied forever, because the
// follow-up heartbeats see commit_max already advanced and skip
// their own commit_journal.
if actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
{
planes.0.commit_journal().await;
}
return;
}
let config = planes.1.0.config();
let Some(partition) = self.resolve_partition_target(
&planes.1.0,
header.namespace,
header.view,
header.replica,
"StartView",
) else {
return;
};
let consensus = partition.consensus();
let actions = consensus.handle_start_view(PlaneKind::Partitions, &header);
dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
dispatch_partition_journal_actions(consensus, partition, &actions).await;
if actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
{
partition.commit_journal(config).await;
}
}
#[allow(clippy::future_not_send)]
async fn on_commit(&self, msg: &Message<CommitHeader>)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StreamsFrontend
+ StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
>,
{
let header = *msg.header();
let planes = self.plane.inner();
if let Some(ref consensus) = planes.0.consensus
&& consensus.namespace() == header.namespace
{
match consensus.handle_commit(&header) {
CommitOutcome::Advanced => planes.0.commit_journal().await,
CommitOutcome::RespondStartView => {
respond_start_view::<B, _, MJ>(consensus).await;
}
CommitOutcome::Accepted => {}
}
return;
}
let config = planes.1.0.config();
let Some(partition) = self.resolve_partition_target(
&planes.1.0,
header.namespace,
header.view,
header.replica,
"Commit",
) else {
return;
};
let consensus = partition.consensus();
match consensus.handle_commit(&header) {
CommitOutcome::Advanced => partition.commit_journal(config).await,
CommitOutcome::RespondStartView => {
respond_start_view::<B, _, MJ>(consensus).await;
}
CommitOutcome::Accepted => {}
}
}
/// Tick partition consensuses. Loop partitions. No partitions-plane journal.
#[allow(clippy::future_not_send)]
pub async fn tick_partitions(&self)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
{
let partitions = self.plane.partitions();
// Fan out over every group (each partition's heartbeat/retransmit timer
// must advance), so the keyed single-namespace lookup the control-frame
// handlers use does not apply here. The namespaces are snapshotted into
// an owned Vec so no partitions-plane borrow is held across the tick
// `.await`.
// TODO(hubcio): reuse the pump's `namespace_scratch` (as
// `process_loopback` does) to drop this per-tick alloc; a quiet cluster
// still pays one Vec per heartbeat.
let namespaces: Vec<_> = partitions.namespaces().copied().collect();
for namespace in namespaces {
let Some(partition) = partitions.get_by_ns(&namespace) else {
continue;
};
let consensus = partition.consensus();
let actions = consensus.tick(PlaneKind::Partitions);
dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
dispatch_partition_journal_actions(consensus, partition, &actions).await;
}
}
/// Flush every owned partition's committed journal prefix to segment
/// storage. Pump-shutdown counterpart of the commit-time persist gate:
/// a graceful stop must not lose committed messages still resident in
/// the in-memory journal (mirrors the legacy pump's final flush).
#[allow(clippy::future_not_send)]
pub async fn flush_partitions(&self)
where
B: MessageBus,
{
let partitions = self.plane.partitions();
let namespaces: Vec<_> = partitions.namespaces().copied().collect();
for namespace in namespaces {
let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
continue;
};
if let Err(error) = partition
.flush_committed_messages(partitions.config())
.await
{
tracing::warn!(
namespace_raw = namespace.inner(),
%error,
"failed to flush partition journal on shutdown"
);
}
}
}
#[allow(clippy::future_not_send)]
pub async fn tick_metadata(&self)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
M: StateMachine<
Input = Message<PrepareHeader>,
Output = metadata::stm::result::ApplyReply,
Error = iggy_common::IggyError,
> + StreamsFrontend,
{
let metadata = self.plane.metadata();
let Some(ref consensus) = metadata.consensus else {
return;
};
let actions = consensus.tick(PlaneKind::Metadata);
dispatch_vsr_actions(consensus, metadata.journal.as_ref(), &actions).await;
// Repair a lost primary self-ack: `RetransmitPrepares` to self is a
// no-op, so the timer-driven retransmit above cannot recover the
// primary's own missing vote. Without this the commit prefix can pin
// forever (commit_min stuck below commit_max). See
// `IggyMetadata::repair_primary_self_acks`.
metadata.repair_primary_self_acks().await;
}
}
/// Broadcast a `StartView` for the current view, answering a replica that
/// still heartbeats an older view (see `CommitOutcome::RespondStartView`).
#[allow(clippy::future_not_send)]
async fn respond_start_view<B, P, J>(consensus: &VsrConsensus<B, P>)
where
B: MessageBus,
P: Pipeline<Entry = consensus::PipelineEntry>,
J: JournalHandle,
<J as JournalHandle>::Target: Journal<
<J as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
{
tracing::info!(
view = consensus.view(),
op = consensus.sequencer().current_sequence(),
commit = consensus.commit_max(),
namespace = consensus.namespace(),
"answering stale-view heartbeat with StartView"
);
let action = VsrAction::SendStartView {
view: consensus.view(),
op: consensus.sequencer().current_sequence(),
commit: consensus.commit_max(),
namespace: consensus.namespace(),
};
dispatch_vsr_actions::<B, P, J>(consensus, None, &[action]).await;
}
/// Dispatch a list of `VsrAction`s by constructing the appropriate
/// protocol messages and sending them via the consensus message bus.
#[allow(
clippy::future_not_send,
clippy::too_many_lines,
clippy::cast_possible_truncation
)]
async fn dispatch_vsr_actions<B, P, J>(
consensus: &VsrConsensus<B, P>,
journal: Option<&J>,
actions: &[VsrAction],
) where
B: MessageBus,
P: Pipeline<Entry = consensus::PipelineEntry>,
J: JournalHandle,
<J as JournalHandle>::Target: Journal<
<J as JournalHandle>::Storage,
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
{
use std::mem::size_of;
let bus = consensus.message_bus();
let self_id = consensus.replica();
let cluster = consensus.cluster();
let replica_count = consensus.replica_count();
let send = |target: u8, msg: Frozen<MESSAGE_ALIGN>| async move {
if let Err(e) = bus.send_to_replica(target, msg).await {
tracing::debug!(replica = self_id, target, "bus send failed: {e}");
}
};
let broadcast = async |frozen: Frozen<MESSAGE_ALIGN>| {
// Freeze once at the primary; each target just bumps the atomic
// refcount on the underlying ControlBlock.
for target in 0..replica_count {
if target != self_id {
send(target, frozen.clone()).await;
}
}
};
for action in actions {
match action {
VsrAction::SendStartViewChange { view, namespace } => {
let msg = Message::<StartViewChangeHeader>::new(size_of::<StartViewChangeHeader>())
.transmute_header(|_, h: &mut StartViewChangeHeader| {
h.command = Command2::StartViewChange;
h.cluster = cluster;
h.replica = self_id;
h.view = *view;
h.namespace = *namespace;
h.size = size_of::<StartViewChangeHeader>() as u32;
});
broadcast(msg.into_generic().into_frozen()).await;
}
VsrAction::SendDoViewChange {
view,
target,
log_view,
op,
commit,
namespace,
} => {
let msg = Message::<DoViewChangeHeader>::new(size_of::<DoViewChangeHeader>())
.transmute_header(|_, h: &mut DoViewChangeHeader| {
h.command = Command2::DoViewChange;
h.cluster = cluster;
h.replica = self_id;
h.view = *view;
h.log_view = *log_view;
h.op = *op;
h.commit = *commit;
h.namespace = *namespace;
h.size = size_of::<DoViewChangeHeader>() as u32;
});
send(*target, msg.into_generic().into_frozen()).await;
}
VsrAction::SendStartView {
view,
op,
commit,
namespace,
} => {
let msg = Message::<StartViewHeader>::new(size_of::<StartViewHeader>())
.transmute_header(|_, h: &mut StartViewHeader| {
h.command = Command2::StartView;
h.cluster = cluster;
h.replica = self_id;
h.view = *view;
h.op = *op;
h.commit = *commit;
h.namespace = *namespace;
h.size = size_of::<StartViewHeader>() as u32;
});
broadcast(msg.into_generic().into_frozen()).await;
}
VsrAction::SendPrepareOk {
view,
from_op,
to_op,
target,
namespace,
} => {
let Some(journal) = journal else {
continue;
};
for op in *from_op..=*to_op {
let Some(prepare_header) = journal.handle().header(op as usize) else {
continue;
};
let prepare_header = *prepare_header;
let msg = Message::<PrepareOkHeader>::new(size_of::<PrepareOkHeader>())
.transmute_header(|_, h: &mut PrepareOkHeader| {
h.command = Command2::PrepareOk;
h.cluster = cluster;
h.replica = self_id;
h.view = *view;
h.op = op;
h.commit = consensus.commit_max();
h.timestamp = prepare_header.timestamp;
h.parent = prepare_header.parent;
h.prepare_checksum = prepare_header.checksum;
h.request = prepare_header.request;
h.operation = prepare_header.operation;
h.namespace = *namespace;
h.size = size_of::<PrepareOkHeader>() as u32;
});
send(*target, msg.into_generic().into_frozen()).await;
}
}
VsrAction::RetransmitPrepares { targets } => {
let Some(journal) = journal else {
continue;
};
for (header, replicas) in targets {
let Some(prepare) = journal.handle().entry(header).await else {
continue;
};
// Freeze the retransmit payload once; clone per target.
let frozen = prepare.into_generic().into_frozen();
for replica in replicas {
send(*replica, frozen.clone()).await;
}
}
}
VsrAction::RebuildPipeline { from_op, to_op } => {
let Some(journal) = journal else {
continue;
};
// Collect headers before borrowing the pipeline to avoid
// holding borrow_mut() across journal reads.
let mut gap_at = None;
let entries: Vec<_> = (*from_op..=*to_op)
.map_while(|op| {
let Some(header) = journal.handle().header(op as usize) else {
gap_at = Some(op);
return None;
};
let mut entry = consensus::PipelineEntry::new(*header);
entry.add_ack(self_id);
Some(entry)
})
.collect();
if let Some(missing_op) = gap_at {
// Journal repair is not yet implemented.Truncate the sequencer
// to the last op we could rebuild so the next client
// prepare chains correctly. Ops above the
// gap are lost until journal repair is added.
let rebuilt_up_to = missing_op.saturating_sub(1);
tracing::warn!(
replica = self_id,
missing_op,
range_start = from_op,
range_end = to_op,
rebuilt = entries.len(),
"RebuildPipeline: journal gap at op {missing_op}, \
truncating sequencer from {to_op} to {rebuilt_up_to} \
({}/{} ops rebuilt)",
entries.len(),
to_op - from_op + 1,
);
consensus.sequencer().set_sequence(rebuilt_up_to);
}
let mut pipeline = consensus.pipeline().borrow_mut();
for entry in entries {
pipeline.push(entry);
}
}
// Handled by the caller (shard view change handlers) since it
// requires access to the plane's commit_journal method.
VsrAction::CommitJournal => {}
VsrAction::SendCommit {
view,
commit,
namespace,
timestamp_monotonic,
} => {
let msg = Message::<CommitHeader>::new(size_of::<CommitHeader>()).transmute_header(
|_, h: &mut CommitHeader| {
h.command = Command2::Commit;
h.cluster = cluster;
h.replica = self_id;
h.view = *view;
h.commit = *commit;
h.namespace = *namespace;
h.timestamp_monotonic = *timestamp_monotonic;
h.size = size_of::<CommitHeader>() as u32;
},
);
broadcast(msg.into_generic().into_frozen()).await;
}
}
}
}
#[allow(
clippy::future_not_send,
clippy::too_many_lines,
clippy::cast_possible_truncation
)]
async fn dispatch_partition_journal_actions<B, P>(
consensus: &VsrConsensus<B, P>,
partition: &IggyPartition<B>,
actions: &[VsrAction],
) where
B: MessageBus,
P: Pipeline<Entry = consensus::PipelineEntry>,
{
use std::mem::size_of;
let bus = consensus.message_bus();
let self_id = consensus.replica();
let cluster = consensus.cluster();
let journal = &partition.log.journal().inner;
let send = |target: u8, msg: Frozen<MESSAGE_ALIGN>| async move {
if let Err(e) = bus.send_to_replica(target, msg).await {
tracing::debug!(replica = self_id, target, "bus send failed: {e}");
}
};
for action in actions {
match action {
VsrAction::SendPrepareOk {
view,
from_op,
to_op,
target,
namespace,
} => {
for op in *from_op..=*to_op {
let Some(prepare_header) = journal.header_by_op(op) else {
continue;
};
let msg = Message::<PrepareOkHeader>::new(size_of::<PrepareOkHeader>())
.transmute_header(|_, h: &mut PrepareOkHeader| {
h.command = Command2::PrepareOk;
h.cluster = cluster;
h.replica = self_id;
h.view = *view;
h.op = op;
h.commit = consensus.commit_max();
h.timestamp = prepare_header.timestamp;
h.parent = prepare_header.parent;
h.prepare_checksum = prepare_header.checksum;
h.request = prepare_header.request;
h.operation = prepare_header.operation;
h.namespace = *namespace;
h.size = size_of::<PrepareOkHeader>() as u32;
});
send(*target, msg.into_generic().into_frozen()).await;
}
}
VsrAction::RetransmitPrepares { targets } => {
// DURABILITY CAVEAT: the only `Storage` impl on
// `PartitionJournal` right now is the in-memory
// `PartitionJournalMemStorage`. After a process restart
// the journal is empty and every `journal.entry` below
// returns `None`, so retransmit silently drops the
// request and peers stall until a view change. The bus
// and consensus plumbing is correct; only the storage
// needs to become durable before cluster workloads go to
// production. Server boot emits a loud warning to the
// operator (see `main.rs`).
for (header, replicas) in targets {
let Some(prepare) = journal.entry(header).await else {
continue;
};
// The partition journal already stores the wire-format
// `Frozen<4096>` (PrepareHeader followed by payload),
// so `send_to_replica` can take it directly and `clone`
// is a refcount bump. Matches the metadata-plane path
// above and avoids both the per-target 4 KiB memcpy
// and the prior `.expect` that would panic the shard
// on a corrupted journal entry.
for replica in replicas {
send(*replica, prepare.clone()).await;
}
}
}
VsrAction::RebuildPipeline { from_op, to_op } => {
let mut gap_at = None;
let entries: Vec<_> = (*from_op..=*to_op)
.map_while(|op| {
let Some(header) = journal.header_by_op(op) else {
gap_at = Some(op);
return None;
};
let mut entry = consensus::PipelineEntry::new(header);
entry.add_ack(self_id);
Some(entry)
})
.collect();
if let Some(missing_op) = gap_at {
let rebuilt_up_to = missing_op.saturating_sub(1);
tracing::warn!(
replica = self_id,
missing_op,
range_start = from_op,
range_end = to_op,
rebuilt = entries.len(),
"RebuildPipeline: journal gap at op {missing_op}, \
truncating sequencer from {to_op} to {rebuilt_up_to} \
({}/{} ops rebuilt)",
entries.len(),
to_op - from_op + 1,
);
consensus.sequencer().set_sequence(rebuilt_up_to);
}
let mut pipeline = consensus.pipeline().borrow_mut();
for entry in entries {
pipeline.push(entry);
}
}
_ => {}
}
}
}