This is an automated email from the ASF dual-hosted git repository.
numinnex pushed a commit to branch state_transfer_metadata_clients_table
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to
refs/heads/state_transfer_metadata_clients_table by this push:
new 557d40c26 address review comments
557d40c26 is described below
commit 557d40c26402d452ecaab4b928d2e8f3fe58d582
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Sun Aug 2 08:23:13 2026 +0200
address review comments
---
core/binary_protocol/src/consensus/header.rs | 27 +-
core/configs/src/server_ng_config/cluster.rs | 6 +
core/configs/src/server_ng_config/validators.rs | 13 +
core/consensus/src/client_table.rs | 249 +++++---
core/consensus/src/le_cursor.rs | 115 ++++
core/consensus/src/lib.rs | 5 +-
core/consensus/src/plane_helpers.rs | 2 +-
core/consensus/src/state_manifest.rs | 64 +--
core/integration/src/harness/handle/server.rs | 18 +-
.../tests/cluster/metadata_state_transfer.rs | 21 +-
core/journal/src/lib.rs | 21 +-
core/metadata/src/impls/metadata.rs | 328 +++++++++--
core/metadata/src/stm/mod.rs | 2 +-
core/metadata/src/stm/mux.rs | 18 +
core/metadata/src/stm/snapshot.rs | 46 +-
core/metadata/src/stm/stream.rs | 164 +++++-
core/metadata/src/stm/user.rs | 9 +
core/partitions/src/iggy_partition.rs | 2 +-
core/partitions/src/journal.rs | 10 +
core/partitions/src/log.rs | 24 +
core/sdk/src/quic/quic_client.rs | 7 -
core/server-ng/src/bootstrap.rs | 11 +-
core/server-ng/src/responses.rs | 25 +-
core/shard/src/lib.rs | 639 +++++++++++++--------
core/shard/src/router.rs | 22 +-
core/simulator/src/deps.rs | 10 +
26 files changed, 1353 insertions(+), 505 deletions(-)
diff --git a/core/binary_protocol/src/consensus/header.rs
b/core/binary_protocol/src/consensus/header.rs
index 93dad5d52..52b9fb6ee 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -1364,6 +1364,7 @@ impl ConsensusHeader for RequestStateTransferHeader {
self.size
}
+ #[allow(clippy::cast_possible_truncation)]
fn validate(&self) -> Result<(), ConsensusError> {
if self.command != Command2::RequestStateTransfer {
return Err(ConsensusError::InvalidCommand {
@@ -1371,6 +1372,16 @@ impl ConsensusHeader for RequestStateTransferHeader {
found: self.command,
});
}
+ // Header-only frame, so the size is fully determined. `EvictionHeader`
+ // pins the same way; the generic `Message::try_from` bound makes this
+ // safe either way, but a validate that checks what it can keeps the
+ // surface uniform across frames.
+ if self.size as usize != HEADER_SIZE {
+ return Err(ConsensusError::InvalidSize {
+ expected: HEADER_SIZE as u32,
+ found: self.size,
+ });
+ }
Ok(())
}
}
@@ -1440,7 +1451,12 @@ impl ConsensusHeader for StateTransferTargetHeader {
));
}
// Unavailable is a bare refusal; a manifest body on it would be
- // ambiguous (which offer would the chunks belong to?).
+ // ambiguous (which offer would the chunks belong to?). An
+ // `available == 1` body is left unbounded here on purpose: it carries
+ // the state manifest, whose entry count and per-artifact/total lengths
+ // are bounded where it is decoded (`STATE_MANIFEST_ENTRIES_MAX`, plus
+ // the receiver's artifact caps), and the generic `Message::try_from`
+ // bound already keeps `size` inside the frame.
if self.available == 0 && self.size as usize != HEADER_SIZE {
return Err(ConsensusError::InvalidField(
"unavailable descriptor must be header-only".to_string(),
@@ -1499,6 +1515,7 @@ impl ConsensusHeader for RequestStateChunkHeader {
self.size
}
+ #[allow(clippy::cast_possible_truncation)]
fn validate(&self) -> Result<(), ConsensusError> {
if self.command != Command2::RequestStateChunk {
return Err(ConsensusError::InvalidCommand {
@@ -1511,6 +1528,14 @@ impl ConsensusHeader for RequestStateChunkHeader {
"chunk len must be non-zero".to_string(),
));
}
+ // Header-only frame; the requested `len` describes the REPLY, which
the
+ // serving side clamps against its own chunk size and the bus ceiling.
+ if self.size as usize != HEADER_SIZE {
+ return Err(ConsensusError::InvalidSize {
+ expected: HEADER_SIZE as u32,
+ found: self.size,
+ });
+ }
Ok(())
}
}
diff --git a/core/configs/src/server_ng_config/cluster.rs
b/core/configs/src/server_ng_config/cluster.rs
index 22f750402..a10a5384f 100644
--- a/core/configs/src/server_ng_config/cluster.rs
+++ b/core/configs/src/server_ng_config/cluster.rs
@@ -71,6 +71,12 @@ const MAX_VIEW_PROBE_ATTEMPTS: u32 = 100;
/// static-asserts it equal to `shard::REPAIR_CHUNK_MAX`.
pub const DEFAULT_REPAIR_CHUNK_MAX: usize = 128;
+/// `size_of::<StateChunkHeader>()`. Duplicated here for the same reason as
+/// [`DEFAULT_REPAIR_CHUNK_MAX`]; `core/server-ng`'s bootstrap static-asserts
it
+/// against the real header. Used to reject a `[message_bus] max_message_size`
+/// too small to carry a single state-transfer chunk.
+pub const STATE_CHUNK_HEADER_LEN: u64 = 256;
+
/// Upper bound on `repair_chunk_max`. A chunk rides the per-peer bus queue, so
/// the load-bearing rule is `repair_chunk_max <
message_bus.peer_queue_capacity`
/// (enforced at the top level); this standalone ceiling is a typo guard.
diff --git a/core/configs/src/server_ng_config/validators.rs
b/core/configs/src/server_ng_config/validators.rs
index 7b24d322c..c00d55df8 100644
--- a/core/configs/src/server_ng_config/validators.rs
+++ b/core/configs/src/server_ng_config/validators.rs
@@ -26,6 +26,7 @@
//! net.
use super::COMPONENT_NG;
+use super::cluster::STATE_CHUNK_HEADER_LEN;
use super::server_ng::{ExtraConfig, NamespaceConfig, ServerNgConfig};
use crate::ConfigurationError;
use err_trail::ErrContext;
@@ -202,6 +203,18 @@ impl Validatable<ConfigurationError> for ServerNgConfig {
return Err(ConfigurationError::InvalidConfigurationValue);
}
+ // State-transfer chunks ride the same bus. A cap that cannot carry one
+ // header plus a byte of payload makes every rejoin that needs a
+ // transfer impossible, and the failure surfaces only as a replica
+ // connection tearing down when the frame is rejected on the read side.
+ let bus_cap = self.message_bus.max_message_size.as_bytes_u64();
+ if bus_cap <= STATE_CHUNK_HEADER_LEN {
+ eprintln!(
+ "{COMPONENT_NG} message_bus.max_message_size ({bus_cap}) must
exceed the {STATE_CHUNK_HEADER_LEN}-byte state-chunk header: state transfer
serves artifact chunks over this bus, and a frame above the cap is rejected by
the receiving transport, which tears down the whole replica connection"
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
+
// WS frame chain: websocket.max_frame_size <=
websocket.max_message_size
// <= message_bus.max_message_size. The bus's WS / WSS install path
takes
// its frame tuning from [websocket], so a WS ceiling above the bus's
own
diff --git a/core/consensus/src/client_table.rs
b/core/consensus/src/client_table.rs
index 2360dc10e..20767574d 100644
--- a/core/consensus/src/client_table.rs
+++ b/core/consensus/src/client_table.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+use crate::le_cursor::{LeCursor, Truncated, split_verified_trailer};
use iggy_binary_protocol::consensus::ConsensusError;
use iggy_binary_protocol::{GenericHeader, ReplyHeader};
use serde::{Deserialize, Serialize};
@@ -24,10 +25,8 @@ use server_common::{
};
use std::collections::{HashMap, VecDeque};
use std::fmt;
-use std::hash::Hasher;
use std::mem::size_of;
use tracing::trace;
-use twox_hash::XxHash3_64;
/// Refcounted wrapper around a committed reply.
///
@@ -952,9 +951,15 @@ impl ClientTable {
}
}
-/// Failure decoding an encoded client table (state transfer install path).
+/// Failure decoding the state-transfer WIRE encoding of a client table
+/// ([`ClientTable::encode`] / [`ClientTable::decode`]).
+///
+/// Distinct from [`ClientTableDecodeError`], which covers the msgpack
+/// CHECKPOINT encoding read off local disk. The two formats validate the same
+/// invariants against differently-trusted inputs: a checkpoint is this node's
+/// own bytes, while these arrive from a peer.
#[derive(Debug)]
-pub enum ClientTableCodecError {
+pub enum ClientTableWireError {
/// Byte stream ended mid-field.
Truncated,
/// Leading magic is not [`CLIENT_TABLE_MAGIC`].
@@ -968,9 +973,17 @@ pub enum ClientTableCodecError {
/// An entry carries an empty reply ring (violates the never-empty
/// invariant registration establishes).
EmptyRing,
+ /// Two entries claim the same `client_id`. Indexing them would leave one
+ /// slot occupied but unindexed, which desynchronizes the capacity check in
+ /// [`ClientTable::commit_register`] from the actual occupancy.
+ DuplicateClientId { slot: usize, client_id: u128 },
+ /// A reply ring longer than [`REPLY_RING_CAPACITY`]. `push_latest` only
+ /// evicts on equality, so an over-capacity ring grows without bound, and
+ /// `encode` writes its length as a `u8`.
+ RingTooLong { slot: usize, len: u8, max: usize },
}
-impl std::fmt::Display for ClientTableCodecError {
+impl std::fmt::Display for ClientTableWireError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Truncated => write!(f, "encoded client table truncated"),
@@ -984,15 +997,38 @@ impl std::fmt::Display for ClientTableCodecError {
}
Self::InvalidReply => write!(f, "encoded client table holds an
invalid cached reply"),
Self::EmptyRing => write!(f, "encoded client table entry has an
empty reply ring"),
+ Self::DuplicateClientId { slot, client_id } => write!(
+ f,
+ "encoded client table repeats client {client_id} at entry
{slot}"
+ ),
+ Self::RingTooLong { slot, len, max } => write!(
+ f,
+ "encoded client table entry {slot} has a {len}-reply ring, max
{max}"
+ ),
}
}
}
-impl std::error::Error for ClientTableCodecError {}
+impl std::error::Error for ClientTableWireError {}
+
+impl From<Truncated> for ClientTableWireError {
+ fn from(_: Truncated) -> Self {
+ Self::Truncated
+ }
+}
/// Format tag for [`ClientTable::encode`]; bump on layout change.
pub const CLIENT_TABLE_MAGIC: [u8; 4] = *b"ICT1";
+/// Per-entry fixed fields in the wire encoding: `client(u128) epoch(u64)
+/// user_id(u32) watermark(u64) watermark_checksum(u128) ring_len(u8)`.
+const ENCODED_ENTRY_FIXED_LEN: usize = size_of::<u128>()
+ + size_of::<u64>()
+ + size_of::<u32>()
+ + size_of::<u64>()
+ + size_of::<u128>()
+ + size_of::<u8>();
+
impl ClientTable {
/// Encode the table for state transfer.
///
@@ -1001,12 +1037,33 @@ impl ClientTable {
/// watermark_checksum(u128) ring_len(u8) [reply_len(u32) reply_bytes]*`,
/// terminated by an `XxHash3_64(8)` over everything before it.
///
- /// Slot order makes the bytes deterministic across caught-up replicas:
- /// slots are apply-derived state.
+ /// Slot order makes the bytes deterministic across caught-up replicas
+ /// that reached this state the same way. Not a cross-replica byte
+ /// identity: entries compact into `0..count` here, so a replica that
+ /// installed a transfer re-slots its clients and later registrations land
+ /// elsewhere than on a replica that never did.
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn encode(&self) -> Vec<u8> {
- let mut out = Vec::with_capacity(64 * self.index.len() + 16);
+ // Size exactly rather than guess: each cached reply is a full wire
+ // message, so at the default client cap a guessed reservation is off
by
+ // orders of magnitude and costs several reallocs of a multi-MB buffer
+ // on the serving primary's pump, once per offer build.
+ let entries = self.slots.iter().flatten();
+ let reserved = CLIENT_TABLE_MAGIC.len()
+ + size_of::<u32>()
+ + entries
+ .map(|entry| {
+ ENCODED_ENTRY_FIXED_LEN
+ + entry
+ .ring
+ .iter()
+ .map(|reply| size_of::<u32>() + reply.bytes.len())
+ .sum::<usize>()
+ })
+ .sum::<usize>()
+ + size_of::<u64>();
+ let mut out = Vec::with_capacity(reserved);
out.extend_from_slice(&CLIENT_TABLE_MAGIC);
out.extend_from_slice(&(self.index.len() as u32).to_le_bytes());
for (slot_idx, slot) in self.slots.iter().enumerate() {
@@ -1024,9 +1081,9 @@ impl ClientTable {
out.extend_from_slice(bytes);
}
}
- let mut hasher = XxHash3_64::new();
- hasher.write(&out);
- out.extend_from_slice(&hasher.finish().to_le_bytes());
+ debug_assert_eq!(out.len() + size_of::<u64>(), reserved, "encode
reservation");
+ let trailer = crate::state_manifest::state_artifact_checksum(&out);
+ out.extend_from_slice(&trailer.to_le_bytes());
out
}
@@ -1037,33 +1094,25 @@ impl ClientTable {
/// back, not trusted from the wire, so it cannot drift from the ring.
///
/// # Errors
- /// [`ClientTableCodecError`] on truncation, magic/checksum mismatch,
- /// capacity overflow, or an undecodable cached reply.
+ /// [`ClientTableWireError`] on truncation, magic/checksum mismatch,
+ /// capacity overflow, a duplicate `client_id`, an out-of-range ring
+ /// length, or an undecodable cached reply.
///
/// # Panics
/// Unreachable: slice-to-array conversions are length-checked first.
- pub fn decode(bytes: &[u8], max_clients: usize) -> Result<Self,
ClientTableCodecError> {
- const TRAILER: usize = size_of::<u64>();
- let content_len = bytes
- .len()
- .checked_sub(TRAILER)
- .ok_or(ClientTableCodecError::Truncated)?;
- let (content, trailer) = bytes.split_at(content_len);
- let expected = u64::from_le_bytes(trailer.try_into().expect("trailer
is 8 bytes"));
- let mut hasher = XxHash3_64::new();
- hasher.write(content);
- let actual = hasher.finish();
- if expected != actual {
- return Err(ClientTableCodecError::ChecksumMismatch { expected,
actual });
- }
+ pub fn decode(bytes: &[u8], max_clients: usize) -> Result<Self,
ClientTableWireError> {
+ let content = split_verified_trailer(bytes).map_err(|mismatch| match
mismatch {
+ Some((expected, actual)) => ClientTableWireError::ChecksumMismatch
{ expected, actual },
+ None => ClientTableWireError::Truncated,
+ })?;
- let mut reader = CodecReader { bytes: content };
+ let mut reader = LeCursor::new(content);
if reader.take(CLIENT_TABLE_MAGIC.len())? != CLIENT_TABLE_MAGIC {
- return Err(ClientTableCodecError::BadMagic);
+ return Err(ClientTableWireError::BadMagic);
}
let count = reader.u32()?;
if count as usize > max_clients {
- return Err(ClientTableCodecError::TooManyEntries {
+ return Err(ClientTableWireError::TooManyEntries {
count,
max: max_clients,
});
@@ -1078,7 +1127,19 @@ impl ClientTable {
let watermark_checksum = reader.u128()?;
let ring_len = reader.u8()?;
if ring_len == 0 {
- return Err(ClientTableCodecError::EmptyRing);
+ return Err(ClientTableWireError::EmptyRing);
+ }
+ // The artifact checksum only proves the bytes survived transit; it
+ // says nothing about the peer that computed them. An over-capacity
+ // ring is admitted forever after (`push_latest` evicts only on
+ // equality) and eventually wraps `encode`'s `u8` length, making
+ // this table permanently un-transferable onward.
+ if usize::from(ring_len) > REPLY_RING_CAPACITY {
+ return Err(ClientTableWireError::RingTooLong {
+ slot: slot_idx,
+ len: ring_len,
+ max: REPLY_RING_CAPACITY,
+ });
}
let mut ring = VecDeque::with_capacity(REPLY_RING_CAPACITY);
for _ in 0..ring_len {
@@ -1086,9 +1147,9 @@ impl ClientTable {
let reply_bytes = reader.take(reply_len)?;
let owned =
Owned::<MESSAGE_ALIGN>::copy_from_slice(reply_bytes);
let message = Message::<GenericHeader>::try_from(owned)
- .map_err(|_| ClientTableCodecError::InvalidReply)?
+ .map_err(|_| ClientTableWireError::InvalidReply)?
.try_into_typed::<ReplyHeader>()
- .map_err(|_| ClientTableCodecError::InvalidReply)?;
+ .map_err(|_| ClientTableWireError::InvalidReply)?;
ring.push_back(CachedReply::from_message(message));
}
let latest_commit = ring
@@ -1105,44 +1166,32 @@ impl ClientTable {
client_id,
latest_commit,
});
- table.index.insert(client_id, slot_idx);
+ // Reject rather than overwrite, as the checkpoint decoder does. An
+ // overwrite leaves the displaced slot occupied but unindexed, and
+ // `commit_register` sizes its eviction check off `index.len()`: a
+ // full table would then skip eviction, find no free slot, and
+ // panic the shard.
+ if let Some(first_slot) = table.index.insert(client_id, slot_idx) {
+ return Err(ClientTableWireError::DuplicateClientId {
+ slot: first_slot,
+ client_id,
+ });
+ }
}
- if !reader.bytes.is_empty() {
- return Err(ClientTableCodecError::Truncated);
+ if !reader.remaining().is_empty() {
+ return Err(ClientTableWireError::Truncated);
}
Ok(table)
}
-}
-/// Little-endian cursor over the encoded table content.
-struct CodecReader<'a> {
- bytes: &'a [u8],
-}
-
-impl<'a> CodecReader<'a> {
- const fn take(&mut self, len: usize) -> Result<&'a [u8],
ClientTableCodecError> {
- if self.bytes.len() < len {
- return Err(ClientTableCodecError::Truncated);
- }
- let (head, tail) = self.bytes.split_at(len);
- self.bytes = tail;
- Ok(head)
- }
-
- fn u8(&mut self) -> Result<u8, ClientTableCodecError> {
- Ok(self.take(1)?[0])
- }
-
- fn u32(&mut self) -> Result<u32, ClientTableCodecError> {
- Ok(u32::from_le_bytes(self.take(4)?.try_into().expect("4B")))
- }
-
- fn u64(&mut self) -> Result<u64, ClientTableCodecError> {
- Ok(u64::from_le_bytes(self.take(8)?.try_into().expect("8B")))
- }
-
- fn u128(&mut self) -> Result<u128, ClientTableCodecError> {
- Ok(u128::from_le_bytes(self.take(16)?.try_into().expect("16B")))
+ /// Slot capacity, i.e. the largest table this one can absorb from a peer.
+ ///
+ /// Sized at construction from the configured cap, then raised by
+ /// [`Self::from_snapshot`] to cover any slot the checkpoint holds, so this
+ /// can exceed `[metadata] clients_table_max`.
+ #[must_use]
+ pub const fn capacity(&self) -> usize {
+ self.slots.len()
}
}
@@ -1984,19 +2033,19 @@ mod tests {
flipped[8] ^= 0xFF;
assert!(matches!(
ClientTable::decode(&flipped, 4),
- Err(ClientTableCodecError::ChecksumMismatch { .. })
+ Err(ClientTableWireError::ChecksumMismatch { .. })
));
// Truncation.
assert!(matches!(
ClientTable::decode(&encoded[..encoded.len() - 1], 4),
- Err(ClientTableCodecError::ChecksumMismatch { .. } |
ClientTableCodecError::Truncated)
+ Err(ClientTableWireError::ChecksumMismatch { .. } |
ClientTableWireError::Truncated)
));
// Capacity overflow.
assert!(matches!(
ClientTable::decode(&encoded, 0),
- Err(ClientTableCodecError::TooManyEntries { .. })
+ Err(ClientTableWireError::TooManyEntries { .. })
));
let empty = ClientTable::new(4).encode();
@@ -2007,4 +2056,64 @@ mod tests {
0
);
}
+
+ /// Re-stamp a hand-edited body so it passes the trailer check and the
+ /// per-field validations are what the decode actually exercises.
+ fn reseal(mut content: Vec<u8>) -> Vec<u8> {
+ let trailer = crate::state_manifest::state_artifact_checksum(&content);
+ content.extend_from_slice(&trailer.to_le_bytes());
+ content
+ }
+
+ // A duplicate client_id would collapse the index onto one slot, leaving
the
+ // other occupied but unindexed. `commit_register` sizes its eviction check
+ // off `index.len()`, so a full table would then skip eviction, find no
free
+ // slot, and panic the shard.
+ #[test]
+ fn decode_rejects_a_duplicate_client_id() {
+ let mut table = ClientTable::new(2);
+ table.commit_register(7, TEST_USER_ID, make_register_reply(7, 10));
+ table.commit_register(9, TEST_USER_ID, make_register_reply(9, 20));
+ let encoded = table.encode();
+
+ // Rewrite the second entry's client_id to match the first. Entries are
+ // fixed-width up to their ring, and both rings hold one register reply
+ // of equal length, so the second entry starts at a computable offset.
+ let content = &encoded[..encoded.len() - size_of::<u64>()];
+ let header_len = CLIENT_TABLE_MAGIC.len() + size_of::<u32>();
+ let reply_len = (content.len() - header_len - 2 *
ENCODED_ENTRY_FIXED_LEN) / 2;
+ let second = header_len + ENCODED_ENTRY_FIXED_LEN + reply_len;
+ let mut duped = content.to_vec();
+ duped[second..second +
size_of::<u128>()].copy_from_slice(&7u128.to_le_bytes());
+
+ assert!(matches!(
+ ClientTable::decode(&reseal(duped), 2),
+ Err(ClientTableWireError::DuplicateClientId { client_id: 7, .. })
+ ));
+ }
+
+ // `push_latest` evicts only on equality, so an over-capacity ring is
+ // admitted permanently and grows on every later reply until `encode`'s u8
+ // length wraps and the table stops being transferable at all.
+ #[test]
+ fn decode_rejects_a_ring_longer_than_capacity() {
+ let mut table = ClientTable::new(1);
+ table.commit_register(3, TEST_USER_ID, make_register_reply(3, 10));
+ let encoded = table.encode();
+
+ let content = &encoded[..encoded.len() - size_of::<u64>()];
+ let mut oversized = content.to_vec();
+ // ring_len is the last fixed field of the entry.
+ let ring_len_at = CLIENT_TABLE_MAGIC.len() + size_of::<u32>() +
ENCODED_ENTRY_FIXED_LEN - 1;
+ assert_eq!(oversized[ring_len_at], 1, "entry's ring holds one reply");
+ #[allow(clippy::cast_possible_truncation)]
+ {
+ oversized[ring_len_at] = REPLY_RING_CAPACITY as u8 + 1;
+ }
+
+ assert!(matches!(
+ ClientTable::decode(&reseal(oversized), 1),
+ Err(ClientTableWireError::RingTooLong { .. })
+ ));
+ }
}
diff --git a/core/consensus/src/le_cursor.rs b/core/consensus/src/le_cursor.rs
new file mode 100644
index 000000000..5b6b36556
--- /dev/null
+++ b/core/consensus/src/le_cursor.rs
@@ -0,0 +1,115 @@
+// 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.
+
+//! Little-endian read cursor shared by the crate's hand-rolled wire codecs
+//! (the state-transfer manifest and the client-table wire encoding).
+//!
+//! Each codec has its own error enum, so the cursor reports the one failure it
+//! can produce ([`Truncated`]) and every codec converts it through `From`.
+//!
+//! [`Truncated`]: Truncated
+
+/// The cursor ran past the end of its slice.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Truncated;
+
+/// Little-endian cursor over an encoded body.
+pub struct LeCursor<'a> {
+ bytes: &'a [u8],
+}
+
+impl<'a> LeCursor<'a> {
+ #[must_use]
+ pub const fn new(bytes: &'a [u8]) -> Self {
+ Self { bytes }
+ }
+
+ /// Bytes not yet consumed. A codec that requires its input fully consumed
+ /// checks this after its last field.
+ #[must_use]
+ pub const fn remaining(&self) -> &'a [u8] {
+ self.bytes
+ }
+
+ /// # Errors
+ /// [`Truncated`] if fewer than `len` bytes remain.
+ pub const fn take(&mut self, len: usize) -> Result<&'a [u8], Truncated> {
+ if self.bytes.len() < len {
+ return Err(Truncated);
+ }
+ let (head, tail) = self.bytes.split_at(len);
+ self.bytes = tail;
+ Ok(head)
+ }
+
+ /// # Errors
+ /// [`Truncated`] if the slice is exhausted.
+ pub fn u8(&mut self) -> Result<u8, Truncated> {
+ Ok(self.take(1)?[0])
+ }
+
+ /// # Errors
+ /// [`Truncated`] if fewer than 4 bytes remain.
+ ///
+ /// # Panics
+ /// Unreachable: the slice is length-checked before the array conversion.
+ pub fn u32(&mut self) -> Result<u32, Truncated> {
+ Ok(u32::from_le_bytes(self.take(4)?.try_into().expect("4B")))
+ }
+
+ /// # Errors
+ /// [`Truncated`] if fewer than 8 bytes remain.
+ ///
+ /// # Panics
+ /// Unreachable: the slice is length-checked before the array conversion.
+ pub fn u64(&mut self) -> Result<u64, Truncated> {
+ Ok(u64::from_le_bytes(self.take(8)?.try_into().expect("8B")))
+ }
+
+ /// # Errors
+ /// [`Truncated`] if fewer than 16 bytes remain.
+ ///
+ /// # Panics
+ /// Unreachable: the slice is length-checked before the array conversion.
+ pub fn u128(&mut self) -> Result<u128, Truncated> {
+ Ok(u128::from_le_bytes(self.take(16)?.try_into().expect("16B")))
+ }
+}
+
+/// Split a trailing `XxHash3_64` stamp off an encoded body and verify it.
+///
+/// Both codecs terminate with the same 8-byte trailer over everything before
+/// it. Returns the content with the trailer removed.
+///
+/// # Errors
+/// `Err(None)` when the input is too short to hold a trailer,
`Err(Some((expected,
+/// actual)))` when the stamp does not match; the caller maps both into its own
+/// error enum.
+///
+/// # Panics
+/// Unreachable: the split point is derived from the trailer's own size.
+pub fn split_verified_trailer(bytes: &[u8]) -> Result<&[u8], Option<(u64,
u64)>> {
+ let content_len = bytes.len().checked_sub(size_of::<u64>()).ok_or(None)?;
+ let (content, trailer) = bytes.split_at(content_len);
+ let expected = u64::from_le_bytes(trailer.try_into().expect("trailer is 8
bytes"));
+ let actual = crate::state_manifest::state_artifact_checksum(content);
+ if expected == actual {
+ Ok(content)
+ } else {
+ Err(Some((expected, actual)))
+ }
+}
diff --git a/core/consensus/src/lib.rs b/core/consensus/src/lib.rs
index f4c38bce8..7b5457a04 100644
--- a/core/consensus/src/lib.rs
+++ b/core/consensus/src/lib.rs
@@ -148,9 +148,10 @@ where
}
pub mod client_table;
+pub mod le_cursor;
pub use client_table::{
- CachedReply, ClientEntrySnapshot, ClientTable, ClientTableCodecError,
ClientTableDecodeError,
- ClientTableSnapshot, CommitReply,
+ CachedReply, ClientEntrySnapshot, ClientTable, ClientTableDecodeError,
ClientTableSnapshot,
+ ClientTableWireError, CommitReply,
};
pub mod state_manifest;
pub use state_manifest::{
diff --git a/core/consensus/src/plane_helpers.rs
b/core/consensus/src/plane_helpers.rs
index e252afd46..4515f2ba7 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -42,7 +42,7 @@ pub async fn pipeline_prepare_common<C, F>(
assert!(consensus.is_normal(), "on_request: status must be normal");
assert!(
!consensus.is_transferring(),
- "on_request: must not be syncing"
+ "on_request: must not be transferring state"
);
consensus.verify_pipeline();
diff --git a/core/consensus/src/state_manifest.rs
b/core/consensus/src/state_manifest.rs
index ea226b270..2841c6c5e 100644
--- a/core/consensus/src/state_manifest.rs
+++ b/core/consensus/src/state_manifest.rs
@@ -35,6 +35,7 @@
//!
//! [`kind`]: StateArtifact::kind
+use crate::le_cursor::{LeCursor, Truncated, split_verified_trailer};
use std::hash::Hasher;
use twox_hash::XxHash3_64;
@@ -128,6 +129,12 @@ impl std::fmt::Display for StateManifestError {
impl std::error::Error for StateManifestError {}
+impl From<Truncated> for StateManifestError {
+ fn from(_: Truncated) -> Self {
+ Self::Truncated
+ }
+}
+
/// Format tag for [`encode_state_manifest`]; bump on incompatible change
/// (appending entry fields is compatible, see `entry_len`).
pub const STATE_MANIFEST_MAGIC: [u8; 4] = *b"ISM1";
@@ -167,9 +174,8 @@ pub fn encode_state_manifest(artifacts: &[StateArtifact])
-> Vec<u8> {
out.extend_from_slice(&artifact.len.to_le_bytes());
out.extend_from_slice(&artifact.checksum.to_le_bytes());
}
- let mut hasher = XxHash3_64::new();
- hasher.write(&out);
- out.extend_from_slice(&hasher.finish().to_le_bytes());
+ let trailer = state_artifact_checksum(&out);
+ out.extend_from_slice(&trailer.to_le_bytes());
out
}
@@ -186,21 +192,12 @@ pub fn encode_state_manifest(artifacts: &[StateArtifact])
-> Vec<u8> {
/// # Panics
/// Unreachable: slice-to-array conversions are length-checked first.
pub fn decode_state_manifest(bytes: &[u8]) -> Result<Vec<StateArtifact>,
StateManifestError> {
- const TRAILER: usize = size_of::<u64>();
- let content_len = bytes
- .len()
- .checked_sub(TRAILER)
- .ok_or(StateManifestError::Truncated)?;
- let (content, trailer) = bytes.split_at(content_len);
- let expected = u64::from_le_bytes(trailer.try_into().expect("trailer is 8
bytes"));
- let mut hasher = XxHash3_64::new();
- hasher.write(content);
- let actual = hasher.finish();
- if expected != actual {
- return Err(StateManifestError::ChecksumMismatch { expected, actual });
- }
+ let content = split_verified_trailer(bytes).map_err(|mismatch| match
mismatch {
+ Some((expected, actual)) => StateManifestError::ChecksumMismatch {
expected, actual },
+ None => StateManifestError::Truncated,
+ })?;
- let mut reader = ManifestReader { bytes: content };
+ let mut reader = LeCursor::new(content);
if reader.take(STATE_MANIFEST_MAGIC.len())? != STATE_MANIFEST_MAGIC {
return Err(StateManifestError::BadMagic);
}
@@ -215,8 +212,7 @@ pub fn decode_state_manifest(bytes: &[u8]) ->
Result<Vec<StateArtifact>, StateMa
let mut artifacts = Vec::with_capacity(count as usize);
for _ in 0..count {
- let entry = reader.take(entry_len as usize)?;
- let mut entry = ManifestReader { bytes: entry };
+ let mut entry = LeCursor::new(reader.take(entry_len as usize)?);
artifacts.push(StateArtifact {
kind: entry.u8()?,
frontier: entry.u64()?,
@@ -224,40 +220,12 @@ pub fn decode_state_manifest(bytes: &[u8]) ->
Result<Vec<StateArtifact>, StateMa
checksum: entry.u64()?,
});
}
- if !reader.bytes.is_empty() {
+ if !reader.remaining().is_empty() {
return Err(StateManifestError::Truncated);
}
Ok(artifacts)
}
-/// Little-endian cursor over the encoded manifest content.
-struct ManifestReader<'a> {
- bytes: &'a [u8],
-}
-
-impl<'a> ManifestReader<'a> {
- const fn take(&mut self, len: usize) -> Result<&'a [u8],
StateManifestError> {
- if self.bytes.len() < len {
- return Err(StateManifestError::Truncated);
- }
- let (head, tail) = self.bytes.split_at(len);
- self.bytes = tail;
- Ok(head)
- }
-
- fn u8(&mut self) -> Result<u8, StateManifestError> {
- Ok(self.take(1)?[0])
- }
-
- fn u32(&mut self) -> Result<u32, StateManifestError> {
- Ok(u32::from_le_bytes(self.take(4)?.try_into().expect("4B")))
- }
-
- fn u64(&mut self) -> Result<u64, StateManifestError> {
- Ok(u64::from_le_bytes(self.take(8)?.try_into().expect("8B")))
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/core/integration/src/harness/handle/server.rs
b/core/integration/src/harness/handle/server.rs
index 7d6fefbc1..34c549807 100644
--- a/core/integration/src/harness/handle/server.rs
+++ b/core/integration/src/harness/handle/server.rs
@@ -211,6 +211,21 @@ impl ServerHandle {
.map_or(0, |log| strip_ansi(&log).matches(marker).count())
}
+ /// This node's stdout log with ANSI escapes stripped, the same text
+ /// [`Self::stdout_occurrences`] matches against. Empty when the log is
+ /// missing or unreadable.
+ ///
+ /// For callers that need to PARSE a marker's fields (`checkpoint_op=193`
+ /// reaches the file as `checkpoint_op\x1b[0m\x1b[2m=\x1b[0m193`) rather
+ /// than just count occurrences of it.
+ #[must_use]
+ pub fn stdout_plain(&self) -> String {
+ self.stdout_path
+ .as_ref()
+ .and_then(|path| fs::read_to_string(path).ok())
+ .map_or_else(String::new, |log| strip_ansi(&log))
+ }
+
/// Returns a `ClientBuilder` using the test transport.
///
/// Returns an error if no test transport is configured.
@@ -1010,8 +1025,6 @@ impl ServerHandle {
/// the committed prefix the node missed no longer exists as WAL entries on
/// the peers that checkpointed it.
pub fn restart_from_clean_slate(&mut self) -> Result<(), TestBinaryError> {
- let cleanup = self.config.cleanup;
- self.config.cleanup = false;
self.stop_dependents()?;
self.stop()?;
@@ -1023,7 +1036,6 @@ impl ServerHandle {
})?;
}
- self.config.cleanup = cleanup;
self.start()
}
}
diff --git a/core/integration/tests/cluster/metadata_state_transfer.rs
b/core/integration/tests/cluster/metadata_state_transfer.rs
index 27eeafaec..b66bfacf7 100644
--- a/core/integration/tests/cluster/metadata_state_transfer.rs
+++ b/core/integration/tests/cluster/metadata_state_transfer.rs
@@ -193,23 +193,10 @@ async fn
given_checkpointed_cluster_when_fresh_node_joins_late_should_state_tran
async fn assert_pairing_matches_install(harness: &TestHarness, node: usize) {
const PAIRING_MARKER: &str = "state transfer recorded its checkpoint
pairing";
- // The server colors its tracing fields, so `checkpoint_op=193` reaches
the log
- // as `checkpoint_op\x1b[0m\x1b[2m=\x1b[0m193`. Strip escapes before
matching,
- // the same reason `ServerHandle::stdout_occurrences` does.
- let (stdout, _stderr) = harness.node(node).collect_logs();
- let mut plain = String::with_capacity(stdout.len());
- let mut chars = stdout.chars();
- while let Some(c) = chars.next() {
- if c == '\u{1b}' {
- for escaped in chars.by_ref() {
- if escaped.is_ascii_alphabetic() {
- break;
- }
- }
- } else {
- plain.push(c);
- }
- }
+ // Escapes stripped by the harness: the server colors its tracing fields,
so
+ // `checkpoint_op=193` reaches the log as
+ // `checkpoint_op\x1b[0m\x1b[2m=\x1b[0m193`.
+ let plain = harness.node(node).stdout_plain();
let recorded_op: u64 = plain
.lines()
.filter(|line| line.contains(PAIRING_MARKER))
diff --git a/core/journal/src/lib.rs b/core/journal/src/lib.rs
index a66a515fc..1159573c3 100644
--- a/core/journal/src/lib.rs
+++ b/core/journal/src/lib.rs
@@ -63,14 +63,19 @@ where
/// Snapshot watermark: entries at or below it are evictable. `0` for
/// journals without snapshot bookkeeping.
- fn snapshot_op(&self) -> u64 {
- 0
- }
-
- /// Advance the snapshot watermark (see [`Self::snapshot_op`]). No-op for
- /// journals without snapshot bookkeeping. State transfer uses this to
- /// mark pre-transfer residents superseded by the installed snapshot.
- fn set_snapshot_op(&self, _op: u64) {}
+ ///
+ /// Required rather than defaulted, and paired with
+ /// [`Self::set_snapshot_op`]: a wrapper that forwards one while inheriting
+ /// the other is silently broken in one direction and panics in the other
+ /// (an implementation whose setter asserts monotonicity would see a getter
+ /// stuck at `0` hand it a watermark below the real one). Journals without
+ /// snapshot bookkeeping answer `0` and no-op the setter EXPLICITLY.
+ fn snapshot_op(&self) -> u64;
+
+ /// Advance the snapshot watermark (see [`Self::snapshot_op`]). State
+ /// transfer uses this to mark pre-transfer residents superseded by the
+ /// installed snapshot.
+ fn set_snapshot_op(&self, op: u64);
}
// TODO: Move to other crate.
diff --git a/core/metadata/src/impls/metadata.rs
b/core/metadata/src/impls/metadata.rs
index 7f616fd61..fa3d791c0 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -809,6 +809,11 @@ pub struct IggyMetadata<C, J, S, M> {
/// them (re-running `commit_register` would double-bump epochs). `0`
/// outside state transfer (no op is skipped). Monotone per install.
client_table_frontier: Cell<u64>,
+ /// Last built [`StateTransferOffer`], shared by every requester of the
same
+ /// snapshot generation. Rebuilding per request re-reads and re-decodes the
+ /// whole snapshot on shard 0's pump, and hands each requester its own
+ /// multi-MB copy.
+ transfer_offer_cache: RefCell<Option<Rc<StateTransferOffer>>>,
}
impl<C, J, S, M> IggyMetadata<C, J, S, M>
@@ -849,11 +854,34 @@ where
default_max_topic_size: Cell::new(u64::MAX),
default_message_expiry: Cell::new(u64::MAX),
client_table_frontier: Cell::new(0),
+ transfer_offer_cache: RefCell::new(None),
}
}
}
impl<C, J, S, M> IggyMetadata<C, J, S, M> {
+ /// Slot capacity of the LIVE client table, i.e. the largest transferred
+ /// table this replica can absorb.
+ ///
+ /// Read at decode instead of a separately plumbed `clients_table_max`: the
+ /// live table sizes itself to `max(configured, highest recovered slot +
1)`,
+ /// so a serving primary can legitimately hold more entries than this
node's
+ /// raw config value and decoding against that value would reject every
+ /// round.
+ #[must_use]
+ pub fn client_table_capacity(&self) -> usize {
+ self.client_table.borrow().capacity()
+ }
+
+ /// Drop the cached state-transfer offer, releasing its snapshot copy.
+ ///
+ /// Called by the shard's expiry sweep once no requester holds an offer:
+ /// the cache exists to collapse repeat builds within one rejoin, not to
+ /// pin a snapshot for the life of the process.
+ pub fn clear_state_transfer_offer_cache(&self) {
+ self.transfer_offer_cache.borrow_mut().take();
+ }
+
/// Install (or replace) the post-commit notifier. Passing `None`
/// removes any previous one. Server-ng bootstrap calls this on shard 0
/// only; peer shards never commit metadata locally.
@@ -1338,20 +1366,125 @@ where
/// One state-transfer serving payload.
///
-/// The on-disk snapshot bytes plus the live client table, both
+/// The on-disk snapshot payload plus the live client table, both
/// frontier-stamped. Built by [`IggyMetadata::state_transfer_offer`] on the
-/// serving primary; the shard caches it per requester and serves chunks
-/// from it.
+/// serving primary; the shard serves chunks out of it and shares one instance
+/// across every requester of the same snapshot generation.
pub struct StateTransferOffer {
/// Serving primary's applied frontier when the offer was built; the
/// receiver's tail repair targets past this.
pub commit_op: u64,
- /// Manifest entries, index-aligned with `payloads`. Metadata plane:
- /// `[METADATA_SNAPSHOT (frontier = sequence_number),
- /// CLIENT_TABLE (frontier = commit_min at encode)]`.
- pub artifacts: Vec<consensus::StateArtifact>,
- /// Artifact bytes, chunk-served by `(manifest index, offset)`.
- pub payloads: Vec<Vec<u8>>,
+ /// The offered snapshot's `sequence_number`, i.e. the generation this
+ /// offer describes. Reused as the cache key: a later checkpoint rewrites
+ /// `snapshot.bin` and invalidates every payload below.
+ pub snapshot_seq: u64,
+ /// Manifest entries paired with their bytes. One `Vec` of pairs rather
+ /// than two index-aligned `Vec`s: the manifest is encoded in one file and
+ /// the chunks served in another, so a desync would be invisible at both
+ /// ends. Metadata plane: `[METADATA_SNAPSHOT (frontier = sequence_number),
+ /// CLIENT_TABLE (frontier = commit_min at encode)]`.
+ ///
+ /// Payloads are refcounted so n simultaneous rejoiners share one copy
+ /// rather than pinning n multi-MB snapshots on shard 0.
+ pub artifacts: Vec<(consensus::StateArtifact, Rc<Vec<u8>>)>,
+}
+
+impl StateTransferOffer {
+ /// Manifest entries for the descriptor body.
+ #[must_use]
+ pub fn manifest(&self) -> Vec<consensus::StateArtifact> {
+ self.artifacts.iter().map(|(entry, _)| *entry).collect()
+ }
+
+ /// Bytes of the artifact at `index` in manifest order.
+ #[must_use]
+ pub fn payload(&self, index: usize) -> Option<&[u8]> {
+ self.artifacts.get(index).map(|(_, bytes)| bytes.as_slice())
+ }
+
+ /// Number of artifacts on offer.
+ #[must_use]
+ pub const fn len(&self) -> usize {
+ self.artifacts.len()
+ }
+
+ /// Whether the offer carries no artifacts at all.
+ #[must_use]
+ pub const fn is_empty(&self) -> bool {
+ self.artifacts.is_empty()
+ }
+
+ /// Total advertised bytes across every artifact.
+ #[must_use]
+ pub fn total_len(&self) -> u64 {
+ self.artifacts.iter().map(|(entry, _)| entry.len).sum()
+ }
+}
+
+/// Why this replica cannot serve a state transfer right now.
+///
+/// Named rather than folded into `None` so the refusal the requester sees is
+/// logged with its actual cause: "no snapshot persisted" and "snapshot.bin is
+/// corrupt" call for opposite operator responses.
+#[derive(Debug)]
+pub enum StateTransferUnavailable {
+ /// Not a caught-up primary, so a client-table read would not be
+ /// authoritative.
+ NotCaughtUpPrimary,
+ /// This shard has no snapshot coordinator, so it never checkpoints.
+ NoCoordinator,
+ /// No snapshot has ever been persisted. The WAL still holds the full
+ /// history, so the requester's journal repair covers its whole gap.
+ NoSnapshot,
+ /// `snapshot.bin` exists but could not be read, or failed its integrity
+ /// trailer. Refusing is strictly better than shipping it: the receiver
+ /// would re-seal the corruption under a fresh valid trailer.
+ SnapshotUnreadable(SnapshotError),
+}
+
+impl std::fmt::Display for StateTransferUnavailable {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::NotCaughtUpPrimary => write!(f, "not a caught-up primary"),
+ Self::NoCoordinator => write!(f, "no snapshot coordinator on this
shard"),
+ Self::NoSnapshot => write!(f, "no snapshot has been persisted
yet"),
+ Self::SnapshotUnreadable(source) => {
+ write!(f, "persisted snapshot is unreadable: {source}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for StateTransferUnavailable {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::SnapshotUnreadable(source) => Some(source),
+ _ => None,
+ }
+ }
+}
+
+/// What a completed [`IggyMetadata::install_state_transfer`] landed.
+///
+/// A degraded install is reported HERE rather than as an `Err`, because it is
+/// a success: the snapshot, table, frontiers and commit point are all in
+/// place by the time the pairing write is attempted. Returning it as an error
+/// invites a caller to treat a completed install as a failure and redo it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct InstallOutcome {
+ /// The receiver's new applied frontier, `max(snapshot_seq,
+ /// local_applied)`. These differ whenever a serving peer offered a
+ /// snapshot BEHIND this replica and the local state machine was kept.
+ pub applied_frontier: u64,
+ /// Whether the transferred checkpoint's `(checkpoint_op, checksum)`
+ /// pairing reached the durable superblock.
+ ///
+ /// `false` leaves the install fully usable: the coordinator already holds
+ /// the new pairing, so the next superblock write (view change or
+ /// checkpoint) records it. Until then a crash recovers the PREVIOUS
+ /// checkpoint and this replica transfers again -- correct, just wasted
+ /// work.
+ pub pairing_durable: bool,
}
impl<B, J, S, M> IggyMetadata<VsrConsensus<B>, J, S, M>
@@ -1366,45 +1499,96 @@ where
Error = iggy_common::IggyError,
>,
{
- /// Build a state-transfer offer for a restarted peer, or `None` when
- /// this replica cannot serve one right now: not a caught-up primary
- /// (table reads would not be authoritative), or no snapshot has ever
- /// been persisted (the WAL then still holds the full history and the
- /// requester's journal repair covers the whole gap).
+ /// Build a state-transfer offer for a restarted peer.
///
- /// The snapshot is served as the on-disk bytes verbatim -- possibly
- /// stale, which costs nothing: the receiver journal-repairs
- /// `(snapshot_seq, commit_max]` afterwards through the existing repair
- /// machinery. The table is encoded live at this instant; both frontier
- /// stamps read `commit_min` inside one synchronous region, so they are
- /// mutually consistent.
- #[must_use]
- pub fn state_transfer_offer(&self) -> Option<StateTransferOffer> {
- let consensus = self.consensus.as_ref()?;
+ /// The snapshot is served as the on-disk PAYLOAD, with its integrity
+ /// trailer verified and stripped. Both halves matter. Verified, because a
+ /// flipped bit inside the payload that still msgpack-decodes would
+ /// otherwise be re-sealed on the receiver under a fresh valid trailer and
+ /// a matching pairing: a fault the source node refuses to boot over would
+ /// become undetectable on the second node. Stripped, because the receiver
+ /// re-persists what it is sent through `write_durably`, which appends a
+ /// trailer of its own -- shipping the sealed file grows `snapshot.bin` by
+ /// one trailer per transfer generation and leaves it byte-shape-different
+ /// from a locally checkpointed one.
+ ///
+ /// The snapshot may be stale, which costs nothing: the receiver
+ /// journal-repairs `(snapshot_seq, commit_max]` afterwards through the
+ /// existing repair machinery. The table is encoded live at this instant;
+ /// both frontier stamps read `commit_min` inside one synchronous region,
+ /// so they are mutually consistent.
+ ///
+ /// The result is cached and shared: a repeat request for the same snapshot
+ /// generation reuses it instead of re-reading and re-decoding the file on
+ /// shard 0's pump.
+ ///
+ /// # Errors
+ /// [`StateTransferUnavailable`] naming why this replica cannot serve.
+ pub fn state_transfer_offer(&self) -> Result<Rc<StateTransferOffer>,
StateTransferUnavailable> {
+ let consensus = self
+ .consensus
+ .as_ref()
+ .ok_or(StateTransferUnavailable::NoCoordinator)?;
if !is_caught_up_primary(consensus) {
- return None;
+ return Err(StateTransferUnavailable::NotCaughtUpPrimary);
}
- let coordinator = self.coordinator.as_ref()?;
- let snapshot = std::fs::read(coordinator.snapshot_path()).ok()?;
- let snapshot_seq =
IggySnapshot::decode(&snapshot).ok()?.sequence_number();
+ let coordinator = self
+ .coordinator
+ .as_ref()
+ .ok_or(StateTransferUnavailable::NoCoordinator)?;
+ let path = coordinator.snapshot_path();
+ if !path.exists() {
+ return Err(StateTransferUnavailable::NoSnapshot);
+ }
+ let sealed = std::fs::read(&path)
+ .map_err(|source|
StateTransferUnavailable::SnapshotUnreadable(source.into()))?;
+ // Verifies the trailer and hands back the payload alone.
+ let (payload, _) =
+ split_trailer(&sealed,
&path).map_err(StateTransferUnavailable::SnapshotUnreadable)?;
+ // Still decoded rather than read off `last_checkpoint()`:
`write_durably`
+ // renames before the parent-dir fsync, so a DirSync failure leaves
the new
+ // file live with that cell stale, and the offer would then
under-advertise
+ // the frontier it is actually shipping.
+ let snapshot_seq = IggySnapshot::decode(payload)
+ .map_err(StateTransferUnavailable::SnapshotUnreadable)?
+ .sequence_number();
+
+ // Reuse the cached offer for this generation. Only the SNAPSHOT half
is
+ // expensive to rebuild, and the cached table is merely older, never
+ // incoherent: its frontier is stamped at its own encode, and the
receiver
+ // replays everything above that frontier during tail repair.
+ if let Some(cached) = self.transfer_offer_cache.borrow().as_ref()
+ && cached.snapshot_seq == snapshot_seq
+ {
+ return Ok(Rc::clone(cached));
+ }
+
let commit_op = consensus.commit_min();
let table = self.client_table.borrow().encode();
- Some(StateTransferOffer {
+ let offer = Rc::new(StateTransferOffer {
commit_op,
+ snapshot_seq,
artifacts: vec![
- consensus::StateArtifact::for_bytes(
- consensus::artifact_kind::METADATA_SNAPSHOT,
- snapshot_seq,
- &snapshot,
+ (
+ consensus::StateArtifact::for_bytes(
+ consensus::artifact_kind::METADATA_SNAPSHOT,
+ snapshot_seq,
+ payload,
+ ),
+ Rc::new(payload.to_vec()),
),
- consensus::StateArtifact::for_bytes(
- consensus::artifact_kind::CLIENT_TABLE,
- commit_op,
- &table,
+ (
+ consensus::StateArtifact::for_bytes(
+ consensus::artifact_kind::CLIENT_TABLE,
+ commit_op,
+ &table,
+ ),
+ Rc::new(table),
),
],
- payloads: vec![snapshot, table],
- })
+ });
+ *self.transfer_offer_cache.borrow_mut() = Some(Rc::clone(&offer));
+ Ok(offer)
}
/// Install a fetched state transfer: persist + restore the snapshot,
@@ -1424,12 +1608,13 @@ where
/// reconciler's periodic full diff against the committed STM, which
/// reads the restored state on its next tick.
///
- /// Returns the installed snapshot sequence (the receiver's new applied
- /// frontier).
+ /// Returns an [`InstallOutcome`]: the new applied frontier, plus whether
+ /// the transferred checkpoint's pairing reached the durable superblock.
///
/// # Errors
/// [`SnapshotError`] when the snapshot bytes do not decode, the persist
- /// fails, or the in-place restore is rejected.
+ /// fails, or the in-place restore is rejected. Every `Err` here means
+ /// NOTHING was installed.
///
/// # Panics
/// If called on a shard without consensus (state transfer is a shard-0
@@ -1441,7 +1626,7 @@ where
client_table: ClientTable,
table_frontier: u64,
commit_op: u64,
- ) -> Result<u64, SnapshotError>
+ ) -> Result<InstallOutcome, SnapshotError>
where
M: RestoreSnapshotInPlace<MetadataSnapshot>,
{
@@ -1489,21 +1674,34 @@ where
let local_applied = consensus.commit_min();
let snapshot_ahead = snapshot_seq > local_applied;
- // Hold the superblock gate across the ENTIRE install, not just its
- // superblock write. A checkpoint does the same pair of steps --
rewrite
- // `snapshot.bin`, record `(checkpoint_op, checksum)` -- so
interleaving
- // the two could leave the file from one and the durable pairing from
the
- // other, which is precisely the torn pairing the superblock exists to
- // detect. Serializing against `checkpoint_if_needed` makes that
- // impossible by construction rather than by argument (a transferring
- // replica withholds acks, so it should not be committing, but "should"
- // is not an invariant this path can rely on).
+ // Serialize the whole install against a concurrent checkpoint, in the
+ // checkpoint's own lock order (`checkpoint_lock` then
+ // `superblock_lock`), so the two cannot deadlock against each other.
+ //
+ // Both do the same pair of steps -- rewrite `snapshot.bin`, record
+ // `(checkpoint_op, checksum)` -- and `checkpoint_if_needed` holds
+ // `checkpoint_lock` across BOTH while taking `superblock_lock` only
+ // around the pairing write. `superblock_lock` alone therefore
+ // serializes nothing against the checkpoint's file rewrite: interleave
+ // them and the file comes from one while the durable pairing describes
+ // the other, which is exactly the torn pairing the superblock exists
to
+ // detect (a crash inside that window refuses boot with
+ // `CheckpointChecksumMismatch`). Checkpoints run on spawned tasks, so
a
+ // prepare that passed preflight before the transfer armed can drive
one
+ // during this install's superblock await -- a transferring replica
+ // withholds acks, but "should not be committing" is not an invariant
+ // this path can rest on.
//
- // Deadlock-free: nothing between here and the write awaits, and
- // `write_superblock` takes no lock of its own -- the checkpoint path
- // calls it under this same gate.
- let _superblock_gate = if snapshot_ahead && self.superblock.is_some() {
- Some(self.superblock_lock.acquire().await)
+ // Deadlock-free: nothing between here and the superblock write awaits,
+ // and `write_superblock` takes no lock of its own.
+ let _install_gates = if snapshot_ahead {
+ let checkpoint = self.checkpoint_lock.acquire().await;
+ let superblock = if self.superblock.is_some() {
+ Some(self.superblock_lock.acquire().await)
+ } else {
+ None
+ };
+ Some((checkpoint, superblock))
} else {
None
};
@@ -1589,23 +1787,27 @@ where
// checkpoint with the WAL intact and the transfer simply retries; a
crash
// after it recovers the transferred state. Failing here withholds
nothing
// already written -- the snapshot on disk subsumes the recorded
pairing, which
- // `verify_checkpoint_pairing` accepts -- but it is still reported so
the
- // caller can retry rather than treat the install as fully durable.
+ // `verify_checkpoint_pairing` accepts -- so it is reported as a
DEGRADED
+ // install rather than a failed one.
+ let mut pairing_durable = true;
if snapshot_ahead && let Some(superblock) = self.superblock.as_ref() {
- // Already under `_superblock_gate`, acquired above; re-acquiring
here
+ // Already under `_install_gates`, acquired above; re-acquiring
here
// would deadlock on the same non-reentrant gate.
- if !self.write_superblock(consensus, superblock.as_ref()).await {
+ pairing_durable = self.write_superblock(consensus,
superblock.as_ref()).await;
+ if !pairing_durable {
tracing::error!(
snapshot_seq,
commit_op,
"state transfer installed but the superblock write failed;
the \
transferred checkpoint is not durable yet"
);
- return Err(SnapshotError::SuperblockNotDurable { op:
snapshot_seq });
}
}
- Ok(snapshot_seq.max(local_applied))
+ Ok(InstallOutcome {
+ applied_frontier: snapshot_seq.max(local_applied),
+ pairing_durable,
+ })
}
/// Submit `Register` from in-process, await commit. Wire reply still fires
@@ -2756,7 +2958,7 @@ where
assert!(consensus.is_normal(), "promotion: status must be normal");
assert!(
!consensus.is_transferring(),
- "promotion: must not be syncing"
+ "promotion: must not be transferring state"
);
consensus.verify_pipeline();
match reply_sender {
diff --git a/core/metadata/src/stm/mod.rs b/core/metadata/src/stm/mod.rs
index 0763f4401..698dbd3c0 100644
--- a/core/metadata/src/stm/mod.rs
+++ b/core/metadata/src/stm/mod.rs
@@ -441,7 +441,7 @@ macro_rules! collect_handlers {
},
)*
[<$state Command>]::RestoreSnapshot(snapshot) => {
- *self =
Self::inner_from_snapshot(snapshot.clone());
+ self.restore_in_place(snapshot.clone());
$crate::stm::result::ApplyReply::default()
},
});
diff --git a/core/metadata/src/stm/mux.rs b/core/metadata/src/stm/mux.rs
index 027b4bcaf..f9e163fb2 100644
--- a/core/metadata/src/stm/mux.rs
+++ b/core/metadata/src/stm/mux.rs
@@ -194,9 +194,23 @@ where
Tail: RestoreSnapshotInPlace<SnapshotData>,
{
fn restore_snapshot_in_place(&self, snapshot: &SnapshotData) -> Result<(),
SnapshotError> {
+ // Two-phase, and the reason this is not a plain `?`-chain: the halves
+ // mutate independently with no rollback between them, and the caller
+ // has already persisted the transferred snapshot and seeded its
+ // pairing by the time it gets here. A half-restored mux would then be
+ // fed the local WAL's contiguous suffix by the follow-up
+ // `commit_journal`, replaying it into only the half that moved. The
+ // boot path fail-stops on the same input; this makes the running path
+ // agree.
+ self.check_restorable(snapshot)?;
self.0.restore_snapshot_in_place(snapshot)?;
self.1.restore_snapshot_in_place(snapshot)
}
+
+ fn check_restorable(&self, snapshot: &SnapshotData) -> Result<(),
SnapshotError> {
+ self.0.check_restorable(snapshot)?;
+ self.1.check_restorable(snapshot)
+ }
}
impl<T, SnapshotData> RestoreSnapshotInPlace<SnapshotData> for
MuxStateMachine<T>
@@ -206,6 +220,10 @@ where
fn restore_snapshot_in_place(&self, snapshot: &SnapshotData) -> Result<(),
SnapshotError> {
self.inner.restore_snapshot_in_place(snapshot)
}
+
+ fn check_restorable(&self, snapshot: &SnapshotData) -> Result<(),
SnapshotError> {
+ self.inner.check_restorable(snapshot)
+ }
}
impl<SnapshotData, T> FillSnapshot<SnapshotData> for MuxStateMachine<T>
diff --git a/core/metadata/src/stm/snapshot.rs
b/core/metadata/src/stm/snapshot.rs
index d80c15a33..ca28a30a4 100644
--- a/core/metadata/src/stm/snapshot.rs
+++ b/core/metadata/src/stm/snapshot.rs
@@ -53,11 +53,6 @@ pub enum SnapshotError {
commit_op: u64,
table_frontier: u64,
},
- /// A state-transfer install wrote its snapshot but could not durably
record the
- /// paired `(checkpoint_op, checksum)` in the superblock. The on-disk
snapshot
- /// subsumes the stale pairing, so recovery still accepts it; the caller
retries
- /// so the pairing (and the integrity check it powers) stops lagging.
- SuperblockNotDurable { op: u64 },
}
/// Stage at which snapshot persistence failed.
@@ -109,13 +104,6 @@ impl fmt::Display for SnapshotError {
commit_op {commit_op}, table frontier {table_frontier}"
)
}
- Self::SuperblockNotDurable { op } => {
- write!(
- f,
- "state transfer installed a snapshot at op {op} but could
not \
- durably pair it in the superblock"
- )
- }
}
}
}
@@ -128,8 +116,7 @@ impl std::error::Error for SnapshotError {
Self::Io(e) | Self::Persist { source: e, .. } => Some(e),
Self::ChecksumMismatch { .. }
| Self::Truncated { .. }
- | Self::IncoherentManifest { .. }
- | Self::SuperblockNotDurable { .. } => None,
+ | Self::IncoherentManifest { .. } => None,
}
}
}
@@ -310,6 +297,16 @@ pub trait RestoreSnapshot<S>: Sized {
pub trait RestoreSnapshotInPlace<S> {
/// Replace this state machine's contents from the snapshot.
fn restore_snapshot_in_place(&self, snapshot: &S) -> Result<(),
SnapshotError>;
+
+ /// Whether this state machine can restore from `snapshot`, WITHOUT
+ /// mutating anything.
+ ///
+ /// A mux restores its halves one at a time, so a snapshot missing the
+ /// second half would otherwise leave the first restored and the second on
+ /// pre-transfer state -- a split the caller cannot undo, because the
+ /// transferred snapshot was already persisted and its pairing seeded.
+ /// Every half agrees here before any half mutates.
+ fn check_restorable(&self, snapshot: &S) -> Result<(), SnapshotError>;
}
/// Base case for the recursive tuple pattern - unit type terminates the
recursion.
@@ -317,6 +314,10 @@ impl<S> RestoreSnapshotInPlace<S> for () {
fn restore_snapshot_in_place(&self, _snapshot: &S) -> Result<(),
SnapshotError> {
Ok(())
}
+
+ fn check_restorable(&self, _snapshot: &S) -> Result<(), SnapshotError> {
+ Ok(())
+ }
}
/// Base case for the recursive tuple pattern - unit type terminates the
recursion.
@@ -399,6 +400,23 @@ macro_rules! impl_fill_restore {
})
}
}
+
+ fn check_restorable(
+ &self,
+ snapshot: &$crate::stm::snapshot::MetadataSnapshot,
+ ) -> Result<(), $crate::stm::snapshot::SnapshotError> {
+ use serde::de::Error as _;
+ use $crate::stm::snapshot::SnapshotError;
+ if snapshot.$field.is_none() {
+ return Err(SnapshotError::Deserialize(
+ rmp_serde::decode::Error::custom(format_args!(
+ "Snapshot Restore Error: {}",
+ stringify!($field)
+ )),
+ ));
+ }
+ Ok(())
+ }
}
};
}
diff --git a/core/metadata/src/stm/stream.rs b/core/metadata/src/stm/stream.rs
index 767affc57..9e94d2ae4 100644
--- a/core/metadata/src/stm/stream.rs
+++ b/core/metadata/src/stm/stream.rs
@@ -27,7 +27,7 @@ use crate::stm::result::{
};
use crate::stm::snapshot::Snapshotable;
use crate::{collect_handlers, define_state, impl_fill_restore};
-use ahash::AHashMap;
+use ahash::{AHashMap, AHashSet};
use bytes::{BufMut, Bytes, BytesMut};
use iggy_binary_protocol::codec::{WireDecode, WireEncode};
// Only `seed_namespace` (below, sim/test-gated) uses these at module scope;
@@ -461,6 +461,43 @@ impl StatsRegistry {
!(*sid == stream_id && *tid == topic_id && *pid >=
first_removed)
});
}
+
+ /// Drop every entry the snapshot does not describe, keeping the rest.
+ ///
+ /// Used by the in-place restore (state transfer), which replaces the whole
+ /// stream tree but must not replace the registry: partition counters live
+ /// only here (never snapshotted), so survivors have to keep their `Arc`s
+ /// or every already-materialized partition reads (0,0,0,0) forever. Slab
+ /// keys are recycled, so anything the snapshot dropped has to go with it.
+ ///
+ /// # Panics
+ /// If the registry mutex is poisoned.
+ fn retain_from_snapshot(&self, snapshot: &StreamsSnapshot) {
+ let mut live_streams: AHashSet<usize> = AHashSet::new();
+ let mut live_topics: AHashSet<(usize, usize)> = AHashSet::new();
+ let mut live_partitions: AHashSet<(usize, usize, usize)> =
AHashSet::new();
+ for (stream_key, stream) in &snapshot.items {
+ live_streams.insert(*stream_key);
+ for (topic_key, topic) in &stream.topics {
+ live_topics.insert((*stream_key, *topic_key));
+ for partition in &topic.partitions {
+ live_partitions.insert((*stream_key, *topic_key,
partition.id));
+ }
+ }
+ }
+ self.streams
+ .lock()
+ .expect("stats registry mutex poisoned")
+ .retain(|id, _| live_streams.contains(id));
+ self.topics
+ .lock()
+ .expect("stats registry mutex poisoned")
+ .retain(|key, _| live_topics.contains(key));
+ self.partitions
+ .lock()
+ .expect("stats registry mutex poisoned")
+ .retain(|key, _| live_partitions.contains(key));
+ }
}
define_state! {
@@ -1945,22 +1982,43 @@ impl Snapshotable for Streams {
fn from_snapshot(
snapshot: Self::Snapshot,
) -> Result<Self, crate::stm::snapshot::SnapshotError> {
- Ok(StreamsInner::inner_from_snapshot(snapshot).into())
+ // Boot: no live registry exists yet, so mint one. Safe because
+ // `new_from_empty` clones this single inner onto the other left-right
+ // buffer rather than building a second one.
+ Ok(StreamsInner::inner_from_snapshot(snapshot,
Arc::new(StatsRegistry::default())).into())
}
}
impl StreamsInner {
- /// Build a complete `StreamsInner` from a snapshot section. Shared by
- /// wrapper construction ([`Snapshotable::from_snapshot`]) and the
- /// in-place restore command (state transfer), which absorbs it on both
- /// left-right buffers.
- pub(crate) fn inner_from_snapshot(snapshot: StreamsSnapshot) -> Self {
+ /// Rebuild from a snapshot section IN PLACE, keeping the live stats
+ /// registry.
+ ///
+ /// The restore command is absorbed on BOTH left-right buffers, so minting
+ /// a registry here would hand the two buffers different `Arc`s and split
+ /// every direct partition-plane counter increment by publish parity --
+ /// exactly what [`StatsRegistry`] exists to prevent. Carrying the registry
+ /// across also preserves the `Arc<PartitionStats>` the data plane
+ /// registered at bootstrap and reconcile, which nothing in a snapshot can
+ /// reconstruct (partition counters are not snapshotted).
+ pub(crate) fn restore_in_place(&mut self, snapshot: StreamsSnapshot) {
+ let registry = Arc::clone(&self.stats_registry);
+ // Slab keys are recycled, so an entry left over from a stream the
+ // snapshot does not have would hand its counters to whatever lands in
+ // that slot next.
+ registry.retain_from_snapshot(&snapshot);
+ *self = Self::inner_from_snapshot(snapshot, registry);
+ }
+
+ /// Build a complete `StreamsInner` from a snapshot section against
+ /// `stats_registry`. Shared by wrapper construction
+ /// ([`Snapshotable::from_snapshot`]) and the in-place restore command
+ /// (state transfer), which absorbs it on both left-right buffers.
+ pub(crate) fn inner_from_snapshot(
+ snapshot: StreamsSnapshot,
+ stats_registry: Arc<StatsRegistry>,
+ ) -> Self {
let mut index: AHashMap<Arc<str>, usize> = AHashMap::new();
let mut stream_entries: Vec<(usize, Stream)> = Vec::new();
- // Register restored stats in the shared registry so both left-right
- // buffers (and any post-restore op) reference one `Arc` per
- // stream/topic (see `StatsRegistry`).
- let stats_registry = Arc::new(StatsRegistry::default());
for (slab_key, stream_snap) in snapshot.items {
let stream_stats = stats_registry.stream(slab_key);
@@ -2469,4 +2527,88 @@ mod tests {
buffer.as_mut_slice()[header_size..].copy_from_slice(&body);
Message::try_from(buffer).unwrap()
}
+
+ /// One stream, one topic, one partition, materialized in the registry the
+ /// way the data plane does at bootstrap.
+ fn inner_with_registered_partition() -> StreamsInner {
+ let mut inner = StreamsInner::new();
+ create_stream(&mut inner, "alpha");
+ let create_topic = CreateTopicWithAssignmentsRequest {
+ request: make_topic_request(0, 1, "logs"),
+ partitions: vec![CreatedPartitionAssignment {
+ partition_id: 0,
+ consensus_group_id: 1,
+ }],
+ };
+ let _ = StateHandler::apply(&create_topic, &mut inner,
IggyTimestamp::now());
+ let topic_stats = inner.items[0].topics[0].stats.clone();
+ inner.stats_registry.partition(0, 0, 0, topic_stats);
+ inner
+ }
+
+ // The restore command is absorbed on BOTH left-right buffers. Minting a
+ // registry per call would hand the two buffers different `Arc`s, so a
+ // direct partition-plane increment would land on one buffer and vanish on
+ // the next publish -- the `messages_count_inconsistent` failure the
+ // registry exists to prevent.
+ #[test]
+ fn in_place_restore_keeps_one_registry_across_both_buffers() {
+ let mut first = inner_with_registered_partition();
+ let snapshot = Streams::from(first.clone()).to_snapshot();
+ let mut second = first.clone();
+
+ first.restore_in_place(snapshot.clone());
+ second.restore_in_place(snapshot);
+
+ assert!(
+ Arc::ptr_eq(&first.stats_registry, &second.stats_registry),
+ "both buffers must keep the one shared registry"
+ );
+ let from_first = first
+ .stats_registry
+ .partition_get(0, 0, 0)
+ .expect("survivor keeps its partition stats");
+ let from_second = second
+ .stats_registry
+ .partition_get(0, 0, 0)
+ .expect("survivor keeps its partition stats");
+ assert!(Arc::ptr_eq(&from_first, &from_second));
+ }
+
+ // Partition counters live only in the registry (never snapshotted), so an
+ // install that dropped them would leave every already-materialized
+ // partition reading zeroes with no way to recover them.
+ #[test]
+ fn in_place_restore_keeps_survivor_partition_stats() {
+ let mut inner = inner_with_registered_partition();
+ let stats = inner.stats_registry.partition_get(0, 0,
0).expect("stats");
+ stats.increment_messages_count(42);
+ let snapshot = Streams::from(inner.clone()).to_snapshot();
+
+ inner.restore_in_place(snapshot);
+
+ let after = inner
+ .stats_registry
+ .partition_get(0, 0, 0)
+ .expect("partition survived the restore, so its stats must too");
+ assert!(Arc::ptr_eq(&stats, &after));
+ assert_eq!(after.messages_count_inconsistent(), 42);
+ }
+
+ // Slab keys are recycled: an entry left behind by a stream the snapshot
+ // does not carry would hand its counters to whatever lands in that slot.
+ #[test]
+ fn in_place_restore_prunes_entries_the_snapshot_dropped() {
+ let mut inner = inner_with_registered_partition();
+ // A second stream that the snapshot below will not contain.
+ let empty = Streams::from(StreamsInner::new()).to_snapshot();
+ assert!(inner.stats_registry.partition_get(0, 0, 0).is_some());
+
+ inner.restore_in_place(empty);
+
+ assert!(
+ inner.stats_registry.partition_get(0, 0, 0).is_none(),
+ "a partition the snapshot dropped must not keep its registry entry"
+ );
+ }
}
diff --git a/core/metadata/src/stm/user.rs b/core/metadata/src/stm/user.rs
index f513e059a..66be115d9 100644
--- a/core/metadata/src/stm/user.rs
+++ b/core/metadata/src/stm/user.rs
@@ -819,6 +819,15 @@ impl Snapshotable for Users {
}
impl UsersInner {
+ /// Rebuild from a snapshot section IN PLACE (state transfer), absorbed on
+ /// both left-right buffers.
+ ///
+ /// Nothing here is shared across buffers the way `StreamsInner`'s stats
+ /// registry is, so a wholesale replace is correct.
+ pub(crate) fn restore_in_place(&mut self, snapshot: UsersSnapshot) {
+ *self = Self::inner_from_snapshot(snapshot);
+ }
+
/// Build a complete `UsersInner` from a snapshot section. Shared by
/// wrapper construction ([`Snapshotable::from_snapshot`]) and the
/// in-place restore command (state transfer), which absorbs it on both
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index 1da90f58f..eb48863c5 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -1371,7 +1371,7 @@ where
);
assert!(
!consensus.is_transferring(),
- "drain_request_queue_into_prepares: must not be syncing"
+ "drain_request_queue_into_prepares: must not be
transferring state"
);
let prepare = req.message.project(consensus);
consensus.verify_pipeline();
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index fb24564f4..da648fcb6 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -719,6 +719,16 @@ impl Journal<PartitionJournalMemStorage> for
PartitionJournal<PartitionJournalMe
#[rustfmt::skip]
type HeaderRef<'a> = &'a Self::Header;
+ /// No snapshot bookkeeping: the partition plane has no checkpoint of its
+ /// own yet, so nothing supersedes journaled entries. Answered explicitly
+ /// (the trait has no default) so partition-plane state transfer has to
+ /// decide this deliberately rather than inherit it.
+ fn snapshot_op(&self) -> u64 {
+ 0
+ }
+
+ fn set_snapshot_op(&self, _op: u64) {}
+
fn header(&self, idx: usize) -> Option<Self::HeaderRef<'_>> {
let headers = unsafe { &mut *self.headers.get() };
headers.get(idx)
diff --git a/core/partitions/src/log.rs b/core/partitions/src/log.rs
index 4fd5673db..fe53992d2 100644
--- a/core/partitions/src/log.rs
+++ b/core/partitions/src/log.rs
@@ -80,6 +80,30 @@ where
fn entry(&self, header: &Self::Header) -> impl Future<Output =
Option<Self::Entry>> {
self.inner.entry(header)
}
+
+ // Forward EVERY method, including the ones the trait could default. A
+ // wrapper that silently substitutes a default for its inner journal is a
+ // trap: `snapshot_op` would answer 0 and `set_snapshot_op` would vanish,
+ // so a state-transfer install through this wrapper would neither evict
+ // superseded entries nor advance the watermark it thinks it advanced.
+ fn snapshot_op(&self) -> u64 {
+ self.inner.snapshot_op()
+ }
+
+ fn set_snapshot_op(&self, op: u64) {
+ self.inner.set_snapshot_op(op);
+ }
+
+ fn remaining_capacity(&self) -> Option<usize> {
+ self.inner.remaining_capacity()
+ }
+
+ fn drain(
+ &self,
+ ops: std::ops::RangeInclusive<u64>,
+ ) -> impl Future<Output = std::io::Result<Vec<Self::Entry>>> {
+ self.inner.drain(ops)
+ }
}
impl<J: Default> Default for JournalState<J> {
diff --git a/core/sdk/src/quic/quic_client.rs b/core/sdk/src/quic/quic_client.rs
index c053d2864..9a731f608 100644
--- a/core/sdk/src/quic/quic_client.rs
+++ b/core/sdk/src/quic/quic_client.rs
@@ -177,13 +177,6 @@ impl BinaryTransport for QuicClient {
#[cfg(feature = "vsr")]
if skip_auto_login {
*self.skip_auto_login_once.lock().await = true;
- // The replayed login/register must mint a fresh Register: the
failed
- // attempt already consumed the one-shot register request id and
may
- // have half-bound the session.
- *self
- .consensus_session
- .lock()
- .expect("consensus session mutex poisoned") =
ConsensusSession::new();
}
let server_address =
self.current_server_address.lock().await.to_string();
info!(
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index d3ee674f2..799580274 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -1779,7 +1779,12 @@ async fn build_shard_for_thread(
// per-shard tunable set once here rather than per consensus group.
shard.set_repair_retry_ticks(repair_retry_ticks(config));
shard.set_repair_chunk_max(config.cluster.repair_chunk_max as u64);
- shard.set_clients_table_max(config.metadata.clients_table_max);
+ // Bounds a served state-transfer chunk. A frame above the bus ceiling is
+ // rejected by the RECEIVING transport, which tears the replica connection
+ // down rather than dropping one message.
+ shard.set_bus_max_message_size(
+
usize::try_from(config.message_bus.max_message_size.as_bytes_u64()).unwrap_or(usize::MAX),
+ );
*shard_handle.borrow_mut() = Some(Rc::downgrade(&shard));
Ok((shard, sessions))
}
@@ -1813,6 +1818,10 @@ const _: () = assert!(
);
const _: () =
assert!(configs::ng_cluster::DEFAULT_REPAIR_CHUNK_MAX as u64 ==
shard::REPAIR_CHUNK_MAX);
+const _: () = assert!(
+ configs::ng_cluster::STATE_CHUNK_HEADER_LEN
+ == size_of::<iggy_binary_protocol::consensus::StateChunkHeader>() as
u64
+);
/// Convert a consensus-timer interval to whole ticks, floored at one tick so a
/// sub-tick value still fires and saturated on overflow.
fn duration_to_ticks(interval: Duration) -> u64 {
diff --git a/core/server-ng/src/responses.rs b/core/server-ng/src/responses.rs
index 047816bfa..bfa459c68 100644
--- a/core/server-ng/src/responses.rs
+++ b/core/server-ng/src/responses.rs
@@ -1340,25 +1340,22 @@ pub(crate) fn build_raw_pat_reply(
return Ok(committed);
}
let header_len = std::mem::size_of::<ReplyHeader>();
- // A `Reply` whose result section is nonzero is not a successful commit
but a
- // committed business rejection or a `TransientNotCommitted` retry frame,
both
- // with no payload and no token to ship. Pass it through so the client
decodes
- // the typed result (and, for a transient, replays) instead of having a raw
- // token grafted onto a rejection body.
- if result_code(&committed.as_slice()[header_len..]) != Some(0) {
- return Ok(committed);
- }
let committed_header =
bytemuck::checked::try_from_bytes::<ReplyHeader>(&committed.as_slice()[..header_len])
.map_err(|_| IggyError::InvalidFormat)?;
let commit = committed_header.commit;
let size = committed_header.size as usize;
- // A committed create can still be a business rejection (duplicate name,
- // invalid expiry) whose reply body carries a nonzero result code. Splice
- // the secret only into a genuine success; pass everything else through
- // untouched so the client decodes the committed error instead of a
- // success-shaped token reply (the minted raw secret is simply dropped).
- // Mirrors the HTTP handler's `committed_payload` gate.
+ // A `Reply` whose result section is nonzero is not a successful commit:
+ // a committed business rejection (duplicate name, invalid expiry) or a
+ // `TransientNotCommitted` retry frame, both with no payload and no token
+ // to ship. Splice the secret only into a genuine success; pass everything
+ // else through untouched so the client decodes the typed result (and, for
+ // a transient, replays) instead of having a raw token grafted onto a
+ // rejection body. Mirrors the HTTP handler's `committed_payload` gate.
+ //
+ // Bounded by the header's own `size` rather than running to the end of the
+ // buffer, so a short frame reads as "no result section" instead of into
+ // allocation padding.
let reply_body = committed
.as_slice()
.get(header_len..size)
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 70980aa01..79bc1ba0d 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -745,11 +745,57 @@ struct MetadataRepairSession {
idle_ticks: u32,
}
+/// The metadata state machine, as every handler that walks or restores it
+/// needs it.
+///
+/// A blanket-implemented alias for a three-part bound that was pasted verbatim
+/// at eleven sites across this file and `router.rs`. No API change: anything
+/// satisfying the parts satisfies this.
+pub trait MetadataStm:
+ StreamsFrontend
+ + StateMachine<
+ Input = Message<PrepareHeader>,
+ Output = metadata::stm::result::ApplyReply,
+ Error = iggy_common::IggyError,
+ >
+{
+}
+
+impl<M> MetadataStm for M where
+ M: StreamsFrontend
+ + StateMachine<
+ Input = Message<PrepareHeader>,
+ Output = metadata::stm::result::ApplyReply,
+ Error = iggy_common::IggyError,
+ >
+{
+}
+
+/// [`MetadataStm`] plus in-place snapshot restore: the additional capability a
+/// state-transfer install needs over a plain commit walk.
+pub trait RestorableMetadataStm:
+ MetadataStm
+ +
metadata::stm::snapshot::RestoreSnapshotInPlace<metadata::stm::snapshot::MetadataSnapshot>
+{
+}
+
+impl<M> RestorableMetadataStm for M where
+ M: MetadataStm
+ +
metadata::stm::snapshot::RestoreSnapshotInPlace<metadata::stm::snapshot::MetadataSnapshot>
+{
+}
+
/// Chunk size for state-transfer artifact pulls. Lockstep (one in flight),
-/// so the bounded per-peer bus queue can never drop a burst tail; the bus
-/// message cap is far above this.
+/// so the bounded per-peer bus queue can never drop a burst tail. Clamped
+/// against the live bus ceiling by
+/// [`IggyShard::state_chunk_len_max`] rather than assumed to fit.
const STATE_CHUNK_LEN: u32 = 256 * 1024;
+/// Bus frame ceiling assumed before bootstrap overrides it. Matches the
+/// shipped `[message_bus] max_message_size` so the simulator and unit tests
+/// clamp the same way a default deployment does.
+const DEFAULT_BUS_MAX_MESSAGE_SIZE: usize = 64 * 1024 * 1024;
+
/// Stall rounds a receiver spends on ONE peer before abandoning the transfer
/// and falling back to journal repair. The retry has no peer re-selection, so
/// this is what keeps a peer that died mid-transfer from wedging the rejoining
@@ -763,6 +809,14 @@ const STATE_TRANSFER_MAX_STALL_RETRIES: u32 = 5;
/// protocol has no completion frame) or gave up.
const STATE_TRANSFER_OFFER_EXPIRY_MULTIPLE: u32 = 10;
+/// Lifetime of a FULLY SERVED offer, as a multiple of the repair-retry
+/// interval. It only has to outlive the receiver re-requesting a lost final
+/// chunk, but the receiver's stall re-request fires at exactly one such
+/// interval, so a one-interval grace is a coin flip against its own retry plus
+/// a network hop -- and losing the race costs a full re-pull (`UnknownOffer`
+/// drops the session with every byte already downloaded).
+const STATE_TRANSFER_SERVED_EXPIRY_MULTIPLE: u32 = 3;
+
/// One artifact of an accepted transfer target: its manifest entry plus the
/// bytes received so far (chunks are sequential, so `buf.len()` doubles as
/// the next request offset).
@@ -794,11 +848,6 @@ struct MetadataTransferSession {
/// Whether a descriptor has been accepted (an accepted EMPTY manifest is
/// distinguishable from "still waiting").
target_accepted: bool,
- /// Stall rounds this session has burned. Bounded by
- /// [`STATE_TRANSFER_MAX_STALL_RETRIES`]: the retry always targets the same
- /// `peer`, so a peer that dies mid-transfer would otherwise wedge the
- /// rejoining node forever.
- attempts: u32,
/// Ticks with no frame progress; at the configured repair-retry
/// threshold the missing piece is re-requested.
idle_ticks: u32,
@@ -808,9 +857,12 @@ struct MetadataTransferSession {
/// primary). Keyed by requester replica id so a rebooted requester's fresh
/// nonce replaces the stale offer; chunks must all come from ONE offer or
/// the artifact checksums cannot hold.
+///
+/// The offer itself is refcounted, so simultaneous rejoiners on the same
+/// snapshot generation share one copy of the snapshot bytes.
struct ServedStateTransfer {
nonce: u128,
- offer: metadata::StateTransferOffer,
+ offer: Rc<metadata::StateTransferOffer>,
/// Ticks since this offer last served a chunk. An offer owns a full copy
of
/// the snapshot and the encoded client table, so a completed or abandoned
/// transfer must not pin them for the process lifetime. There is no
@@ -945,17 +997,27 @@ where
/// `[cluster] repair_chunk_max` at bootstrap.
repair_chunk_max: Cell<u64>,
- /// Capacity for a state-transferred client table
- /// ([`consensus::ClientTable::decode`]). Defaults to
- /// [`consensus::CLIENTS_TABLE_MAX`]; server-ng overrides it from
- /// `[metadata] clients_table_max` at bootstrap so the installed table
- /// matches the configured capacity instead of the compile-time default.
- clients_table_max: Cell<usize>,
-
/// Live stalled-repair retry threshold in consensus ticks. Defaults to
/// [`partitions::REPAIR_RETRY_TICKS`]; server-ng overrides it from
/// `[cluster] repair_retry_interval` at bootstrap.
repair_retry_ticks: Cell<u32>,
+
+ /// Live `[message_bus] max_message_size`. Bounds a served state chunk: a
+ /// frame above this is rejected by the RECEIVING transport, which tears
+ /// down the whole replica connection. Defaults to a value that leaves
+ /// [`STATE_CHUNK_LEN`] usable; server-ng overrides it at bootstrap.
+ bus_max_message_size: Cell<usize>,
+
+ /// Consecutive metadata state-transfer rounds that made no progress.
+ ///
+ /// Deliberately NOT on [`MetadataTransferSession`]: three of the four
+ /// arming sites mint a fresh session, so a per-session counter bounded
+ /// nothing -- a permanently undecodable peer cycled abandon -> repair ->
+ /// `RangeEvicted` -> re-arm at zero forever. Held here it survives the
+ /// cycle, and it is reset by actual progress (see
+ /// [`IggyShard::note_metadata_transfer_progress`]) so scattered transient
+ /// stalls cannot accumulate into abandoning a nearly-complete transfer.
+ metadata_transfer_attempts: Cell<u32>,
}
impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
@@ -1052,7 +1114,8 @@ where
metadata_transfer_offers: RefCell::new(HashMap::new()),
repair_chunk_max: Cell::new(REPAIR_CHUNK_MAX),
repair_retry_ticks: Cell::new(partitions::REPAIR_RETRY_TICKS),
- clients_table_max: Cell::new(consensus::CLIENTS_TABLE_MAX),
+ bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
+ metadata_transfer_attempts: Cell::new(0),
})
}
@@ -1070,12 +1133,11 @@ where
self.repair_chunk_max.set(chunk);
}
- /// Override the state-transfer client-table capacity from configuration
- /// (`[metadata] clients_table_max`). Called once per shard at bootstrap;
- /// the simulator and tests keep the compile-time
- /// [`consensus::CLIENTS_TABLE_MAX`] default.
- pub fn set_clients_table_max(&self, max_clients: usize) {
- self.clients_table_max.set(max_clients);
+ /// Override the message-bus frame ceiling from configuration
+ /// (`[message_bus] max_message_size`). Called once per shard at bootstrap;
+ /// the simulator and tests keep the compile-time default.
+ pub fn set_bus_max_message_size(&self, max_message_size: usize) {
+ self.bus_max_message_size.set(max_message_size);
}
/// Hand a metadata consensus submit (login/logout) to shard 0.
@@ -1286,7 +1348,8 @@ where
metadata_transfer_offers: RefCell::new(HashMap::new()),
repair_chunk_max: Cell::new(REPAIR_CHUNK_MAX),
repair_retry_ticks: Cell::new(partitions::REPAIR_RETRY_TICKS),
- clients_table_max: Cell::new(consensus::CLIENTS_TABLE_MAX),
+ bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
+ metadata_transfer_attempts: Cell::new(0),
}
}
@@ -2104,12 +2167,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StreamsFrontend
- + StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- >,
+ M: MetadataStm,
{
let header = *msg.header();
let planes = self.plane.inner();
@@ -2121,9 +2179,12 @@ where
if planes.0.persist_superblock_if_needed(consensus).await {
dispatch_vsr_actions(consensus, planes.0.journal.as_ref(),
&actions).await;
}
+ // Same transfer gate as `on_start_view` and `on_commit`: the
+ // pre-install STM must not walk while a transfer is in flight.
if actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
+ && !consensus.is_transferring()
{
planes.0.commit_journal().await;
}
@@ -2162,12 +2223,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StreamsFrontend
- + StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- >,
+ M: MetadataStm,
{
let header = *msg.header();
let planes = self.plane.inner();
@@ -2176,6 +2232,12 @@ where
&& consensus.namespace() == header.namespace
{
let actions = consensus.handle_start_view(PlaneKind::Metadata,
&header);
+ // Every rejection path (wrong primary, old view, stale
incarnation,
+ // below the commit floor, self-sent) returns no actions, and an
+ // adopted StartView always emits at least `CommitJournal`. That
+ // makes emptiness the adoption signal -- and the arms below must
+ // not fire on a StartView this replica did not adopt.
+ let adopted = !actions.is_empty();
if planes.0.persist_superblock_if_needed(consensus).await {
dispatch_vsr_actions(consensus, planes.0.journal.as_ref(),
&actions).await;
}
@@ -2186,6 +2248,12 @@ where
// snapshot already contains, and the transfer replaces the table
// anyway.
//
+ // Gated on `adopted`: a stale StartView leaves `header.replica`
+ // pointing at a replica that need not be primary, and re-arming on
+ // one would re-mint the nonce (dropping the descriptor already in
+ // flight through the nonce filter) and, before the budget moved
off
+ // the session, reset the retry bound as well.
+ //
// Outside the superblock gate above: that gate fail-closes the VSR
// actions this replica would VOUCH with (notably `PrepareOk`)
until
// the adopted view is durable. Requesting a transfer vouches for
@@ -2193,24 +2261,26 @@ where
// withholds `PrepareOk` on its own (`is_transferring`). Gating it
// would also wedge the one path that repairs a replica whose gap
// sits below every peer's floor.
- if consensus.state_transfer_stage() ==
consensus::StateTransferStage::AwaitingTarget {
- let nonce = iggy_common::random_id::get_uuid();
- *self.metadata_transfer.borrow_mut() =
Some(MetadataTransferSession {
- nonce,
- peer: header.replica,
- commit_op: 0,
- artifacts: Vec::new(),
- target_accepted: false,
- attempts: 0,
- idle_ticks: 0,
- });
+ if adopted
+ && consensus.state_transfer_stage() ==
consensus::StateTransferStage::AwaitingTarget
+ {
tracing::info!(
shard = self.id,
peer = header.replica,
"adopted a live view while awaiting transfer; requesting
metadata state transfer"
);
- self.send_request_state_transfer(consensus, header.replica,
nonce)
- .await;
+ self.arm_metadata_transfer(consensus, header.replica).await;
+ return;
+ }
+ // Mid-transfer the pre-install STM must not walk: the snapshot
+ // being installed already contains those ops, and a walk that
+ // advances `commit_min` past the incoming `snapshot_seq` flips the
+ // install to table-only (no STM restore, no persist, no pairing)
+ // while still reporting success. Landing inside the install's
+ // superblock await instead trips `set_commit_floor`'s anti-rewind
+ // assert. The `AwaitingTarget` return above covers only that one
+ // stage; `Fetching` and `Installing` fall through to here.
+ if consensus.is_transferring() {
return;
}
// `dispatch_vsr_actions` deliberately no-ops `CommitJournal` (it
@@ -2297,12 +2367,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StreamsFrontend
- + StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- >,
+ M: MetadataStm,
{
let header = *msg.header();
let planes = self.plane.inner();
@@ -2684,12 +2749,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StreamsFrontend
- + StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- >,
+ M: MetadataStm,
{
let header = *msg.header();
let planes = self.plane.inner();
@@ -2762,25 +2822,15 @@ where
if consensus.state_transfer_stage() ==
consensus::StateTransferStage::Idle {
*self.metadata_repair.borrow_mut() = None;
consensus.begin_state_transfer_await();
- let nonce = iggy_common::random_id::get_uuid();
- *self.metadata_transfer.borrow_mut() =
Some(MetadataTransferSession {
- nonce,
- peer: header.replica,
- commit_op: 0,
- artifacts: Vec::new(),
- target_accepted: false,
- attempts: 0,
- idle_ticks: 0,
- });
tracing::info!(
shard = self.id,
peer = header.replica,
retained_from = header.op,
local_commit = consensus.commit_min(),
+ attempts = self.metadata_transfer_attempts.get(),
"metadata repair floor evicted; converting to
state transfer"
);
- self.send_request_state_transfer(consensus,
header.replica, nonce)
- .await;
+ self.arm_metadata_transfer(consensus,
header.replica).await;
} else {
tracing::debug!(
shard = self.id,
@@ -3052,7 +3102,7 @@ where
) where
B: MessageBus,
{
- let manifest = offer.map(|offer|
consensus::encode_state_manifest(&offer.artifacts));
+ let manifest = offer.map(|offer|
consensus::encode_state_manifest(&offer.manifest()));
let total_size =
size_of::<StateTransferTargetHeader>() +
manifest.as_ref().map_or(0, Vec::len);
let mut msg = Message::<StateTransferTargetHeader>::new(total_size);
@@ -3125,12 +3175,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StreamsFrontend
- + StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- >,
+ M: MetadataStm,
{
let header = *msg.header();
let planes = self.plane.inner();
@@ -3140,17 +3185,34 @@ where
if consensus.namespace() != header.namespace {
return;
}
- let offer = planes.0.state_transfer_offer();
let cluster = consensus.cluster();
let self_id = consensus.replica();
- if let Some(offer) = offer {
- tracing::info!(
+
+ // First-wins per (requester, nonce). A stall-retry
`RequestStateTransfer`
+ // reuses the session nonce, and rebuilding under it would replace a
+ // manifest the receiver may already have accepted: the client table is
+ // encoded live, so a rebuild that is SHORTER (a client logged out
between
+ // the two builds) lands the receiver's cursor exactly at the new
length
+ // and it re-requests an empty tail forever. Re-answering with the SAME
+ // offer is also what makes the retry idempotent.
+ let cached = self
+ .metadata_transfer_offers
+ .borrow_mut()
+ .get_mut(&header.replica)
+ .filter(|served| served.nonce == header.nonce)
+ .map(|served| {
+ // A descriptor retry proves the requester is alive and still
+ // wants THIS offer, so it counts as liveness: without the
reset
+ // the offer could age out mid-retry and the rebuild that
+ // replaced it is exactly what first-wins exists to prevent.
+ served.idle_ticks = 0;
+ Rc::clone(&served.offer)
+ });
+ if let Some(offer) = cached {
+ tracing::debug!(
shard = self.id,
requester = header.replica,
- commit_op = offer.commit_op,
- artifacts = offer.artifacts.len(),
- total_len = offer.artifacts.iter().map(|a| a.len).sum::<u64>(),
- "serving metadata state transfer"
+ "re-answering a state transfer request from the offer already
served"
);
self.send_state_transfer_target(
cluster,
@@ -3161,31 +3223,60 @@ where
Some(&offer),
)
.await;
- self.metadata_transfer_offers.borrow_mut().insert(
- header.replica,
- ServedStateTransfer {
- nonce: header.nonce,
- offer,
- idle_ticks: 0,
- fully_served: false,
- },
- );
- } else {
- tracing::info!(
- shard = self.id,
- requester = header.replica,
- "cannot serve metadata state transfer (not a caught-up \
- primary, or no snapshot persisted); requester falls back"
- );
- self.send_state_transfer_target(
- cluster,
- self_id,
- header.replica,
- header.nonce,
- header.namespace,
- None,
- )
- .await;
+ return;
+ }
+
+ match planes.0.state_transfer_offer() {
+ Ok(offer) => {
+ tracing::info!(
+ shard = self.id,
+ requester = header.replica,
+ commit_op = offer.commit_op,
+ snapshot_seq = offer.snapshot_seq,
+ artifacts = offer.len(),
+ total_len = offer.total_len(),
+ "serving metadata state transfer"
+ );
+ self.send_state_transfer_target(
+ cluster,
+ self_id,
+ header.replica,
+ header.nonce,
+ header.namespace,
+ Some(&offer),
+ )
+ .await;
+ self.metadata_transfer_offers.borrow_mut().insert(
+ header.replica,
+ ServedStateTransfer {
+ nonce: header.nonce,
+ offer,
+ idle_ticks: 0,
+ fully_served: false,
+ },
+ );
+ }
+ Err(reason) => {
+ // Log the ACTUAL reason: "no snapshot yet" is routine and the
+ // requester recovers through journal repair, while an
unreadable
+ // or corrupt `snapshot.bin` is an operator-visible fault on
THIS
+ // node that the old catch-all message actively misattributed.
+ tracing::info!(
+ shard = self.id,
+ requester = header.replica,
+ %reason,
+ "cannot serve metadata state transfer; requester falls
back"
+ );
+ self.send_state_transfer_target(
+ cluster,
+ self_id,
+ header.replica,
+ header.nonce,
+ header.namespace,
+ None,
+ )
+ .await;
+ }
}
}
@@ -3201,14 +3292,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StreamsFrontend
- + StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- > + metadata::stm::snapshot::RestoreSnapshotInPlace<
- metadata::stm::snapshot::MetadataSnapshot,
- >,
+ M: RestorableMetadataStm,
{
/// Alloc cap per artifact: a corrupt length field must not OOM the
/// shard. Far above any real metadata snapshot or client table.
@@ -3348,6 +3432,10 @@ where
let Some(ref consensus) = planes.0.consensus else {
return;
};
+ // Same clamp the serving side applies, so a bus ceiling below
+ // `STATE_CHUNK_LEN` shrinks the ask instead of leaving the server to
+ // silently serve less than was requested.
+ let chunk_len_max = self.state_chunk_len_max() as u64;
let request = {
let session = self.metadata_transfer.borrow();
session.as_ref().and_then(|session| {
@@ -3362,7 +3450,7 @@ where
let offset = artifact.buf.len() as u64;
let remaining = artifact.entry.len - offset;
#[allow(clippy::cast_possible_truncation)]
- let len = remaining.min(u64::from(STATE_CHUNK_LEN)) as u32;
+ let len = remaining.min(chunk_len_max) as u32;
#[allow(clippy::cast_possible_truncation)]
Some((session.nonce, session.peer, index as u32, offset, len))
})
@@ -3382,6 +3470,68 @@ where
}
}
+ /// Arm a fresh metadata transfer session against `peer` and request its
+ /// descriptor.
+ ///
+ /// Every arming site goes through here. Three near-identical session
+ /// literals had already drifted on the retry budget, which is why that
+ /// budget now lives on the shard ([`Self::metadata_transfer_attempts`])
+ /// instead of being re-minted with each session.
+ #[allow(clippy::future_not_send)]
+ async fn arm_metadata_transfer<P>(&self, consensus: &VsrConsensus<B, P>,
peer: u8)
+ where
+ B: MessageBus,
+ P: Pipeline<Entry = consensus::PipelineEntry>,
+ {
+ let nonce = iggy_common::random_id::get_uuid();
+ *self.metadata_transfer.borrow_mut() = Some(MetadataTransferSession {
+ nonce,
+ peer,
+ commit_op: 0,
+ artifacts: Vec::new(),
+ target_accepted: false,
+ idle_ticks: 0,
+ });
+ self.send_request_state_transfer(consensus, peer, nonce)
+ .await;
+ }
+
+ /// Largest state-chunk PAYLOAD this side will put on the wire.
+ ///
+ /// Clamped so header + payload stays inside the bus ceiling. Above it the
+ /// RECEIVING transport rejects the frame and tears down the entire replica
+ /// connection, which surfaces to an operator as an unexplained link flap.
+ /// Both ends derive their chunk size from this same function, so a bus cap
+ /// below [`STATE_CHUNK_LEN`] shrinks the chunk rather than making large
+ /// artifacts untransferable.
+ fn state_chunk_len_max(&self) -> usize {
+ let budget = self
+ .bus_max_message_size
+ .get()
+ .saturating_sub(size_of::<StateChunkHeader>());
+ // A bus cap at or below one header cannot carry a chunk at all. Serve
+ // one byte at a time rather than zero: a zero-length chunk is the
+ // livelock `on_request_state_chunk` refuses, and the boot validator
+ // rejects this configuration anyway.
+ budget.clamp(1, STATE_CHUNK_LEN as usize)
+ }
+
+ /// Burn one retry round; `true` once the budget is exhausted.
+ fn burn_metadata_transfer_attempt(&self) -> bool {
+ let attempts = self.metadata_transfer_attempts.get() + 1;
+ self.metadata_transfer_attempts.set(attempts);
+ attempts > STATE_TRANSFER_MAX_STALL_RETRIES
+ }
+
+ /// Real progress: reset the retry budget.
+ ///
+ /// The budget bounds CONSECUTIVE failures, not lifetime ones. Without this
+ /// five stalls scattered across a large transfer would abandon one that
was
+ /// nearly done, throwing away every byte already pulled.
+ fn note_metadata_transfer_progress(&self) {
+ self.metadata_transfer_attempts.set(0);
+ }
+
/// Serve one chunk out of the cached offer. An unknown nonce (offer
/// evicted, e.g. the serving process restarted) answers with an
/// `available = 0` descriptor so the requester restarts its session.
@@ -3401,6 +3551,12 @@ where
let cluster = consensus.cluster();
let self_id = consensus.replica();
+ // Never serve a frame the receiving transport will reject: anything
past
+ // `max_message_size` tears down the whole replica connection, which
reads
+ // as an unexplained link flap. Bounded by the requester's own ask,
this
+ // side's chunk size, and what the bus will carry.
+ let chunk_len_max = self.state_chunk_len_max();
+
// Frame built inside the borrow; every send runs after it drops (a
// RefCell borrow must not cross an await on the shard).
// Out-of-bounds requests are dropped silently inside the block.
@@ -3410,39 +3566,54 @@ where
.get_mut(&header.replica)
.filter(|served| served.nonce == header.nonce);
served.map_or(Some(ChunkReply::UnknownOffer), |served| {
- // Serving a chunk is the only liveness signal the offer gets;
- // the expiry sweep drops it once these stop arriving.
- served.idle_ticks = 0;
// Manifest-index addressing: an index past the offer is a
// requester bug (or a stale frame) and is dropped below.
- let last_artifact =
served.offer.payloads.len().saturating_sub(1);
- let artifact_bytes = served.offer.payloads.get(header.artifact
as usize)?;
+ let last_artifact = served.offer.len().saturating_sub(1);
+ let artifact_bytes = served.offer.payload(header.artifact as
usize)?;
let start = header.offset as usize;
- let end = start.saturating_add(header.len as usize);
- // Tail of the final artifact: the receiver now holds
everything
+ // A request AT the end of an artifact has nothing left to
serve.
+ // Answering it with `Some(&[])` -- which `get(len..len)`
happily
+ // returns -- would extend nothing on the receiver, reset both
+ // sides' idle counters, and be re-requested at the same offset
+ // forever: an unbounded empty-frame ping-pong with the
rejoining
+ // replica withholding `PrepareOk` for the life of the process.
+ // Reachable when a rebuilt offer is SHORTER than the manifest
the
+ // receiver accepted (a client logged out between the two
builds).
+ if start >= artifact_bytes.len() {
+ return None;
+ }
+ let end = start
+ .saturating_add((header.len as usize).min(chunk_len_max))
+ .min(artifact_bytes.len());
+ let payload = artifact_bytes.get(start..end)?;
+ // Only now that bytes are actually going out: an
out-of-bounds or
+ // stale frame must not flip a live offer onto the short
expiry.
+ // Tail of the final artifact means the receiver holds
everything
// the manifest promised, so the offer only has to outlive a
// possible re-request of this very chunk.
if header.artifact as usize == last_artifact && end >=
artifact_bytes.len() {
served.fully_served = true;
}
- artifact_bytes
- .get(start..end.min(artifact_bytes.len()))
- .map(|payload| {
- let total_size = size_of::<StateChunkHeader>() +
payload.len();
- let mut chunk =
Message::<StateChunkHeader>::new(total_size);
- chunk.as_mut_slice()[size_of::<StateChunkHeader>()..]
- .copy_from_slice(payload);
- ChunkReply::Chunk(chunk.transmute_header(|_, h: &mut
StateChunkHeader| {
- h.command = Command2::StateChunk;
- h.cluster = cluster;
- h.replica = self_id;
- h.nonce = header.nonce;
- h.namespace = header.namespace;
- h.artifact = header.artifact;
- h.offset = header.offset;
- h.size = total_size as u32;
- }))
- })
+ // Serving a chunk is the only liveness signal the offer gets;
+ // the expiry sweep drops it once these stop arriving. Set here
+ // rather than on entry so a request that serves NOTHING cannot
+ // keep an abandoned offer alive.
+ served.idle_ticks = 0;
+ let total_size = size_of::<StateChunkHeader>() + payload.len();
+ let mut chunk = Message::<StateChunkHeader>::new(total_size);
+
chunk.as_mut_slice()[size_of::<StateChunkHeader>()..].copy_from_slice(payload);
+ Some(ChunkReply::Chunk(chunk.transmute_header(
+ |_, h: &mut StateChunkHeader| {
+ h.command = Command2::StateChunk;
+ h.cluster = cluster;
+ h.replica = self_id;
+ h.nonce = header.nonce;
+ h.namespace = header.namespace;
+ h.artifact = header.artifact;
+ h.offset = header.offset;
+ h.size = total_size as u32;
+ },
+ )))
})
};
match reply {
@@ -3492,14 +3663,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StreamsFrontend
- + StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- > + metadata::stm::snapshot::RestoreSnapshotInPlace<
- metadata::stm::snapshot::MetadataSnapshot,
- >,
+ M: RestorableMetadataStm,
{
let header = *msg.header();
let planes = self.plane.inner();
@@ -3536,9 +3700,19 @@ where
);
return;
}
+ // A zero-byte payload is not progress: it extends nothing and the
+ // same offset is re-requested immediately. Resetting the liveness
+ // counters on one is what turned a short rebuilt offer into an
+ // unbounded empty-frame ping-pong. The serving side refuses to
+ // produce these now; the guard stays because a peer running an
+ // older build still can.
+ if payload.is_empty() {
+ return;
+ }
artifact.buf.extend_from_slice(payload);
session.idle_ticks = 0;
}
+ self.note_metadata_transfer_progress();
self.on_transfer_progress().await;
}
@@ -3557,19 +3731,35 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StreamsFrontend
- + StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- > + metadata::stm::snapshot::RestoreSnapshotInPlace<
- metadata::stm::snapshot::MetadataSnapshot,
- >,
+ M: RestorableMetadataStm,
{
let planes = self.plane.inner();
let Some(ref consensus) = planes.0.consensus else {
return;
};
+ // The stage is the authority on whether this transfer is still wanted,
+ // and it can be cleared from OUTSIDE this file: the probe-exhausted
+ // election fallback lives in the consensus crate, which cannot reach
+ // `metadata_transfer`, so it drops the stage to `Idle` (legal from
+ // `Fetching`, hence silent) and leaves the session armed with its
nonce
+ // intact. Chunks then keep arriving, the pull completes, and the
+ // `Installing` transition below asserts on an `Idle -> Installing`
edge
+ // that takes down shard 0. Drop the abandoned session here instead --
+ // this is the single funnel both descriptor acceptance and chunk
+ // arrival pass through.
+ let stage = consensus.state_transfer_stage();
+ if stage != consensus::StateTransferStage::Fetching {
+ if self.metadata_transfer.borrow().is_some() {
+ tracing::info!(
+ shard = self.id,
+ ?stage,
+ "metadata state transfer was abandoned out from under its
session; \
+ dropping it"
+ );
+ *self.metadata_transfer.borrow_mut() = None;
+ }
+ return;
+ }
let complete = {
let session = self.metadata_transfer.borrow();
match session.as_ref() {
@@ -3592,7 +3782,6 @@ where
.expect("session checked above");
let peer = session.peer;
let commit_op = session.commit_op;
- let attempts = session.attempts;
// Per-artifact integrity, then pick the pieces this plane installs.
// Unknown kinds are refused rather than skipped: an artifact the
@@ -3633,10 +3822,23 @@ where
let decoded = if damaged {
None
} else if let (Some(snapshot), Some((table_bytes, table_frontier))) =
(snapshot, table) {
- match consensus::ClientTable::decode(&table_bytes,
self.clients_table_max.get()) {
+ // Decode against the LIVE table's capacity, not a separately
+ // plumbed config cell. The serving primary's table can
legitimately
+ // hold more entries than this node's configured cap --
heterogeneous
+ // config, or homogeneous config after a cap reduction, since
+ // `from_snapshot` sizes capacity to `max(min_slots, highest_slot +
+ // 1)`. Against the raw config value that is a deterministic
+ // `TooManyEntries` every round: a permanent, log-only join
failure.
+ let capacity = planes.0.client_table_capacity();
+ match consensus::ClientTable::decode(&table_bytes, capacity) {
Ok(table) => Some((snapshot, table, table_frontier)),
Err(error) => {
- tracing::error!(shard = self.id, %error, "transferred
client table undecodable");
+ tracing::error!(
+ shard = self.id,
+ capacity,
+ %error,
+ "transferred client table undecodable"
+ );
None
}
}
@@ -3652,17 +3854,16 @@ where
// Damage is usually transit corruption, which a re-fetch fixes.
But
// it can also be permanent -- a peer whose artifacts this build
// cannot decode, or an unknown artifact kind -- and that re-offers
- // identically every round. Carry the attempt count across the
- // restart and share the stall budget, so a re-fetch that keeps
- // failing gives up instead of pulling the whole snapshot forever.
- // Distinct from the stall path: frames ARE flowing here, so
+ // identically every round. The budget lives on the shard, so it
+ // survives the abandon -> repair -> re-arm cycle and a re-fetch
that
+ // keeps failing gives up instead of pulling the whole snapshot
+ // forever. Distinct from the stall path: frames ARE flowing here,
so
// `idle_ticks` never accumulates and that sweep can never fire.
- let attempts = attempts + 1;
- if attempts > STATE_TRANSFER_MAX_STALL_RETRIES {
+ if self.burn_metadata_transfer_attempt() {
tracing::warn!(
shard = self.id,
peer,
- attempts,
+ attempts = self.metadata_transfer_attempts.get(),
"state transfer artifacts kept failing to decode;
abandoning \
and falling back to journal repair"
);
@@ -3678,18 +3879,7 @@ where
if consensus.state_transfer_stage() ==
consensus::StateTransferStage::Fetching {
consensus.set_state_transfer_stage(consensus::StateTransferStage::AwaitingTarget);
}
- let nonce = iggy_common::random_id::get_uuid();
- *self.metadata_transfer.borrow_mut() =
Some(MetadataTransferSession {
- nonce,
- peer,
- commit_op: 0,
- artifacts: Vec::new(),
- target_accepted: false,
- attempts,
- idle_ticks: 0,
- });
- self.send_request_state_transfer(consensus, peer, nonce)
- .await;
+ self.arm_metadata_transfer(consensus, peer).await;
return;
};
@@ -3699,42 +3889,41 @@ where
.install_state_transfer(&snapshot, table, table_frontier,
commit_op)
.await
{
- Ok(applied_frontier) => {
+ Ok(outcome) => {
consensus.set_state_transfer_stage(consensus::StateTransferStage::Idle);
- // `applied_frontier`, not the transferred snapshot's op: the
install
- // returns `max(snapshot_seq, local_applied)`, which differs
whenever a
- // serving peer offers a snapshot BEHIND this replica
(checkpoints are
- // node-local) and the local state machine is kept instead.
- tracing::info!(
- shard = self.id,
- applied_frontier,
- commit_op,
- table_frontier,
- "metadata state transfer installed; handing tail to
journal repair"
- );
+ // A completed install: the budget starts fresh for any later
+ // rejoin rather than carrying this one's stalls forward.
+ self.note_metadata_transfer_progress();
+ if outcome.pairing_durable {
+ // `applied_frontier`, not the transferred snapshot's op:
the install
+ // returns `max(snapshot_seq, local_applied)`, which
differs whenever a
+ // serving peer offers a snapshot BEHIND this replica
(checkpoints are
+ // node-local) and the local state machine is kept instead.
+ tracing::info!(
+ shard = self.id,
+ applied_frontier = outcome.applied_frontier,
+ commit_op,
+ table_frontier,
+ "metadata state transfer installed; handing tail to
journal repair"
+ );
+ } else {
+ // Deliberately NOT prefixed with the success line's text:
+ // the specs match log substrings, so a shared prefix would
+ // let every one of them pass on the degraded path.
+ tracing::warn!(
+ shard = self.id,
+ applied_frontier = outcome.applied_frontier,
+ commit_op,
+ table_frontier,
+ "metadata state transfer landed WITHOUT a durable
checkpoint \
+ pairing; the next superblock write records it"
+ );
+ }
// Walk whatever is already walkable, then let repair fetch
// the (snapshot_seq, commit_max] tail.
planes.0.commit_journal().await;
self.maybe_request_metadata_repair(consensus, peer).await;
}
- // Installed, but its `(checkpoint_op, checksum)` pairing is not
durable
- // yet. Not a failed install: the snapshot, table and frontiers
are all in
- // place, and the coordinator already holds the new pairing, so
the next
- // superblock write (view change or checkpoint) records it. Until
then a
- // crash recovers the PREVIOUS checkpoint and this replica
transfers again,
- // which is correct, just wasted work. Continue as a success.
- Err(metadata::stm::snapshot::SnapshotError::SuperblockNotDurable {
op }) => {
-
consensus.set_state_transfer_stage(consensus::StateTransferStage::Idle);
- tracing::warn!(
- shard = self.id,
- snapshot_seq = op,
- commit_op,
- "metadata state transfer installed but its checkpoint
pairing is \
- not durable yet; the next superblock write records it"
- );
- planes.0.commit_journal().await;
- self.maybe_request_metadata_repair(consensus, peer).await;
- }
Err(error) => {
tracing::error!(
shard = self.id,
@@ -3886,14 +4075,15 @@ where
// built and breaking transfers outright.
let retry_ticks = self.repair_retry_ticks.get().max(1);
let idle_expiry_ticks =
retry_ticks.saturating_mul(STATE_TRANSFER_OFFER_EXPIRY_MULTIPLE);
+ let served_expiry_ticks =
retry_ticks.saturating_mul(STATE_TRANSFER_SERVED_EXPIRY_MULTIPLE);
let mut offers = self.metadata_transfer_offers.borrow_mut();
offers.retain(|requester, served| {
served.idle_ticks += 1;
// A fully-served offer only has to outlive a re-request of its
last
- // chunk, so it goes after one retry interval; anything else is an
+ // chunk, so it goes on the short clock; anything else is an
// abandoned transfer and waits out the full idle window.
let expiry_ticks = if served.fully_served {
- retry_ticks
+ served_expiry_ticks
} else {
idle_expiry_ticks
};
@@ -3908,6 +4098,11 @@ where
}
live
});
+ // Nobody is pulling: release the cached snapshot copy too, rather than
+ // pinning it for the life of the process.
+ if offers.is_empty() {
+ self.plane.metadata().clear_state_transfer_offer_cache();
+ }
}
#[allow(clippy::future_not_send)]
@@ -3968,23 +4163,19 @@ where
return None;
}
session.idle_ticks = 0;
- session.attempts += 1;
- Some((
- session.peer,
- session.nonce,
- session.target_accepted,
- session.attempts,
- ))
+ Some((session.peer, session.nonce, session.target_accepted))
})
};
- if let Some((peer, nonce, target_accepted, attempts)) =
transfer_stalled {
+ if let Some((peer, nonce, target_accepted)) = transfer_stalled {
+ let exhausted = self.burn_metadata_transfer_attempt();
+ let attempts = self.metadata_transfer_attempts.get();
// Retrying the same peer forever is a wedge when that peer is the
// thing that died: nothing in this loop re-selects a target. Give
up
// after a bounded number of rounds and fall back to journal
repair,
// which re-picks a peer and, if the gap is still below its
retained
// floor, answers `RangeEvicted` and arms a fresh transfer against
// whoever is primary now.
- if attempts > STATE_TRANSFER_MAX_STALL_RETRIES {
+ if exhausted {
tracing::warn!(
shard = self.id,
peer,
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index 75d829e71..37f265045 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -19,15 +19,13 @@ use crate::metrics::{frame_drop_reason, frame_drop_variant};
use crate::shards_table::{
ShardsTable, calculate_shard_assignment, calculate_shard_from_consensus_ns,
};
-use crate::{IggyShard, LifecycleFrame, Receiver, ShardFrame};
+use crate::{IggyShard, LifecycleFrame, Receiver, RestorableMetadataStm,
ShardFrame};
use consensus::{MetadataHandle, PartitionsHandle};
use crossfire::TrySendError;
use futures::FutureExt;
use iggy_binary_protocol::{ConsensusHeader, GenericHeader, Operation,
PrepareHeader};
use journal::{Journal, JournalHandle};
use message_bus::{ConnectionInstaller, MessageBus, ReplicaHandshakeDoneFn};
-use metadata::impls::metadata::StreamsFrontend;
-use metadata::stm::StateMachine;
use server_common::sharding::{IggyNamespace, METADATA_CONSENSUS_NAMESPACE};
use server_common::{Message, MessageBag};
@@ -301,14 +299,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- > + StreamsFrontend
- + metadata::stm::snapshot::RestoreSnapshotInPlace<
- metadata::stm::snapshot::MetadataSnapshot,
- >,
+ M: RestorableMetadataStm,
{
// Reused across every pump iteration; pre-size to skip the
// first-drain reallocation.
@@ -440,14 +431,7 @@ where
Entry = Message<PrepareHeader>,
Header = PrepareHeader,
>,
- M: StateMachine<
- Input = Message<PrepareHeader>,
- Output = metadata::stm::result::ApplyReply,
- Error = iggy_common::IggyError,
- > + StreamsFrontend
- + metadata::stm::snapshot::RestoreSnapshotInPlace<
- metadata::stm::snapshot::MetadataSnapshot,
- >,
+ M: RestorableMetadataStm,
{
match frame {
ShardFrame::Consensus { message, .. } => {
diff --git a/core/simulator/src/deps.rs b/core/simulator/src/deps.rs
index b557d8bdc..de7c2240e 100644
--- a/core/simulator/src/deps.rs
+++ b/core/simulator/src/deps.rs
@@ -176,6 +176,16 @@ impl<S: Storage<Buffer = Vec<u8>>> Journal<S> for
SimJournal<S> {
where
Self: 'a;
+ /// The simulated journal retains everything for the run, so nothing is
+ /// ever superseded by a snapshot. Answered explicitly (the trait has no
+ /// default) so a simulated state transfer has to opt into a watermark
+ /// rather than silently inherit one that never moves.
+ fn snapshot_op(&self) -> u64 {
+ 0
+ }
+
+ fn set_snapshot_op(&self, _op: u64) {}
+
// TODO(hubcio): validate that the caller's checksum matches the stored
// header - currently this looks up by op only, ignoring the checksum.
// A real journal implementation must reject mismatches.