blob: e38d3ddc545514e8739dc7aba4101ca500cedbc5 [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.
use crate::oneshot::{self, Receiver, Sender};
use crate::vsr_timeout::{TimeoutKind, TimeoutManager};
use crate::{
AckLogEvent, Consensus, ControlActionLogEvent, DvcQuorumArray, IgnoreReason, Pipeline,
PlaneKind, PrepareLogEvent, Project, ReplicaLogContext, SimEventKind, StoredDvc,
ViewChangeLogEvent, ViewChangeReason, dvc_count, dvc_max_commit, dvc_quorum_array_empty,
dvc_record, dvc_reset, dvc_select_winner, emit_replica_event, emit_sim_event,
};
use bit_set::BitSet;
use iggy_binary_protocol::{
Command2, ConsensusHeader, DoViewChangeHeader, GenericHeader, PrepareHeader, PrepareOkHeader,
ReplyHeader, RequestHeader, StartViewChangeHeader, StartViewHeader,
};
use iggy_common::IggyTimestamp;
use message_bus::IggyMessageBus;
use message_bus::MessageBus;
use server_common::Message;
use server_common::sharding::{IggyNamespace, METADATA_CONSENSUS_NAMESPACE};
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
pub trait Sequencer {
type Sequence;
/// Get the current sequence number
fn current_sequence(&self) -> Self::Sequence;
/// Allocate the next sequence number.
/// TODO Should this return a Future<Output = u64>? for async case?
fn next_sequence(&self) -> Self::Sequence;
/// Update the current sequence number.
fn set_sequence(&self, sequence: Self::Sequence);
}
#[derive(Debug)]
pub struct LocalSequencer {
op: Cell<u64>,
}
impl LocalSequencer {
#[must_use]
pub const fn new(initial_op: u64) -> Self {
Self {
op: Cell::new(initial_op),
}
}
}
impl Sequencer for LocalSequencer {
type Sequence = u64;
fn current_sequence(&self) -> Self::Sequence {
self.op.get()
}
fn next_sequence(&self) -> Self::Sequence {
let current = self.current_sequence();
let next = current.checked_add(1).expect("sequence number overflow");
self.set_sequence(next);
next
}
fn set_sequence(&self, sequence: Self::Sequence) {
self.op.set(sequence);
}
}
/// TODO The below numbers need to be added a consensus config
/// TODO understand how to configure these numbers.
/// Maximum number of prepares that can be in-flight in the pipeline.
///
/// Sized to absorb a synchronized client burst (e.g. the 20-way
/// concurrent-creation race tests across TCP/QUIC/WebSocket) without
/// `PipelineFull`-rejecting and disconnecting clients that cannot replay in
/// time. At depth 8 the QUIC burst wedges the metadata consensus even in
/// release. Stays well under the journal's `SLOT_COUNT` (1024) and the inbox
/// capacity headroom.
pub const PIPELINE_PREPARE_QUEUE_MAX: usize = 32;
/// Max accepted-but-not-yet-prepared requests buffered behind a full
/// prepare queue. Beyond this, requests drop and the client retries.
pub const PIPELINE_REQUEST_QUEUE_MAX: usize = 64;
/// Maximum number of replicas in a cluster.
pub const REPLICAS_MAX: usize = 32;
/// Maximum number of clients tracked in the clients table.
/// When exceeded, the client with the oldest committed request is evicted.
pub const CLIENTS_TABLE_MAX: usize = 8192;
#[derive(Debug)]
pub struct PipelineEntry {
pub header: PrepareHeader,
/// Bitmap of replicas that have acknowledged this prepare.
pub ok_from_replicas: BitSet<u32>,
/// Whether we've received a quorum of `prepare_ok` messages.
pub ok_quorum_received: bool,
/// In-process reply subscriber. `None` = network path (`message_bus`);
/// `Some` = in-server awaiter. Set by [`Self::with_subscriber`], taken
/// by commit handler via [`Self::take_reply_sender`]. Drop wakes
/// receiver with `Canceled` (view-change reset, eviction, commit fail).
pub(crate) reply_sender: Option<Sender<Message<ReplyHeader>>>,
}
impl PipelineEntry {
/// Entry without subscriber (network path).
#[must_use]
pub fn new(header: PrepareHeader) -> Self {
Self {
header,
ok_from_replicas: BitSet::with_capacity(REPLICAS_MAX),
ok_quorum_received: false,
reply_sender: None,
}
}
/// Entry paired with a fresh receiver, wakes when this prepare commits.
///
/// # Returns
/// `(entry, receiver)`. Receiver resolves with reply, or `Err(Canceled)`
/// if entry drops before commit.
#[must_use]
pub fn with_subscriber(header: PrepareHeader) -> (Self, Receiver<Message<ReplyHeader>>) {
let (sender, receiver) = oneshot::channel();
let entry = Self {
header,
ok_from_replicas: BitSet::with_capacity(REPLICAS_MAX),
ok_quorum_received: false,
reply_sender: Some(sender),
};
(entry, receiver)
}
/// Take reply sender; caller fires after slot update (slot-first ordering).
/// Idempotent: subsequent calls return `None`.
pub const fn take_reply_sender(&mut self) -> Option<Sender<Message<ReplyHeader>>> {
self.reply_sender.take()
}
/// `true` iff the entry still owns a reply sender (in-process awaiter).
/// Caller checks before [`Self::take_reply_sender`] so it can branch on
/// the slot's network-vs-in-process role without consuming the sender.
#[must_use]
pub const fn has_reply_sender(&self) -> bool {
self.reply_sender.is_some()
}
/// Record a `prepare_ok` from the given replica.
/// Returns the new count of acknowledgments.
pub fn add_ack(&mut self, replica: u8) -> usize {
self.ok_from_replicas.insert(replica as usize);
self.ok_from_replicas.count()
}
/// Check if we have an ack from the given replica.
#[must_use]
pub fn has_ack(&self, replica: u8) -> bool {
self.ok_from_replicas.contains(replica as usize)
}
/// Get the number of acks received.
#[must_use]
pub fn ack_count(&self) -> usize {
self.ok_from_replicas.count()
}
}
/// Accepted request waiting in `request_queue` for a prepare slot.
#[derive(Debug)]
pub struct RequestEntry {
pub message: Message<RequestHeader>,
// TODO: populate from monotonic clock at push, promote to `pub` for
// age-based filtering. Currently `0`; `pub(crate)` blocks sort-on-stub.
#[allow(dead_code)]
pub(crate) received_at: i64,
}
impl RequestEntry {
#[must_use]
pub const fn new(message: Message<RequestHeader>) -> Self {
Self {
message,
received_at: 0,
}
}
}
/// Two-queue pipeline: in-flight prepares + buffered requests.
#[derive(Debug)]
pub struct LocalPipeline {
/// Uncommitted prepares; cap [`PIPELINE_PREPARE_QUEUE_MAX`].
prepare_queue: VecDeque<PipelineEntry>,
/// Requests awaiting a prepare slot; cap [`PIPELINE_REQUEST_QUEUE_MAX`].
request_queue: VecDeque<RequestEntry>,
}
impl Default for LocalPipeline {
fn default() -> Self {
Self::new()
}
}
impl LocalPipeline {
#[must_use]
pub fn new() -> Self {
Self {
prepare_queue: VecDeque::with_capacity(PIPELINE_PREPARE_QUEUE_MAX),
request_queue: VecDeque::with_capacity(PIPELINE_REQUEST_QUEUE_MAX),
}
}
#[must_use]
pub fn prepare_count(&self) -> usize {
self.prepare_queue.len()
}
#[must_use]
pub fn prepare_queue_full(&self) -> bool {
self.prepare_queue.len() >= PIPELINE_PREPARE_QUEUE_MAX
}
#[must_use]
pub fn request_queue_len(&self) -> usize {
self.request_queue.len()
}
#[must_use]
pub fn request_queue_full(&self) -> bool {
self.request_queue.len() >= PIPELINE_REQUEST_QUEUE_MAX
}
#[must_use]
pub fn request_queue_is_empty(&self) -> bool {
self.request_queue.is_empty()
}
/// Buffer a request behind a full prepare queue.
///
/// # Errors
/// `Err(entry)` if request queue also full; caller drops, client retries.
pub fn push_request(&mut self, entry: RequestEntry) -> Result<(), RequestEntry> {
if self.request_queue_full() {
return Err(entry);
}
self.request_queue.push_back(entry);
Ok(())
}
/// Pop request-queue head. Called when a prepare commits and frees a slot.
pub fn pop_request(&mut self) -> Option<RequestEntry> {
self.request_queue.pop_front()
}
/// True iff `prepare_queue` is full (NOT including `request_queue`).
/// Callers branch on this between direct push and [`Self::push_request`].
#[must_use]
pub fn is_full(&self) -> bool {
self.prepare_queue_full()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.prepare_queue.is_empty() && self.request_queue.is_empty()
}
/// Push a new entry to the pipeline.
///
/// # Panics
/// - If message queue is full.
/// - If the message doesn't chain correctly to the previous entry.
pub fn push(&mut self, entry: PipelineEntry) {
assert!(!self.prepare_queue_full(), "prepare queue is full");
let header = entry.header;
if let Some(tail) = self.prepare_queue.back() {
let tail_header = &tail.header;
assert_eq!(
header.op,
tail_header.op + 1,
"sequence must be sequential: expected {}, got {}",
tail_header.op + 1,
header.op
);
assert_eq!(
header.parent, tail_header.checksum,
"parent must chain to previous checksum"
);
assert!(header.view >= tail_header.view, "view cannot go backwards");
}
self.prepare_queue.push_back(entry);
}
#[allow(clippy::needless_pass_by_value)]
pub fn push_message(&mut self, message: Message<PrepareHeader>) {
self.push(PipelineEntry::new(*message.header()));
}
/// Pop the oldest message (after it's been committed).
///
pub fn pop_message(&mut self) -> Option<PipelineEntry> {
self.prepare_queue.pop_front()
}
/// Get the head (oldest) prepare.
#[must_use]
pub fn prepare_head(&self) -> Option<&PipelineEntry> {
self.prepare_queue.front()
}
pub fn prepare_head_mut(&mut self) -> Option<&mut PipelineEntry> {
self.prepare_queue.front_mut()
}
/// Get the tail (newest) prepare.
#[must_use]
pub fn prepare_tail(&self) -> Option<&PipelineEntry> {
self.prepare_queue.back()
}
/// Find a message by op number and checksum (immutable).
// Pipeline bounded at PIPELINE_PREPARE_QUEUE_MAX (8) entries; index always fits in usize.
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn message_by_op_and_checksum(&self, op: u64, checksum: u128) -> Option<&PipelineEntry> {
let head_op = self.prepare_queue.front()?.header.op;
let tail_op = self.prepare_queue.back()?.header.op;
// Verify consecutive ops invariant
debug_assert_eq!(
tail_op,
head_op + self.prepare_queue.len() as u64 - 1,
"prepare queue ops not consecutive"
);
if op < head_op || op > tail_op {
return None;
}
let index = (op - head_op) as usize;
let entry = self.prepare_queue.get(index)?;
debug_assert_eq!(entry.header.op, op);
if entry.header.checksum == checksum {
Some(entry)
} else {
None
}
}
/// Find a message by op number only.
// Pipeline bounded at PIPELINE_PREPARE_QUEUE_MAX (8) entries; index always fits in usize.
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn message_by_op(&self, op: u64) -> Option<&PipelineEntry> {
let head_op = self.prepare_queue.front()?.header.op;
if op < head_op {
return None;
}
let index = (op - head_op) as usize;
self.prepare_queue.get(index)
}
/// Get mutable reference to a message entry by op number.
/// Returns None if op is not in the pipeline.
// Pipeline bounded at PIPELINE_PREPARE_QUEUE_MAX (8) entries; index always fits in usize.
#[allow(clippy::cast_possible_truncation)]
pub fn message_by_op_mut(&mut self, op: u64) -> Option<&mut PipelineEntry> {
let head_op = self.prepare_queue.front()?.header.op;
if op < head_op {
return None;
}
let index = (op - head_op) as usize;
if index >= self.prepare_queue.len() {
return None;
}
self.prepare_queue.get_mut(index)
}
/// Get the entry at the head of the prepare queue (oldest uncommitted).
#[must_use]
pub fn head(&self) -> Option<&PipelineEntry> {
self.prepare_queue.front()
}
/// True if either queue holds a message from `client`. Used by preflights
/// for in-progress dedup; request_queue-only entries still count.
#[must_use]
pub fn has_message_from_client(&self, client: u128) -> bool {
self.prepare_queue.iter().any(|p| p.header.client == client)
|| self
.request_queue
.iter()
.any(|r| r.message.header().client == client)
}
/// Verify pipeline invariants.
///
/// # Panics
/// If any invariant is violated.
pub fn verify(&self) {
// Check capacity limits
assert!(self.prepare_queue.len() <= PIPELINE_PREPARE_QUEUE_MAX);
assert!(self.request_queue.len() <= PIPELINE_REQUEST_QUEUE_MAX);
// Verify prepare queue hash chain
if let Some(head) = self.prepare_queue.front() {
let mut expected_parent = head.header.parent;
for (expected_op, entry) in (head.header.op..).zip(self.prepare_queue.iter()) {
let header = &entry.header;
assert_eq!(header.op, expected_op, "ops must be sequential");
assert_eq!(header.parent, expected_parent, "must be hash-chained");
expected_parent = header.checksum;
}
}
}
/// Clear both queues at view-change completion. New primary rebuilds
/// prepares from journal; clients retry dropped requests via read-timeout.
pub fn clear(&mut self) {
self.prepare_queue.clear();
self.request_queue.clear();
}
/// Drop reply senders on all prepare entries; receivers wake with
/// `Canceled`. Prepares survive (DVC log reconciliation), cleared at
/// view-change *completion*. `request_queue` untouched, see
/// [`Self::clear_request_queue`].
pub fn cancel_all_subscribers(&mut self) {
for entry in &mut self.prepare_queue {
entry.reply_sender.take();
}
}
/// Drop `request_queue` only; preserve `prepare_queue`. View-change reset.
///
/// # Safety
/// Without this, stale primary-era requests survive into the next view.
/// If `drain_request_queue_into_prepares` fires pre-completion, those
/// requests project via `pipeline_prepare_common`, which asserts
/// `is_primary() && is_normal()` and panics the shard pump.
pub fn clear_request_queue(&mut self) {
self.request_queue.clear();
}
}
impl Pipeline for LocalPipeline {
type Entry = PipelineEntry;
type Request = RequestEntry;
fn push(&mut self, entry: Self::Entry) {
Self::push(self, entry);
}
fn pop(&mut self) -> Option<Self::Entry> {
Self::pop_message(self)
}
fn clear(&mut self) {
Self::clear(self);
}
fn entry_by_op(&self, op: u64) -> Option<&Self::Entry> {
Self::message_by_op(self, op)
}
fn entry_by_op_mut(&mut self, op: u64) -> Option<&mut Self::Entry> {
Self::message_by_op_mut(self, op)
}
fn entry_by_op_and_checksum(&self, op: u64, checksum: u128) -> Option<&Self::Entry> {
Self::message_by_op_and_checksum(self, op, checksum)
}
fn head(&self) -> Option<&Self::Entry> {
Self::head(self)
}
fn is_full(&self) -> bool {
Self::is_full(self)
}
fn is_empty(&self) -> bool {
Self::is_empty(self)
}
fn len(&self) -> usize {
self.prepare_count()
}
fn verify(&self) {
Self::verify(self);
}
fn has_message_from_client(&self, client_id: u128) -> bool {
Self::has_message_from_client(self, client_id)
}
fn cancel_all_subscribers(&mut self) {
Self::cancel_all_subscribers(self);
}
fn clear_request_queue(&mut self) {
Self::clear_request_queue(self);
}
fn push_request(&mut self, request: Self::Request) -> Result<(), Self::Request> {
Self::push_request(self, request)
}
fn pop_request(&mut self) -> Option<Self::Request> {
Self::pop_request(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Normal,
ViewChange,
Recovering,
}
/// What a received `Commit` heartbeat did, so the caller knows whether to
/// drain the journal or correct a stale peer.
#[derive(Debug, Clone, Copy)]
pub enum CommitOutcome {
/// Nothing to do: the heartbeat was absorbed (or ignored as stale /
/// foreign / wrong-status) without moving `commit_max`.
Accepted,
/// `commit_max` advanced; run `commit_journal`.
Advanced,
/// A replica is still heartbeating an older view in which it was
/// primary; this replica is the current view's primary and should
/// broadcast `StartView` so the stale replica adopts the view.
RespondStartView,
}
/// Actions to be taken by the caller after processing a VSR event.
#[derive(Debug, Clone)]
pub enum VsrAction {
/// Send `StartViewChange` to all replicas.
SendStartViewChange { view: u32, namespace: u64 },
/// Send `DoViewChange` to primary.
SendDoViewChange {
view: u32,
target: u8,
log_view: u32,
op: u64,
commit: u64,
namespace: u64,
},
/// Send `StartView` to all backups (as new primary).
SendStartView {
view: u32,
op: u64,
commit: u64,
namespace: u64,
},
/// Send `PrepareOK` for each op in `[from_op, to_op]` that is present in the WAL.
///
/// The caller MUST verify each op exists in the journal before sending.
/// Sending `PrepareOk` for a missing op is a safety violation, it can
/// cause the primary to commit an op without enough replicas holding the data.
SendPrepareOk {
view: u32,
from_op: u64,
to_op: u64,
target: u8,
namespace: u64,
},
/// Retransmit uncommitted prepares from the WAL to replicas that haven't acked.
///
/// Emitted when the primary's prepare timeout fires and there are
/// uncommitted entries in the pipeline. Each entry is a prepare header
/// (for journal lookup) and the list of replica IDs that need it.
RetransmitPrepares {
targets: Vec<(PrepareHeader, Vec<u8>)>,
},
/// Rebuild the pipeline from the journal after a view change.
///
/// The new primary must re-populate its pipeline with uncommitted ops
/// from the WAL so that incoming `PrepareOk` messages can be matched
/// and commits can proceed.
RebuildPipeline { from_op: u64, to_op: u64 },
/// Catch up `commit_min` to `commit_max` by applying committed ops from the
/// journal. Emitted during view change completion so the new primary
/// is fully caught up before accepting new requests.
CommitJournal,
/// Primary heartbeat: send current commit point to all backups.
///
/// Emitted when the `CommitMessage` timeout fires. Prevents backups
/// from starting a view change during idle periods.
SendCommit {
view: u32,
commit: u64,
namespace: u64,
timestamp_monotonic: u64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrepareOkOutcome {
Accepted {
ack_count: usize,
quorum_reached: bool,
},
Ignored {
reason: IgnoreReason,
},
}
impl PrepareOkOutcome {
#[must_use]
pub const fn quorum_reached(self) -> bool {
match self {
Self::Accepted { quorum_reached, .. } => quorum_reached,
Self::Ignored { .. } => false,
}
}
}
#[allow(unused)]
#[derive(Debug)]
pub struct VsrConsensus<B = IggyMessageBus, P = LocalPipeline>
where
B: MessageBus,
P: Pipeline,
{
cluster: u128,
replica: u8,
replica_count: u8,
namespace: u64,
view: Cell<u32>,
// The latest view where
// - the replica was a primary and acquired a DVC quorum, or
// - the replica was a backup and processed a SV message.
// i.e. the latest view in which this replica changed its head message.
// Initialized from the superblock's VSRState.
// Invariants:
// * `replica.log_view ≥ replica.log_view_durable`
// * `replica.log_view = 0` when replica_count=1.
log_view: Cell<u32>,
/// Commit point the recovered WAL suffix must re-reach before admitting
/// client requests as primary (`0` = no recovered suffix pending).
recovery_barrier: Cell<u64>,
/// True until a replica that booted without its consensus state (see
/// `init_recovering`) learns the cluster commit point. While set, the
/// first `StartView` / `Commit` fast-forwards `commit_min` to the learned
/// `commit_max`: the replica's recovered durable state stands in for the
/// journal prefix it no longer has, so walking `commit_journal` from op 1
/// would find nothing and declare divergence.
recovering: Cell<bool>,
/// True while this replica declines the primaryship its (stale) recovered
/// view assigns it (see `init_as_backup`). `is_primary()` is pure view
/// math, so without this flag a restarted view-N primary would still pass
/// the submit gate and advertise itself as the roster leader while never
/// heartbeating. Cleared as soon as any view transition resolves the role
/// legitimately (`StartView` adoption, DVC completion).
ceded_primaryship: Cell<bool>,
status: Cell<Status>,
/// Highest op number that has been locally executed (state machine applied,
/// client table updated). Advances one-by-one in `commit_journal` (backup)
/// and `on_ack` (primary). On a normal primary, `commit_min == commit_max`.
commit_min: Cell<u64>,
/// Highest op number known to be committed by the cluster. Advances
/// immediately when the replica learns about commits (from prepare
/// messages, commit heartbeats, or view change messages).
commit_max: Cell<u64>,
sequencer: LocalSequencer,
last_timestamp: Cell<u64>,
last_prepare_checksum: Cell<u128>,
pipeline: RefCell<P>,
message_bus: B,
loopback_queue: RefCell<VecDeque<Message<GenericHeader>>>,
/// Tracks start view change messages received from all replicas (including self)
start_view_change_from_all_replicas: RefCell<BitSet<u32>>,
/// Tracks DVC messages received (only used by primary candidate)
/// Stores metadata; actual log comes from message
do_view_change_from_all_replicas: RefCell<DvcQuorumArray>,
/// Whether DVC quorum has been achieved in current view change
do_view_change_quorum: Cell<bool>,
/// Whether we've sent our own SVC for current view
sent_own_start_view_change: Cell<bool>,
/// Whether we've sent our own DVC for current view
sent_own_do_view_change: Cell<bool>,
timeouts: RefCell<TimeoutManager>,
/// Monotonic timestamp from the most recent accepted commit heartbeat.
/// Old/replayed commit messages with a lower timestamp are ignored.
heartbeat_timestamp: Cell<u64>,
}
impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
/// # Panics
/// - If `replica >= replica_count`.
/// - If `replica_count < 1`.
pub fn new(
cluster: u128,
replica: u8,
replica_count: u8,
namespace: u64,
message_bus: B,
pipeline: P,
) -> Self {
assert!(
replica < replica_count,
"replica index must be < replica_count"
);
assert!(replica_count >= 1, "need at least 1 replica");
// Consensus-control routing distinguishes metadata frames from
// partition frames by namespace value: metadata uses the sentinel,
// partitions use `IggyNamespace::inner()` which lives strictly
// inside the packed range. A namespace outside both ranges would
// route to neither and silently warn-drop on every receiving peer.
debug_assert!(
namespace == METADATA_CONSENSUS_NAMESPACE || IggyNamespace::is_packable(namespace),
"VsrConsensus namespace must be METADATA_CONSENSUS_NAMESPACE or a packable \
IggyNamespace; got {namespace:#x}"
);
// TODO: Verify that XOR-based seeding provides sufficient jitter diversity
// across groups. Consider using a proper hash (e.g., Murmur3) of
// (replica_id, namespace) for production.
let timeout_seed = u128::from(replica) ^ u128::from(namespace);
Self {
cluster,
replica,
replica_count,
namespace,
view: Cell::new(0),
log_view: Cell::new(0),
recovery_barrier: Cell::new(0),
recovering: Cell::new(false),
ceded_primaryship: Cell::new(false),
status: Cell::new(Status::Recovering),
sequencer: LocalSequencer::new(0),
commit_min: Cell::new(0),
commit_max: Cell::new(0),
last_timestamp: Cell::new(0),
last_prepare_checksum: Cell::new(0),
pipeline: RefCell::new(pipeline),
message_bus,
loopback_queue: RefCell::new(VecDeque::with_capacity(PIPELINE_PREPARE_QUEUE_MAX)),
start_view_change_from_all_replicas: RefCell::new(BitSet::with_capacity(REPLICAS_MAX)),
do_view_change_from_all_replicas: RefCell::new(dvc_quorum_array_empty()),
do_view_change_quorum: Cell::new(false),
sent_own_start_view_change: Cell::new(false),
sent_own_do_view_change: Cell::new(false),
timeouts: RefCell::new(TimeoutManager::new(timeout_seed)),
heartbeat_timestamp: Cell::new(0),
}
}
pub fn init(&self) {
self.status.set(Status::Normal);
let mut timeouts = self.timeouts.borrow_mut();
if self.is_primary() {
timeouts.start(TimeoutKind::Prepare);
timeouts.start(TimeoutKind::CommitMessage);
} else {
timeouts.start(TimeoutKind::NormalHeartbeat);
}
}
/// Initialize a restarted replica as a backup regardless of what its
/// recovered view says about primaryship. A resumed stale primary races
/// the peers' election: if they moved on (or move on now), two nodes act
/// primary for different planes and clients route to the wrong one. Join
/// as a backup instead; either the peers' heartbeat timeout elects a
/// primary and its `StartView` brings this replica forward, or this
/// replica's own silence provokes that election. Unlike
/// [`Self::init_recovering`] the local journal is intact, so the normal
/// commit walk applies it -- no commit-floor fast-forward.
pub fn init_as_backup(&self) {
self.status.set(Status::Normal);
self.ceded_primaryship.set(true);
self.timeouts
.borrow_mut()
.start(TimeoutKind::NormalHeartbeat);
}
/// See the `ceded_primaryship` field.
#[must_use]
pub const fn has_ceded_primaryship(&self) -> bool {
self.ceded_primaryship.get()
}
/// Initialize a replica whose consensus state did NOT survive restart
/// (e.g. a partition group: its journal is in-memory and its segments
/// carry no op numbers). Such a replica must not assume primaryship: it
/// would heartbeat `commit_min = 0`, dragging the group behind backups
/// that kept their journals, and it has no ops to re-pipeline. Join as a
/// backup instead; the peers' heartbeat timeout elects a primary that
/// still holds the log, and its `StartView` brings this replica forward.
pub fn init_recovering(&self) {
self.init_as_backup();
self.recovering.set(true);
}
#[must_use]
// cast_lossless: `u32::from()` unavailable in const fn.
// cast_possible_truncation: modulo by replica_count (u8) guarantees result fits in u8.
#[allow(clippy::cast_lossless, clippy::cast_possible_truncation)]
pub const fn primary_index(&self, view: u32) -> u8 {
(view % self.replica_count as u32) as u8
}
#[must_use]
pub const fn is_primary(&self) -> bool {
self.primary_index(self.view.get()) == self.replica
}
/// Advance `commit_max` - the highest op known to be committed by the cluster.
///
/// Called when the replica learns about new commits from the primary
/// (via prepare messages, commit heartbeats, or view change messages).
///
/// # Panics
/// If `commit_max` would be less than `commit_min` after the update
/// (invariant violation).
pub fn advance_commit_max(&self, commit: u64) {
if commit > self.commit_max.get() {
self.commit_max.set(commit);
// A prepare just committed. Re-arm the prepare-retransmit timer for
// the next-oldest pending prepare rather than letting it inherit the
// previous op's grown backoff: the push-site `start` is a no-op
// while ticking and nothing else clears `attempts`, so under
// sustained load the backoff ratchets up to 16x base and the tail
// op's retransmit fires too rarely to recover a lost backup ack,
// stalling commit. `start` (not `reset`) forces the timer ticking
// at the base interval - `reset` alone leaves `ticking` untouched,
// so a timer stopped earlier would be armed-but-dead and never
// fire. When nothing is pending the timer self-stops via the
// empty-pipeline branch in `handle_prepare_timeout`.
if self.sequencer.current_sequence() > commit {
self.timeouts.borrow_mut().start(TimeoutKind::Prepare);
}
}
assert!(self.commit_max.get() >= self.commit_min.get());
}
/// Advance `commit_min` - the highest op locally executed.
///
/// Called after each op is applied through `commit_journal` (backup)
/// or `on_ack` (primary). Must advance sequentially (by 1).
///
/// # Panics
/// - If `op` is not exactly `commit_min + 1` (must advance sequentially).
/// - If `commit_min` would exceed `commit_max` after the update.
pub fn advance_commit_min(&self, op: u64) {
assert_eq!(
op,
self.commit_min.get() + 1,
"commit_min must advance sequentially: expected {}, got {op}",
self.commit_min.get() + 1
);
self.commit_min.set(op);
assert!(self.commit_max.get() >= self.commit_min.get());
}
/// Restore local commit progress from already-applied state during bootstrap.
///
/// Unlike `advance_commit_min`, this is intended for recovery paths where the
/// state machine has already been restored up to the supplied commit point.
///
/// # Panics
/// - If `commit_min > commit_max`.
/// - If commit progress has already been initialized on this consensus instance.
pub fn restore_commit_state(&self, commit_min: u64, commit_max: u64) {
assert!(
commit_min <= commit_max,
"commit_min ({commit_min}) must be <= commit_max ({commit_max})"
);
assert_eq!(
self.commit_min.get(),
0,
"restore_commit_state must only be used on a fresh consensus instance"
);
assert_eq!(
self.commit_max.get(),
0,
"restore_commit_state must only be used on a fresh consensus instance"
);
self.commit_max.set(commit_max);
self.commit_min.set(commit_min);
}
/// Maximum number of faulty replicas that can be tolerated.
/// For a cluster of 2f+1 replicas, this returns f.
#[must_use]
pub const fn max_faulty(&self) -> usize {
(self.replica_count as usize - 1) / 2
}
/// Quorum size = f + 1 = `max_faulty` + 1
#[must_use]
pub const fn quorum(&self) -> usize {
self.max_faulty() + 1
}
/// Highest op locally executed (state machine applied, client table updated).
#[must_use]
pub const fn commit_min(&self) -> u64 {
self.commit_min.get()
}
/// Highest op known to be committed by the cluster.
#[must_use]
pub const fn commit_max(&self) -> u64 {
self.commit_max.get()
}
#[must_use]
pub const fn replica(&self) -> u8 {
self.replica
}
#[must_use]
pub const fn sequencer(&self) -> &LocalSequencer {
&self.sequencer
}
#[must_use]
pub const fn view(&self) -> u32 {
self.view.get()
}
/// Commit point the recovered WAL suffix must re-reach before this
/// replica (as primary) admits new client requests; `0` when no suffix
/// was re-pipelined. See `is_caught_up_primary`.
pub const fn recovery_barrier(&self) -> u64 {
self.recovery_barrier.get()
}
pub fn set_recovery_barrier(&self, required_commit: u64) {
self.recovery_barrier.set(required_commit);
}
pub fn set_view(&mut self, view: u32) {
self.view.set(view);
}
#[must_use]
pub const fn status(&self) -> Status {
self.status.get()
}
// TODO(hubcio): returning &RefCell<P> leaks interior mutability - callers
// could hold a Ref/RefMut across an .await and cause a runtime panic.
// We had this problem with slab + ECS.
#[must_use]
pub const fn pipeline(&self) -> &RefCell<P> {
&self.pipeline
}
#[must_use]
pub const fn pipeline_mut(&mut self) -> &mut RefCell<P> {
&mut self.pipeline
}
/// Push a pre-built [`PipelineEntry`]; start prepare timeout if idle.
///
/// Shared by [`Consensus::pipeline_message`] (no subscriber) and
/// [`Self::pipeline_message_with_subscriber`] (in-band receiver). The
/// only difference is whether the entry carries `reply_sender`;
/// everything else (sim event, timeout, primary assertion) is here.
fn push_prepare_entry(
&self,
plane: PlaneKind,
message: &Message<PrepareHeader>,
entry: PipelineEntry,
) {
assert!(self.is_primary(), "only primary can pipeline messages");
let mut pipeline = self.pipeline.borrow_mut();
pipeline.push(entry);
let pipeline_depth = pipeline.len();
drop(pipeline);
let header = message.header();
// Atomically advance sequencer + last_prepare_checksum with the
// push. Without this, a sibling on_request that runs while on_replicate
// awaits journal.append would project a duplicate op + parent.
// The late set in on_replicate (metadata.rs / iggy_partition.rs) is
// backup-only for the same reason: re-setting on primary would rewind
// past a sibling prepare pipelined during the append await.
self.sequencer.set_sequence(header.op);
self.set_last_prepare_checksum(header.checksum);
emit_sim_event(
SimEventKind::PrepareQueued,
&PrepareLogEvent {
replica: ReplicaLogContext::from_consensus(self, plane),
op: header.op,
parent_checksum: header.parent,
prepare_checksum: header.checksum,
client_id: header.client,
request_id: header.request,
operation: header.operation,
pipeline_depth,
},
);
// Start (not reset) prepare timeout: an already-ticking timer must not
// be pushed out by every new request. Drives retransmit on missing acks.
let mut timeouts = self.timeouts.borrow_mut();
if !timeouts.is_ticking(TimeoutKind::Prepare) {
timeouts.start(TimeoutKind::Prepare);
}
}
/// Push `message` with in-band reply subscriber.
///
/// Like [`Consensus::pipeline_message`], but entry is built via
/// [`PipelineEntry::with_subscriber`]; caller gets a [`Receiver`] that
/// wakes via `take_reply_sender().send(reply)` from the commit handler
/// (or `Canceled` on view-change reset / entry drop).
///
/// In-process producers (e.g. `IggyMetadata::submit_register_in_process`)
/// use this to learn their own prepare's commit without `send_to_client`.
/// Additive: wire reply still fires (`commit_register`/`commit_reply`),
/// so wire SDK + in-process awaiter both see the same reply.
///
/// # Panics
/// If not primary (mirrors [`Consensus::pipeline_message`]).
pub fn pipeline_message_with_subscriber(
&self,
plane: PlaneKind,
message: &Message<PrepareHeader>,
) -> Receiver<Message<ReplyHeader>> {
let (entry, receiver) = PipelineEntry::with_subscriber(*message.header());
self.push_prepare_entry(plane, message, entry);
receiver
}
#[must_use]
pub const fn cluster(&self) -> u128 {
self.cluster
}
#[must_use]
pub const fn replica_count(&self) -> u8 {
self.replica_count
}
#[must_use]
pub const fn namespace(&self) -> u64 {
self.namespace
}
#[must_use]
pub const fn last_prepare_checksum(&self) -> u128 {
self.last_prepare_checksum.get()
}
pub fn set_last_prepare_checksum(&self, checksum: u128) {
self.last_prepare_checksum.set(checksum);
}
/// Returns a primary-stamped prepare timestamp that is strictly greater
/// than every previously-stamped value on this primary.
///
/// Without monotonicity, an NTP step backwards could produce
/// `prepare[N+1].timestamp < prepare[N].timestamp`, breaking
/// `created_at` ordering invariants in deterministic state.
pub fn next_monotonic_timestamp(&self) -> u64 {
let now = IggyTimestamp::now().as_micros();
let prev = self.last_timestamp.get();
let next = now.max(prev.saturating_add(1));
self.last_timestamp.set(next);
next
}
#[must_use]
pub const fn log_view(&self) -> u32 {
self.log_view.get()
}
pub fn set_log_view(&self, log_view: u32) {
self.log_view.set(log_view);
}
#[must_use]
pub const fn is_primary_for_view(&self, view: u32) -> bool {
self.primary_index(view) == self.replica
}
/// Count SVCs from OTHER replicas (excluding self).
fn svc_count_excluding_self(&self) -> usize {
let svc = self.start_view_change_from_all_replicas.borrow();
let total = svc.count();
if svc.contains(self.replica as usize) {
total.saturating_sub(1)
} else {
total
}
}
/// Reset SVC quorum tracking.
fn reset_svc_quorum(&self) {
self.start_view_change_from_all_replicas
.borrow_mut()
.make_empty();
}
/// Reset DVC quorum tracking.
fn reset_dvc_quorum(&self) {
dvc_reset(&mut self.do_view_change_from_all_replicas.borrow_mut());
self.do_view_change_quorum.set(false);
}
/// Reset view-change state on view transition.
///
/// - Clear loopback (stale `PrepareOks` would no-op).
/// - Cancel subscribers (awaiters wake with `Canceled`).
/// - Drop `request_queue` (buffered requests have no DVC role).
///
/// `prepare_queue` survives here for DVC log reconciliation; cleared
/// at view-change *completion*.
///
/// # Safety
/// `request_queue` clear required: a future broadening of
/// `drain_request_queue_into_prepares` could project stale entries
/// via `pipeline_prepare_common`, which panics on non-normal status.
pub(crate) fn reset_view_change_state(&self) {
self.reset_svc_quorum();
self.reset_dvc_quorum();
self.sent_own_start_view_change.set(false);
self.sent_own_do_view_change.set(false);
self.loopback_queue.borrow_mut().clear();
let mut pipeline = self.pipeline.borrow_mut();
pipeline.cancel_all_subscribers();
pipeline.clear_request_queue();
}
/// Process one tick. Call this periodically (e.g., every 10ms).
///
/// Returns a list of actions to take based on fired timeouts.
/// Empty vec means no actions needed.
pub fn tick(&self, plane: PlaneKind) -> Vec<VsrAction> {
let mut actions = Vec::new();
let mut timeouts = self.timeouts.borrow_mut();
// Phase 1: Tick all timeouts
timeouts.tick();
// Phase 2: Handle fired timeouts
if timeouts.fired(TimeoutKind::NormalHeartbeat) {
drop(timeouts);
actions.extend(self.handle_normal_heartbeat_timeout(plane));
timeouts = self.timeouts.borrow_mut();
}
if timeouts.fired(TimeoutKind::StartViewChangeMessage) {
drop(timeouts);
actions.extend(self.handle_start_view_change_message_timeout(plane));
timeouts = self.timeouts.borrow_mut();
}
if timeouts.fired(TimeoutKind::DoViewChangeMessage) {
drop(timeouts);
actions.extend(self.handle_do_view_change_message_timeout(plane));
timeouts = self.timeouts.borrow_mut();
}
if timeouts.fired(TimeoutKind::Prepare) {
drop(timeouts);
actions.extend(self.handle_prepare_timeout());
timeouts = self.timeouts.borrow_mut();
}
if timeouts.fired(TimeoutKind::CommitMessage) {
drop(timeouts);
actions.extend(self.handle_commit_message_timeout());
timeouts = self.timeouts.borrow_mut();
}
if timeouts.fired(TimeoutKind::ViewChangeStatus) {
drop(timeouts);
actions.extend(self.handle_view_change_status_timeout(plane));
// timeouts = self.timeouts.borrow_mut(); // Not needed if last
}
actions
}
/// Called when `normal_heartbeat` timeout fires.
/// Backup hasn't heard from primary - start view change.
fn handle_normal_heartbeat_timeout(&self, plane: PlaneKind) -> Vec<VsrAction> {
// Only backups trigger view change on heartbeat timeout. `is_primary`
// is pure view math though: a replica that booted recovering / with
// ceded primaryship while sitting at the primary index is a backup by
// role -- if it early-returned here it would neither heartbeat nor
// start an election, silently dropping out of quorum until an
// unrelated view change rescues it. Let it climb StartViewChange like
// any other backup.
if self.is_primary() && !self.recovering.get() && !self.ceded_primaryship.get() {
return Vec::new();
}
// Already in view change
if self.status.get() == Status::ViewChange {
return Vec::new();
}
// Advance to new view and transition to view change
let old_view = self.view.get();
let new_view = old_view + 1;
self.view.set(new_view);
self.status.set(Status::ViewChange);
self.reset_view_change_state();
self.sent_own_start_view_change.set(true);
self.start_view_change_from_all_replicas
.borrow_mut()
.insert(self.replica as usize);
// Update timeouts for view change status
{
let mut timeouts = self.timeouts.borrow_mut();
timeouts.stop(TimeoutKind::NormalHeartbeat);
timeouts.start(TimeoutKind::StartViewChangeMessage);
timeouts.start(TimeoutKind::ViewChangeStatus);
}
emit_sim_event(
SimEventKind::ViewChangeStarted,
&ViewChangeLogEvent {
replica: ReplicaLogContext::from_consensus(self, plane),
old_view,
new_view,
reason: ViewChangeReason::NormalHeartbeatTimeout,
},
);
let action = VsrAction::SendStartViewChange {
view: new_view,
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&action,
),
);
vec![action]
}
/// Resend SVC message if we've started view change.
fn handle_start_view_change_message_timeout(&self, plane: PlaneKind) -> Vec<VsrAction> {
if !self.sent_own_start_view_change.get() {
return Vec::new();
}
self.timeouts
.borrow_mut()
.reset(TimeoutKind::StartViewChangeMessage);
let action = VsrAction::SendStartViewChange {
view: self.view.get(),
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&action,
),
);
vec![action]
}
/// Resend DVC message if we've sent one.
fn handle_do_view_change_message_timeout(&self, plane: PlaneKind) -> Vec<VsrAction> {
if self.status.get() != Status::ViewChange {
return Vec::new();
}
if !self.sent_own_do_view_change.get() {
return Vec::new();
}
// If we're primary candidate with quorum, don't resend
if self.is_primary() && self.do_view_change_quorum.get() {
return Vec::new();
}
self.timeouts
.borrow_mut()
.reset(TimeoutKind::DoViewChangeMessage);
let current_op = self.sequencer.current_sequence();
let action = VsrAction::SendDoViewChange {
view: self.view.get(),
target: self.primary_index(self.view.get()),
log_view: self.log_view.get(),
op: current_op,
// commit_max clamped to op: see `handle_start_view_change`.
commit: self.commit_max.get().min(current_op),
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&action,
),
);
vec![action]
}
/// Escalate to next view if stuck in view change.
fn handle_view_change_status_timeout(&self, plane: PlaneKind) -> Vec<VsrAction> {
if self.status.get() != Status::ViewChange {
return Vec::new();
}
// Escalate: try next view
let old_view = self.view.get();
let next_view = old_view + 1;
self.view.set(next_view);
self.reset_view_change_state();
self.sent_own_start_view_change.set(true);
self.start_view_change_from_all_replicas
.borrow_mut()
.insert(self.replica as usize);
self.timeouts
.borrow_mut()
.reset(TimeoutKind::ViewChangeStatus);
emit_sim_event(
SimEventKind::ViewChangeStarted,
&ViewChangeLogEvent {
replica: ReplicaLogContext::from_consensus(self, plane),
old_view,
new_view: next_view,
reason: ViewChangeReason::ViewChangeStatusTimeout,
},
);
let action = VsrAction::SendStartViewChange {
view: next_view,
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&action,
),
);
vec![action]
}
/// Collect uncommitted pipeline entries that should be retransmitted.
///
/// Returns `(PrepareHeader, Vec<u8>)` pairs: each op that hasn't reached
/// quorum paired with the replica IDs that haven't acked it.
fn retransmit_targets(&self) -> Vec<(PrepareHeader, Vec<u8>)> {
let pipeline = self.pipeline.borrow();
let current_op = self.sequencer.current_sequence();
let replica_count = self.replica_count;
let mut targets = Vec::new();
let mut op = self.commit_max() + 1;
while op <= current_op {
if let Some(entry) = pipeline.entry_by_op(op)
&& !entry.ok_quorum_received
{
let missing: Vec<u8> = (0..replica_count).filter(|&r| !entry.has_ack(r)).collect();
if !missing.is_empty() {
targets.push((entry.header, missing));
}
}
op += 1;
}
targets
}
/// Retransmit uncommitted prepares when the prepare timeout fires.
///
/// Only acts on the primary in normal status with a non-empty pipeline.
/// Resets the timeout with backoff on each firing.
fn handle_prepare_timeout(&self) -> Vec<VsrAction> {
// TODO(prepare-timeout): adopt TigerBeetle's timer lifecycle
// (replica.zig `on_prepare_ok` / `on_prepare_timeout`). They
// disarm in the ack path the moment quorum drains the pipeline
// (`stop()`) and rearm for the next-oldest prepare when one
// commits with others still pending (`reset()`), giving the
// invariant "ticking iff pipeline non-empty" (asserted in their
// timeout handler) and a timeout that always measures the
// current oldest prepare's age. Ours arms once per idle->busy
// transition and disarms lazily below, so a prepare pushed late
// into an armed window can be retransmitted before it is
// `PREPARE_TICKS` old. They also special-case "all remote acks
// present, own journal write is the laggard" by retrying the
// local write instead of retransmitting.
//
// Every early return below must stop or back off the timeout.
// `fired()` stays true until the timer is rearmed, so returning
// with the fired state intact turns the next pipeline push into
// an instant spurious retransmit on the following tick (the push
// sees `is_ticking` and does not restart the timer).
if !self.is_primary() || self.status.get() != Status::Normal {
self.timeouts.borrow_mut().stop(TimeoutKind::Prepare);
return Vec::new();
}
if self.pipeline.borrow().is_empty() {
// Everything committed before the timeout fired; the next
// push restarts the timer from zero.
self.timeouts.borrow_mut().stop(TimeoutKind::Prepare);
return Vec::new();
}
let targets = self.retransmit_targets();
if targets.is_empty() {
// In-flight ops all have their acks; re-check after backoff.
self.timeouts.borrow_mut().backoff(TimeoutKind::Prepare);
return Vec::new();
}
tracing::debug!(
replica = self.replica,
view = self.view.get(),
targets = targets.len(),
first_op = targets.first().map(|(h, _)| h.op),
"prepare timeout: retransmitting un-acked prepares"
);
self.timeouts.borrow_mut().backoff(TimeoutKind::Prepare);
vec![VsrAction::RetransmitPrepares { targets }]
}
/// Primary heartbeat: send commit point to all backups so they know
/// the primary is alive and can advance their own `commit_max`.
fn handle_commit_message_timeout(&self) -> Vec<VsrAction> {
if !self.is_primary() || self.status.get() != Status::Normal {
return Vec::new();
}
self.timeouts.borrow_mut().reset(TimeoutKind::CommitMessage);
// Don't advertise a commit point we haven't locally executed yet.
// After view change the new primary may have commit_min < commit_max
// until commit_journal catches up. Send commit_min (what we've
// actually applied) so backups don't advance past us.
let ts = self.heartbeat_timestamp.get() + 1;
self.heartbeat_timestamp.set(ts);
vec![VsrAction::SendCommit {
view: self.view.get(),
commit: self.commit_min.get(),
namespace: self.namespace,
timestamp_monotonic: ts,
}]
}
/// Handle a received `StartViewChange` message.
///
/// "When replica i receives STARTVIEWCHANGE messages for its view-number
/// from f OTHER replicas, it sends a DOVIEWCHANGE message to the node
/// that will be the primary in the new view."
///
/// # Panics
/// If `header.namespace` does not match this replica's namespace.
pub fn handle_start_view_change(
&self,
plane: PlaneKind,
header: &StartViewChangeHeader,
) -> Vec<VsrAction> {
assert_eq!(
header.namespace, self.namespace,
"SVC routed to wrong group"
);
let from_replica = header.replica;
let msg_view = header.view;
// Ignore SVCs for old views
if msg_view < self.view.get() {
return Vec::new();
}
let mut actions = Vec::new();
// If SVC is for a higher view, advance to that view
if msg_view > self.view.get() {
let old_view = self.view.get();
self.view.set(msg_view);
self.status.set(Status::ViewChange);
self.reset_view_change_state();
self.sent_own_start_view_change.set(true);
self.start_view_change_from_all_replicas
.borrow_mut()
.insert(self.replica as usize);
// Update timeouts
{
let mut timeouts = self.timeouts.borrow_mut();
timeouts.stop(TimeoutKind::NormalHeartbeat);
timeouts.start(TimeoutKind::StartViewChangeMessage);
timeouts.start(TimeoutKind::ViewChangeStatus);
}
emit_sim_event(
SimEventKind::ViewChangeStarted,
&ViewChangeLogEvent {
replica: ReplicaLogContext::from_consensus(self, plane),
old_view,
new_view: msg_view,
reason: ViewChangeReason::ReceivedStartViewChange,
},
);
// Send our own SVC
let action = VsrAction::SendStartViewChange {
view: msg_view,
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&action,
),
);
actions.push(action);
}
// Record the SVC from sender
self.start_view_change_from_all_replicas
.borrow_mut()
.insert(from_replica as usize);
// Check if we have f SVCs from OTHER replicas
// We need f SVCs from others to send DVC
if !self.sent_own_do_view_change.get()
&& self.svc_count_excluding_self() >= self.max_faulty()
{
self.sent_own_do_view_change.set(true);
let primary_candidate = self.primary_index(self.view.get());
let current_op = self.sequencer.current_sequence();
// DVC carries commit_max (highest known-committed), not commit_min
// (locally applied). The new primary floors its pipeline rebuild at
// max(commit) across the quorum; only commit_max bounds that range
// to pipeline depth (every replica holds op - commit_max <= depth).
// commit_min can lag far behind and overflow the rebuild. The
// committed-but-unapplied tail (commit_min..commit_max] is replayed
// by the new primary's CommitJournal, not the pipeline.
//
// Clamp to op: a backup learns commit_max from a heartbeat before
// receiving the prepares, so commit_max can exceed its op. The wire
// contract `DoViewChangeHeader::validate` rejects commit > op and
// drops such a DVC (view-change liveness stall). The clamp is
// lossless for the rebuild floor: quorum intersection guarantees
// some sender whose op covers the true commit point carries it, so
// max(commit) across the quorum is unchanged.
let commit = self.commit_max.get().min(current_op);
// Start DVC timeout
self.timeouts
.borrow_mut()
.start(TimeoutKind::DoViewChangeMessage);
let action = VsrAction::SendDoViewChange {
view: self.view.get(),
target: primary_candidate,
log_view: self.log_view.get(),
op: current_op,
commit,
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&action,
),
);
actions.push(action);
// If we are the primary candidate, record our own DVC
if primary_candidate == self.replica {
let own_dvc = StoredDvc {
replica: self.replica,
log_view: self.log_view.get(),
op: current_op,
commit,
};
dvc_record(
&mut self.do_view_change_from_all_replicas.borrow_mut(),
own_dvc,
);
// Check if we now have quorum
if dvc_count(&self.do_view_change_from_all_replicas.borrow()) >= self.quorum() {
self.do_view_change_quorum.set(true);
actions.extend(self.complete_view_change_as_primary(plane));
}
}
}
actions
}
/// Handle a received `DoViewChange` message (only relevant for primary candidate).
///
/// "When the new primary receives f + 1 DOVIEWCHANGE messages from different
/// replicas (including itself), it sets its view-number to that in the messages
/// and selects as the new log the one contained in the message with the largest v'..."
///
/// # Panics
/// If `header.namespace` does not match this replica's namespace.
pub fn handle_do_view_change(
&self,
plane: PlaneKind,
header: &DoViewChangeHeader,
) -> Vec<VsrAction> {
assert_eq!(
header.namespace, self.namespace,
"DVC routed to wrong group"
);
let from_replica = header.replica;
let msg_view = header.view;
let msg_log_view = header.log_view;
let msg_op = header.op;
let msg_commit = header.commit;
// Ignore DVCs for old views
if msg_view < self.view.get() {
return Vec::new();
}
let mut actions = Vec::new();
// If DVC is for a higher view, advance to that view
if msg_view > self.view.get() {
let old_view = self.view.get();
self.view.set(msg_view);
self.status.set(Status::ViewChange);
self.reset_view_change_state();
self.sent_own_start_view_change.set(true);
self.start_view_change_from_all_replicas
.borrow_mut()
.insert(self.replica as usize);
// Update timeouts
{
let mut timeouts = self.timeouts.borrow_mut();
timeouts.stop(TimeoutKind::NormalHeartbeat);
timeouts.start(TimeoutKind::StartViewChangeMessage);
timeouts.start(TimeoutKind::ViewChangeStatus);
}
emit_sim_event(
SimEventKind::ViewChangeStarted,
&ViewChangeLogEvent {
replica: ReplicaLogContext::from_consensus(self, plane),
old_view,
new_view: msg_view,
reason: ViewChangeReason::ReceivedDoViewChange,
},
);
// Send our own SVC
let action = VsrAction::SendStartViewChange {
view: msg_view,
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&action,
),
);
actions.push(action);
}
// Only the primary candidate processes DVCs for quorum
if !self.is_primary_for_view(self.view.get()) {
return actions;
}
// Must be in view change to process DVCs
if self.status.get() != Status::ViewChange {
return actions;
}
let current_op = self.sequencer.current_sequence();
// commit_max clamped to op: see `handle_start_view_change`.
let commit = self.commit_max.get().min(current_op);
// If we haven't sent our own DVC yet, record it
if !self.sent_own_do_view_change.get() {
self.sent_own_do_view_change.set(true);
let own_dvc = StoredDvc {
replica: self.replica,
log_view: self.log_view.get(),
op: current_op,
commit,
};
dvc_record(
&mut self.do_view_change_from_all_replicas.borrow_mut(),
own_dvc,
);
}
// Record the received DVC
let dvc = StoredDvc {
replica: from_replica,
log_view: msg_log_view,
op: msg_op,
commit: msg_commit,
};
dvc_record(&mut self.do_view_change_from_all_replicas.borrow_mut(), dvc);
// Check if quorum achieved
if !self.do_view_change_quorum.get()
&& dvc_count(&self.do_view_change_from_all_replicas.borrow()) >= self.quorum()
{
self.do_view_change_quorum.set(true);
actions.extend(self.complete_view_change_as_primary(plane));
}
actions
}
/// Handle a received `StartView` message (backups only).
///
/// "When other replicas receive the STARTVIEW message, they replace their log
/// with the one in the message, set their op-number to that of the latest entry
/// in the log, set their view-number to the view number in the message, change
/// their status to normal, and send `PrepareOK` for any uncommitted ops."
///
/// # Panics
/// If `header.namespace` does not match this replica's namespace.
/// # Client-table maintenance
///
/// Backups maintain the client-table during normal operation via
/// `commit_journal` in `on_replicate`, which walks the WAL and updates
/// the client table for each committed op. The WAL survives view changes,
/// so the new primary can process any committed op it received.
///
/// Gap: if a backup never received a prepare (lost message),
/// `commit_journal` stops at the gap. Requires message repair.
pub fn handle_start_view(&self, plane: PlaneKind, header: &StartViewHeader) -> Vec<VsrAction> {
assert_eq!(header.namespace, self.namespace, "SV routed to wrong group");
let from_replica = header.replica;
let msg_view = header.view;
let msg_op = header.op;
let msg_commit = header.commit;
// Verify sender is the primary for this view
if self.primary_index(msg_view) != from_replica {
return Vec::new();
}
// Ignore old views
if msg_view < self.view.get() {
return Vec::new();
}
// Skip equal-view SV with old op.
// Already in this view; re-running reset_view_change_state would
// cancel subscribers (waking register awaiters Canceled) and clear
// pipeline for nothing. log_view (not self.view) tracks last-normal view.
if msg_view == self.log_view.get() && msg_op < self.sequencer.current_sequence() {
return Vec::new();
}
// We shouldn't process our own StartView
if from_replica == self.replica {
return Vec::new();
}
// Accept the StartView and transition to normal
tracing::info!(
replica = self.replica,
old_view = self.view.get(),
new_view = msg_view,
op = msg_op,
commit = msg_commit,
"adopting view from StartView"
);
self.view.set(msg_view);
self.log_view.set(msg_view);
self.status.set(Status::Normal);
self.ceded_primaryship.set(false);
self.advance_commit_max(msg_commit);
self.fast_forward_recovering_commit_floor();
self.reset_view_change_state();
// Stale pipeline entries from the old view must be discarded
self.pipeline.borrow_mut().clear();
// TODO: StartView should carry uncommitted headers so backup installs
// into WAL and sets op WAL-verified. Today we trust msg_op, correct
// for truncation (sequencer > msg_op) but wrong when behind
// (sequencer < msg_op): gap is unreachable without message repair.
self.sequencer.set_sequence(msg_op);
// Update timeouts for normal backup operation
{
let mut timeouts = self.timeouts.borrow_mut();
timeouts.stop(TimeoutKind::ViewChangeStatus);
timeouts.stop(TimeoutKind::DoViewChangeMessage);
timeouts.stop(TimeoutKind::RequestStartViewMessage);
timeouts.start(TimeoutKind::NormalHeartbeat);
}
// Send PrepareOK for uncommitted ops that we actually have in the WAL.
// The caller must verify each op exists before sending.
emit_replica_event(
SimEventKind::ReplicaStateChanged,
&ReplicaLogContext::from_consensus(self, plane),
);
// CommitJournal so backup applies inherited ops to client_table now,
// mirroring `complete_view_change_as_primary`. Without this, the
// table lags until the next Commit heartbeat / Prepare, a
// promoted-resigned-re-elected primary running register_preflight
// in that window observes incomplete state.
let mut actions = Vec::new();
actions.push(VsrAction::CommitJournal);
if msg_commit < msg_op {
let send_prepare_ok = VsrAction::SendPrepareOk {
view: msg_view,
from_op: msg_commit + 1,
to_op: msg_op,
target: from_replica,
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&send_prepare_ok,
),
);
actions.push(send_prepare_ok);
}
actions
}
/// Handle a `Commit` (heartbeat) message from the primary.
///
/// Advances `commit_max` and resets the backup's `NormalHeartbeat` timeout
/// so it doesn't start a spurious view change. Returns `true` if
/// `commit_max` advanced, signalling the caller to run `commit_journal`.
///
/// Only accepts heartbeats with a strictly newer monotonic timestamp
/// to prevent old/replayed messages from suppressing view changes.
///
/// # Panics
/// If `header.namespace` does not match this replica's namespace.
pub fn handle_commit(&self, header: &iggy_binary_protocol::CommitHeader) -> CommitOutcome {
assert_eq!(
header.namespace, self.namespace,
"Commit routed to wrong group"
);
if self.is_primary() {
// A heartbeat from the primary of an OLDER view means that
// replica missed our view change entirely -- typically it
// restarted while the view advanced and recovered the stale
// view from its journal (there is no durable view watermark),
// so the SVC/DVC/SV exchange never reached it. Left alone it
// wedges: it drops our newer-view traffic as foreign and we
// drop its stale prepares, while its live heartbeats keep its
// backups from electing anyone. Re-announcing the current view
// lets its `handle_start_view` adopt the view and cancel its
// stale pipeline.
if self.status.get() == Status::Normal
&& header.view < self.view.get()
&& header.replica == self.primary_index(header.view)
{
return CommitOutcome::RespondStartView;
}
return CommitOutcome::Accepted;
}
if self.status.get() != Status::Normal {
return CommitOutcome::Accepted;
}
if header.view != self.view.get() {
return CommitOutcome::Accepted;
}
// TODO: Once connection-level peer verification is added promote
// this to an assert, the network layer would guarantee the sender
// matches header.replica.
if header.replica != self.primary_index(header.view) {
return CommitOutcome::Accepted;
}
// Only accept heartbeats with a strictly newer timestamp to prevent
// old/replayed commit messages from resetting the timeout.
if self.heartbeat_timestamp.get() < header.timestamp_monotonic {
self.heartbeat_timestamp.set(header.timestamp_monotonic);
self.timeouts
.borrow_mut()
.reset(TimeoutKind::NormalHeartbeat);
}
let old_commit_max = self.commit_max.get();
self.advance_commit_max(header.commit);
if self.recovering.get() {
// First learned commit point after a state-less boot: the
// recovered durable data stands in for the journal prefix, so
// there is nothing local to apply for it.
self.fast_forward_recovering_commit_floor();
return CommitOutcome::Accepted;
}
if self.commit_max.get() > old_commit_max {
CommitOutcome::Advanced
} else {
CommitOutcome::Accepted
}
}
/// See the `recovering` field: align `commit_min` with the learned
/// `commit_max` exactly once, then leave recovery mode.
fn fast_forward_recovering_commit_floor(&self) {
if !self.recovering.get() {
return;
}
self.recovering.set(false);
let commit_max = self.commit_max.get();
if commit_max > self.commit_min.get() {
tracing::info!(
replica = self.replica,
namespace_raw = self.namespace,
commit_max,
"recovering replica adopting cluster commit floor"
);
self.commit_min.set(commit_max);
}
}
/// Complete view change as the new primary after collecting DVC quorum.
///
/// # Client-table maintenance
///
/// Backups populate the client-table during normal operation via
/// `commit_journal` in `on_replicate`. The WAL survives view changes, so
/// when this replica transitions from backup to primary, its table
/// contains entries for all committed ops it received.
///
/// Gap: missing prepares (lost messages) require message repair.
fn complete_view_change_as_primary(&self, plane: PlaneKind) -> Vec<VsrAction> {
let dvc_array = self.do_view_change_from_all_replicas.borrow();
let Some(winner) = dvc_select_winner(&dvc_array) else {
return Vec::new();
};
let new_op = winner.op;
let max_commit = dvc_max_commit(&dvc_array);
// Update state
self.log_view.set(self.view.get());
self.status.set(Status::Normal);
self.ceded_primaryship.set(false);
self.advance_commit_max(max_commit);
self.sequencer.set_sequence(new_op);
// Stale pipeline entries are invalid in new view; reconciliation
// replays from journal.
//
// Cancel BEFORE clear: relying on Sender::Drop is correct today
// (drop → Canceled), but a future refactor that moves senders
// out-of-band could silently lose the wake-up. Explicit cancel
// pins the contract.
{
let mut pipeline = self.pipeline.borrow_mut();
pipeline.cancel_all_subscribers();
pipeline.clear();
}
// Stale PrepareOk messages from the old view must not leak into the new view.
// `reset_view_change_state` handles this for view-number advances (SVC/DVC/SV),
// but this path fires within the current view after DVC quorum -- so we clear
// the loopback queue directly.
self.loopback_queue.borrow_mut().clear();
// Update timeouts for normal primary operation
{
let mut timeouts = self.timeouts.borrow_mut();
timeouts.stop(TimeoutKind::ViewChangeStatus);
timeouts.stop(TimeoutKind::DoViewChangeMessage);
timeouts.stop(TimeoutKind::StartViewChangeMessage);
timeouts.start(TimeoutKind::CommitMessage);
// If there are uncommitted ops in the rebuilt pipeline, start the
// Prepare timeout so that lost PrepareOks trigger retransmission.
if max_commit < new_op {
timeouts.start(TimeoutKind::Prepare);
}
}
let state = ReplicaLogContext::from_consensus(self, plane);
emit_replica_event(SimEventKind::PrimaryElected, &state);
emit_replica_event(SimEventKind::ReplicaStateChanged, &state);
let action = VsrAction::SendStartView {
view: self.view.get(),
op: new_op,
commit: max_commit,
namespace: self.namespace,
};
emit_sim_event(
SimEventKind::ControlMessageScheduled,
&ControlActionLogEvent::from_vsr_action(
ReplicaLogContext::from_consensus(self, plane),
&action,
),
);
let mut actions = vec![action];
// Catch up commit_min to commit_max before rebuilding the pipeline.
// Without this, a behind backup (commit_min < max_commit) that becomes
// primary would have unapplied committed ops.
actions.push(VsrAction::CommitJournal);
// The new primary must rebuild its pipeline from the journal so that
// incoming PrepareOk messages can be matched and commits can proceed.
if max_commit < new_op {
assert!(
(new_op - max_commit) <= PIPELINE_PREPARE_QUEUE_MAX as u64,
"view change: uncommitted range {}..={} ({} ops) exceeds pipeline capacity ({}); \
DVC winner claims more in-flight ops than the pipeline can hold",
max_commit + 1,
new_op,
new_op - max_commit,
PIPELINE_PREPARE_QUEUE_MAX,
);
actions.push(VsrAction::RebuildPipeline {
from_op: max_commit + 1,
to_op: new_op,
});
}
actions
}
/// Handle a `PrepareOk` message from a replica.
///
/// Returns rich ack-progress information for structured logging.
/// Caller (`on_ack`) should validate `is_primary` and status before calling.
///
/// # Panics
/// - If `header.command` is not `Command2::PrepareOk`.
/// - If `header.replica >= self.replica_count`.
pub fn handle_prepare_ok(
&self,
plane: PlaneKind,
header: &PrepareOkHeader,
) -> PrepareOkOutcome {
assert_eq!(header.command, Command2::PrepareOk);
assert!(
header.replica < self.replica_count,
"handle_prepare_ok: invalid replica {}",
header.replica
);
// Ignore if from older view
if header.view < self.view() {
return PrepareOkOutcome::Ignored {
reason: IgnoreReason::OlderView,
};
}
// Ignore if from newer view
if header.view > self.view() {
return PrepareOkOutcome::Ignored {
reason: IgnoreReason::NewerView,
};
}
// Ignore if syncing
if self.is_syncing() {
return PrepareOkOutcome::Ignored {
reason: IgnoreReason::Syncing,
};
}
// Find the prepare in our pipeline
let mut pipeline = self.pipeline.borrow_mut();
let Some(entry) = pipeline.entry_by_op_mut(header.op) else {
// Not in pipeline - could be old/duplicate or already committed
return PrepareOkOutcome::Ignored {
reason: IgnoreReason::UnknownPrepare,
};
};
// Verify checksum matches
if entry.header.checksum != header.prepare_checksum {
return PrepareOkOutcome::Ignored {
reason: IgnoreReason::ChecksumMismatch,
};
}
// Check for duplicate ack
if entry.has_ack(header.replica) {
return PrepareOkOutcome::Ignored {
reason: IgnoreReason::DuplicateAck,
};
}
// Record the ack from this replica
let ack_count = entry.add_ack(header.replica);
let quorum = self.quorum();
let quorum_reached = ack_count >= quorum && !entry.ok_quorum_received;
// Check if we've reached quorum
if quorum_reached {
entry.ok_quorum_received = true;
}
drop(pipeline);
emit_sim_event(
SimEventKind::PrepareAcked,
&AckLogEvent {
replica: ReplicaLogContext::from_consensus(self, plane),
op: header.op,
prepare_checksum: header.prepare_checksum,
ack_from_replica: header.replica,
ack_count,
quorum,
quorum_reached,
},
);
PrepareOkOutcome::Accepted {
ack_count,
quorum_reached,
}
}
/// Enqueue a self-addressed message for processing in the next loopback drain.
///
/// Currently only `PrepareOk` messages are routed here (via `send_or_loopback`).
// TODO: Route SVC/DVC self-messages through loopback once VsrAction dispatch is implemented.
pub(crate) fn push_loopback(&self, message: Message<GenericHeader>) {
assert!(
self.loopback_queue.borrow().len() < PIPELINE_PREPARE_QUEUE_MAX,
"loopback queue overflow: {} items",
self.loopback_queue.borrow().len()
);
self.loopback_queue.borrow_mut().push_back(message);
}
/// Drain all pending loopback messages into `buf`, leaving the queue empty.
///
/// The caller must dispatch each drained message to the appropriate handler.
pub fn drain_loopback_into(&self, buf: &mut Vec<Message<GenericHeader>>) {
buf.extend(self.loopback_queue.borrow_mut().drain(..));
}
/// Send a message to `target`, routing self-addressed messages through the loopback queue.
// VsrConsensus uses Cell/RefCell for single-threaded compio shards; futures are intentionally !Send.
#[allow(clippy::future_not_send)]
pub(crate) async fn send_or_loopback(&self, target: u8, message: Message<GenericHeader>)
where
B: MessageBus,
{
if target == self.replica {
self.push_loopback(message);
} else if let Err(e) = self
.message_bus
.send_to_replica(target, message.into_frozen())
.await
{
tracing::warn!(
replica = self.replica,
target,
"send_or_loopback failed: {e}"
);
}
}
#[must_use]
pub const fn message_bus(&self) -> &B {
&self.message_bus
}
}
impl<B, P> Project<Message<PrepareHeader>, VsrConsensus<B, P>> for Message<RequestHeader>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
type Consensus = VsrConsensus<B, P>;
fn project(self, consensus: &Self::Consensus) -> Message<PrepareHeader> {
let op = consensus.sequencer.current_sequence() + 1;
// Primary stamps wall-clock once at prepare-build; the value is
// replicated to every backup so apply() reads the same timestamp
// across the cluster (deterministic state-machine apply). Monotonic
// wrapper guards against NTP rewinds; see
// `VsrConsensus::next_monotonic_timestamp`.
let timestamp = consensus.next_monotonic_timestamp();
self.transmute_header(|old, new| {
*new = PrepareHeader {
cluster: consensus.cluster,
size: old.size,
view: consensus.view.get(),
release: old.release,
command: Command2::Prepare,
replica: consensus.replica,
client: old.client,
parent: consensus.last_prepare_checksum(),
request_checksum: old.request_checksum,
request: old.request,
commit: consensus.commit_max.get(),
op,
timestamp,
operation: old.operation,
namespace: old.namespace,
// Copied verbatim: carries the stamped acting user for client
// ops (and the authenticated user on Register), so the in-apply
// RBAC gate resolves the same identity on every backup.
user_id: old.user_id,
..Default::default()
}
})
}
}
impl<B, P> Project<Message<PrepareOkHeader>, VsrConsensus<B, P>> for Message<PrepareHeader>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
type Consensus = VsrConsensus<B, P>;
#[allow(clippy::cast_possible_truncation)]
fn project(self, consensus: &Self::Consensus) -> Message<PrepareOkHeader> {
self.transmute_header(|old, new| {
*new = PrepareOkHeader {
command: Command2::PrepareOk,
parent: old.parent,
prepare_checksum: old.checksum,
request: old.request,
cluster: consensus.cluster,
replica: consensus.replica,
// It's important to use the view of the replica, not the received prepare!
view: consensus.view.get(),
op: old.op,
commit: consensus.commit_max.get(),
timestamp: old.timestamp,
operation: old.operation,
namespace: old.namespace,
// PrepareOk is header-only; the frame is exactly the header, so
// `size` is the header size.
size: std::mem::size_of::<PrepareOkHeader>() as u32,
..Default::default()
};
})
}
}
impl<B, P> Consensus for VsrConsensus<B, P>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
{
type MessageBus = B;
#[rustfmt::skip] // Scuffed formatter. TODO: Make the naming less ambiguous for `Message`.
type Message<H> = Message<H> where H: ConsensusHeader;
type RequestHeader = RequestHeader;
type ReplicateHeader = PrepareHeader;
type AckHeader = PrepareOkHeader;
type Sequencer = LocalSequencer;
type Pipeline = P;
// The primary's self-ack is delivered via the loopback queue
// (push_loopback / drain_loopback_into) rather than inline here,
// so that WAL persistence can happen between pipeline insertion
// and ack recording.
fn pipeline_message(&self, plane: PlaneKind, message: &Self::Message<Self::ReplicateHeader>) {
self.push_prepare_entry(plane, message, PipelineEntry::new(*message.header()));
}
fn verify_pipeline(&self) {
let pipeline = self.pipeline.borrow();
pipeline.verify();
}
fn is_follower(&self) -> bool {
!self.is_primary()
}
fn is_normal(&self) -> bool {
self.status() == Status::Normal
}
fn is_syncing(&self) -> bool {
// TODO: for now return false. we have to add syncing related setup to VsrConsensus to make this work.
false
}
}
#[cfg(test)]
mod request_queue_tests {
use super::*;
use iggy_binary_protocol::{Command2, Operation};
fn make_request(client: u128, request_num: u64) -> Message<RequestHeader> {
let header_size = std::mem::size_of::<RequestHeader>();
let mut msg = Message::<RequestHeader>::new(header_size);
let header = bytemuck::checked::try_from_bytes_mut::<RequestHeader>(
&mut msg.as_mut_slice()[..header_size],
)
.expect("zeroed bytes are valid");
*header = RequestHeader {
command: Command2::Request,
client,
session: 1,
request: request_num,
operation: Operation::SendMessages,
..RequestHeader::default()
};
msg
}
#[test]
fn push_request_buffers_when_prepare_queue_full() {
let mut pipeline = LocalPipeline::new();
// Buffer one with empty prepare queue.
let entry = RequestEntry::new(make_request(1, 1));
pipeline.push_request(entry).expect("request queue empty");
assert_eq!(pipeline.request_queue_len(), 1);
assert!(!pipeline.request_queue_full());
// Symmetric drain.
let popped = pipeline.pop_request().expect("just-pushed entry");
assert_eq!(popped.message.header().client, 1);
assert_eq!(popped.message.header().request, 1);
assert_eq!(pipeline.request_queue_len(), 0);
}
#[test]
fn push_request_returns_err_when_queue_full() {
let mut pipeline = LocalPipeline::new();
for i in 0..PIPELINE_REQUEST_QUEUE_MAX {
let entry = RequestEntry::new(make_request(i as u128 + 1, 1));
pipeline
.push_request(entry)
.expect("under capacity must succeed");
}
assert!(pipeline.request_queue_full());
// Over capacity: entry returned as Err.
let overflow = RequestEntry::new(make_request(0xFFFF, 1));
let err = pipeline
.push_request(overflow)
.expect_err("over capacity must reject");
assert_eq!(err.message.header().client, 0xFFFF);
}
#[test]
fn has_message_from_client_scans_both_queues() {
let mut pipeline = LocalPipeline::new();
// Push only into request queue.
pipeline
.push_request(RequestEntry::new(make_request(0xCAFE, 1)))
.expect("request queue empty");
// Both queues scanned.
assert!(pipeline.has_message_from_client(0xCAFE));
assert!(!pipeline.has_message_from_client(0xBEEF));
}
// pipeline.clear() must clear both queues, old-view buffered requests
// must not leak into new view.
#[test]
fn clear_drops_both_queues() {
let mut pipeline = LocalPipeline::new();
pipeline
.push_request(RequestEntry::new(make_request(1, 1)))
.unwrap();
pipeline
.push_request(RequestEntry::new(make_request(2, 1)))
.unwrap();
assert_eq!(pipeline.request_queue_len(), 2);
pipeline.clear();
assert!(pipeline.request_queue_is_empty());
assert!(pipeline.is_empty());
}
// View-change *reset* drops request_queue, preserves prepare_queue
// for DVC log reconciliation. Wired in `reset_view_change_state` via
// `cancel_all_subscribers` + `clear_request_queue`.
#[test]
fn clear_request_queue_drops_only_request_queue() {
let mut pipeline = LocalPipeline::new();
// Two requests buffered, one prepare in flight.
pipeline
.push_request(RequestEntry::new(make_request(1, 1)))
.unwrap();
pipeline
.push_request(RequestEntry::new(make_request(2, 1)))
.unwrap();
let prepare_header = PrepareHeader {
op: 7,
..PrepareHeader::default()
};
pipeline.push(PipelineEntry::new(prepare_header));
assert_eq!(pipeline.request_queue_len(), 2);
assert_eq!(pipeline.prepare_count(), 1);
pipeline.clear_request_queue();
assert!(
pipeline.request_queue_is_empty(),
"request queue must be drained at view-change reset"
);
assert_eq!(
pipeline.prepare_count(),
1,
"prepare queue must survive view-change reset for DVC log reconciliation"
);
let head = pipeline
.prepare_head()
.expect("prepare must still be there");
assert_eq!(head.header.op, 7);
}
// is_full() tracks ONLY prepare_queue, splits "backpressure" signal
// from "drop the request" signal.
#[test]
fn is_full_tracks_only_prepare_queue() {
let mut pipeline = LocalPipeline::new();
// Full request queue must not flip is_full.
for i in 0..PIPELINE_REQUEST_QUEUE_MAX {
pipeline
.push_request(RequestEntry::new(make_request(i as u128 + 1, 1)))
.unwrap();
}
assert!(pipeline.request_queue_full());
assert!(
!pipeline.is_full(),
"request queue full does not imply is_full"
);
}
}
#[cfg(test)]
mod pipeline_entry_tests {
//! Pin `PipelineEntry::reply_sender` lifecycle relied on by metadata +
//! partition commit handlers.
//!
//! Contract: commit caller takes sender after slot update, fires reply.
//! Reverting to header-destructure (the original bug) would wake every
//! subscriber `Canceled` even on happy path. Tests pin both halves.
use super::*;
use iggy_binary_protocol::{Command2, ReplyHeader};
use server_common::Message;
fn make_reply(client: u128, request: u64) -> Message<ReplyHeader> {
let header_size = std::mem::size_of::<ReplyHeader>();
let mut msg = Message::<ReplyHeader>::new(header_size);
let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(
&mut msg.as_mut_slice()[..header_size],
)
.expect("zeroed bytes are valid");
*header = ReplyHeader {
command: Command2::Reply,
client,
request,
..ReplyHeader::default()
};
msg
}
/// Happy path: take sender, fire reply.
#[test]
fn with_subscriber_take_and_send_delivers_reply() {
let header = PrepareHeader::default();
let (mut entry, receiver) = PipelineEntry::with_subscriber(header);
let sender = entry
.take_reply_sender()
.expect("with_subscriber entry must hold a sender");
let reply = make_reply(0xCAFE, 7);
sender.send(reply).ok();
let delivered = futures::executor::block_on(receiver)
.expect("receiver must resolve to the reply, not Canceled");
assert_eq!(delivered.header().client, 0xCAFE);
assert_eq!(delivered.header().request, 7);
}
/// Pre-fix bug: dropping entry without firing sender cancels receiver.
/// What `for entry in drained { let header = entry.header; ... }` did.
/// Regression marker for any refactor that loses the explicit fire.
#[test]
fn drop_entry_without_take_yields_canceled() {
let header = PrepareHeader::default();
let (entry, receiver) = PipelineEntry::with_subscriber(header);
// Exactly what the buggy commit path did via destructure-with-`..`.
drop(entry);
let outcome = futures::executor::block_on(receiver);
assert!(
outcome.is_err(),
"dropped sender must wake receiver Canceled (distinguishes \
'consensus reset' from 'reply delivered')"
);
}
/// `take_reply_sender` idempotent: later calls return `None`, no panic.
#[test]
fn take_reply_sender_is_idempotent() {
let header = PrepareHeader::default();
let (mut entry, _receiver) = PipelineEntry::with_subscriber(header);
assert!(entry.take_reply_sender().is_some(), "first take wins");
assert!(
entry.take_reply_sender().is_none(),
"subsequent takes return None"
);
}
/// `new()` (no subscriber) → `take_reply_sender()` returns `None`.
/// Commit handler's `if let Some(_) = ...` relies on this.
#[test]
fn new_entry_has_no_sender() {
let header = PrepareHeader::default();
let mut entry = PipelineEntry::new(header);
assert!(entry.take_reply_sender().is_none());
}
}