Merge branch 'master' into harness-single-node-default
diff --git a/Cargo.lock b/Cargo.lock
index 9111fc8..0cf2039 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -9548,6 +9548,7 @@
"bytes",
"compio",
"consensus",
+ "futures",
"iggy_binary_protocol",
"iggy_common",
"journal",
diff --git a/core/bench/Cargo.toml b/core/bench/Cargo.toml
index 9842a44..faa3269 100644
--- a/core/bench/Cargo.toml
+++ b/core/bench/Cargo.toml
@@ -31,6 +31,18 @@
name = "iggy-bench"
path = "src/main.rs"
+[features]
+# Switches the SDK to the vsr Register-handshake framing spoken by server-ng
+# clusters. The framing is chosen at compile time, so a default-features bench
+# binary cannot talk to a vsr cluster at all (the first request never frames).
+# TRAP: `cargo test -p integration --features vsr` does NOT rebuild this
+# binary -- the harness spawns whatever the last build produced, and a
+# default-featured leftover trips the bench timeout in
+# `run_bench_and_wait_for_finish` (one per restart-matrix case). Build the
+# workspace (or this crate with --features vsr) first; `just nextest-vsr`
+# does.
+vsr = ["iggy/vsr"]
+
[dependencies]
async-trait = { workspace = true }
bench-report = { workspace = true }
diff --git a/core/bench/src/main.rs b/core/bench/src/main.rs
index 6556a5a..70e06be 100644
--- a/core/bench/src/main.rs
+++ b/core/bench/src/main.rs
@@ -33,11 +33,25 @@
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use utils::cpu_name::append_cpu_name_lowercase;
+/// Which SDK framing this binary was compiled with.
+///
+/// One binary name, two wire dialects, and the mismatch is asymmetric: a
+/// default-features bench against a vsr server HANGS rather than fails, because
+/// the server reads a full 256-byte header before validating anything and the
+/// client's own response timeout is itself vsr-gated. Printing the flavor in the
+/// always-on banner turns that from a silent hang into a one-second diagnosis.
+const SDK_FRAMING: &str = if cfg!(feature = "vsr") {
+ "vsr (server-ng Register handshake)"
+} else {
+ "legacy (classic server framing)"
+};
+
#[tokio::main]
async fn main() -> Result<(), IggyError> {
let standard_font = FIGlet::standard().unwrap();
let figure = standard_font.convert("Iggy Bench");
println!("{}", figure.unwrap());
+ println!("SDK framing: {SDK_FRAMING}");
let mut args = IggyBenchArgs::parse();
args.validate();
diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index 52b9fb6..a4661cd 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -1415,7 +1415,40 @@
pub commit_op: u64,
pub namespace: u64,
pub available: u8,
- pub reserved: [u8; 95],
+ /// Set on an `available == 0` refusal that means "not right now" rather than
+ /// "this node is broken".
+ ///
+ /// PARTITION arm only: it is the only side with a consecutive-failure count
+ /// to charge. The requester then re-arms on a flat interval instead of
+ /// charging that count, whose exponential backoff climbs to 1024x the retry
+ /// interval and is reset only by a completed install. A serving primary
+ /// momentarily behind its own frontier is the common case under produce
+ /// load.
+ ///
+ /// This and `commit_max` below claim the HEAD of what used to be the
+ /// reserved tail, so every pre-existing field keeps its published offset.
+ /// Layout compatibility only: the size assert cannot catch an equal-size
+ /// reshuffle, so a mid-struct insertion would silently move every field
+ /// after it. It says nothing about the semantics of these two -- an older
+ /// peer presents zeros here and serves no partition transfers at all.
+ pub unavailable_transient: u8,
+ /// Explicit padding so `commit_max` sits 8-aligned without the implicit
+ /// padding `NoUninit` forbids.
+ pub reserved_alignment: [u8; 6],
+ /// Serving replica's `commit_max` when the descriptor was built.
+ ///
+ /// Read by the PARTITION receiver only; the metadata arm branches on
+ /// `available` and falls back to journal repair without a refusal.
+ ///
+ /// A partition receiver refuses an offer from a replica that knows LESS
+ /// than it does:
+ /// without this the descriptor carried no proof of the sender's own
+ /// progress, and a phantom view-0 primary (a group whose directory vanished
+ /// boots `init()` rather than `init_as_backup()`, comes up Normal at view 0,
+ /// and an empty log is trivially caught up) could hand a data-holding
+ /// rejoiner an empty offer that unlinks its chain.
+ pub commit_max: u64,
+ pub reserved: [u8; 80],
}
const _: () = {
assert!(size_of::<StateTransferTargetHeader>() == HEADER_SIZE);
@@ -1423,7 +1456,14 @@
offset_of!(StateTransferTargetHeader, nonce)
== offset_of!(StateTransferTargetHeader, reserved_frame) + size_of::<[u8; 66]>()
);
- assert!(offset_of!(StateTransferTargetHeader, reserved) + size_of::<[u8; 95]>() == HEADER_SIZE);
+ // The pre-existing published offsets. New fields grow into the reserved
+ // tail only; a change that moves one of these is a wire break.
+ assert!(offset_of!(StateTransferTargetHeader, commit_op) == 144);
+ assert!(offset_of!(StateTransferTargetHeader, namespace) == 152);
+ assert!(offset_of!(StateTransferTargetHeader, available) == 160);
+ assert!(offset_of!(StateTransferTargetHeader, unavailable_transient) == 161);
+ assert!(offset_of!(StateTransferTargetHeader, commit_max) == 168);
+ assert!(offset_of!(StateTransferTargetHeader, reserved) + size_of::<[u8; 80]>() == HEADER_SIZE);
};
impl ConsensusHeader for StateTransferTargetHeader {
@@ -1450,6 +1490,20 @@
"available must be 0 or 1".to_string(),
));
}
+ if self.unavailable_transient > 1 {
+ return Err(ConsensusError::InvalidField(
+ "unavailable_transient must be 0 or 1".to_string(),
+ ));
+ }
+ // The flag qualifies a refusal, so it is meaningless on an offer. Inert
+ // today (the receiver reads it only inside the `available == 0` arm),
+ // rejected anyway because a self-contradictory descriptor says the
+ // sender is not the build this field was designed for.
+ if self.available == 1 && self.unavailable_transient == 1 {
+ return Err(ConsensusError::InvalidField(
+ "unavailable_transient must be 0 on an available offer".to_string(),
+ ));
+ }
// Unavailable is a bare refusal; a manifest body on it would be
// ambiguous (which offer would the chunks belong to?). An
// `available == 1` body is left unbounded here on purpose: it carries
diff --git a/core/binary_protocol/src/consensus/operation.rs b/core/binary_protocol/src/consensus/operation.rs
index 11d15a1..026ff9b 100644
--- a/core/binary_protocol/src/consensus/operation.rs
+++ b/core/binary_protocol/src/consensus/operation.rs
@@ -186,6 +186,21 @@
(*self as u8) >= Self::PARTITION_START
}
+ /// Operations that replicate through the METADATA consensus group and live
+ /// in its WAL.
+ ///
+ /// Wider than [`Self::is_metadata`]: the session ops replicate on the
+ /// metadata plane without being metadata mutations. The single source of
+ /// truth for "does the metadata plane own this op", shared by the plane's
+ /// own applicability predicate and the repair router's legacy-stamp
+ /// acceptance -- the two drifting is how a metadata op ends up offered to
+ /// the partition arm.
+ #[must_use]
+ #[inline]
+ pub const fn is_metadata_plane(&self) -> bool {
+ self.is_metadata() || matches!(self, Self::Register | Self::Logout)
+ }
+
/// Operations clients are allowed to send directly.
#[must_use]
#[inline]
diff --git a/core/configs/src/server_ng_config/defaults.rs b/core/configs/src/server_ng_config/defaults.rs
index e68f088..40695a6 100644
--- a/core/configs/src/server_ng_config/defaults.rs
+++ b/core/configs/src/server_ng_config/defaults.rs
@@ -176,6 +176,11 @@
prepare_queue_depth: partition.prepare_queue_depth as usize,
evicted_ring_capacity: partition.evicted_ring_capacity as usize,
evicted_ring_bytes_max: partition.evicted_ring_bytes_max.parse().unwrap(),
+ transfer_served_cache_bytes_max: partition
+ .transfer_served_cache_bytes_max
+ .parse()
+ .unwrap(),
+ transfer_artifact_bytes_max: partition.transfer_artifact_bytes_max.parse().unwrap(),
}
}
}
diff --git a/core/configs/src/server_ng_config/displays.rs b/core/configs/src/server_ng_config/displays.rs
index 2d40475..ab48712 100644
--- a/core/configs/src/server_ng_config/displays.rs
+++ b/core/configs/src/server_ng_config/displays.rs
@@ -24,6 +24,7 @@
use super::message_bus::MessageBusConfig;
use super::metadata::MetadataConfig;
+use super::partition::PartitionConfig;
use super::quic::{QuicCertificateConfig, QuicConfig, QuicSocketConfig};
use super::server_ng::{ExtraConfig, NamespaceConfig, ServerNgConfig};
use super::tcp::{TcpConfig, TcpSocketConfig, TcpTlsConfig};
@@ -35,7 +36,7 @@
f,
"{{ consumer_group: {}, data_maintenance: {}, extra: {}, message_saver: {}, \
heartbeat: {}, system: {}, quic: {}, tcp: {}, http: {}, telemetry: {}, \
- metadata: {}, message_bus: {} }}",
+ metadata: {}, message_bus: {}, partition: {} }}",
self.consumer_group,
self.data_maintenance,
self.extra,
@@ -48,6 +49,23 @@
self.telemetry,
self.metadata,
self.message_bus,
+ self.partition,
+ )
+ }
+}
+
+impl Display for PartitionConfig {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "{{ prepare_queue_depth: {}, evicted_ring_capacity: {}, \
+ evicted_ring_bytes_max: {}, transfer_served_cache_bytes_max: {}, \
+ transfer_artifact_bytes_max: {} }}",
+ self.prepare_queue_depth,
+ self.evicted_ring_capacity,
+ self.evicted_ring_bytes_max,
+ self.transfer_served_cache_bytes_max,
+ self.transfer_artifact_bytes_max,
)
}
}
diff --git a/core/configs/src/server_ng_config/partition.rs b/core/configs/src/server_ng_config/partition.rs
index 580668e..011e82a 100644
--- a/core/configs/src/server_ng_config/partition.rs
+++ b/core/configs/src/server_ng_config/partition.rs
@@ -55,6 +55,22 @@
/// sizing endorsement.
pub const MAX_PARTITION_PREPARE_QUEUE_DEPTH: usize = 256;
+/// Mirrors the free const `shard::PARTITION_ARTIFACT_LEN_DEFAULT` (segment
+/// ceiling plus the one whole batch a segment may close past it).
+pub const DEFAULT_TRANSFER_ARTIFACT_BYTES_MAX: u64 = 1024 * 1024 * 1024 + 64 * 1024 * 1024;
+
+/// Mirrors the free const `shard::SERVED_SEGMENT_CACHE_BYTES_DEFAULT`: room for
+/// two concurrently served segments at the size a SEALED one actually reaches,
+/// which is the artifact ceiling above, not the configured segment target. Sized
+/// off the target instead, two admitted pulls would not both fit and would evict
+/// each other on every chunk.
+pub const DEFAULT_TRANSFER_SERVED_CACHE_BYTES_MAX: u64 = 2 * DEFAULT_TRANSFER_ARTIFACT_BYTES_MAX;
+
+/// Upper bound on the two state-transfer byte knobs. A typo guard, not a sizing
+/// endorsement: both are PER SHARD, so a slipped digit multiplies by the core
+/// count.
+pub const MAX_TRANSFER_BYTES: u64 = 64 * 1024 * 1024 * 1024;
+
/// Mirrors `partitions::EVICTED_RING_CAPACITY`.
pub const DEFAULT_EVICTED_RING_CAPACITY: usize = 4096;
@@ -95,6 +111,27 @@
/// [`MAX_EVICTED_RING_BYTES`].
#[config_env(leaf)]
pub evicted_ring_bytes_max: IggyByteSize,
+
+ /// Byte budget for segment payloads a SERVING shard keeps resident to
+ /// answer state-transfer chunk requests, per shard (so the process-wide
+ /// bound is this times the shard count).
+ ///
+ /// Sized for concurrent pulls, not one: at exactly one maximum segment a
+ /// single receiver arming several transfers thrashes the cache by itself,
+ /// and every miss re-reads and re-hashes a whole segment to serve one
+ /// chunk. Must be > 0.
+ #[config_env(leaf)]
+ pub transfer_served_cache_bytes_max: IggyByteSize,
+
+ /// Alloc ceiling for ONE received state-transfer artifact, per shard.
+ ///
+ /// A receiver holds the whole artifact resident through verify, walk and
+ /// staging write, so the in-flight cap multiplies this. It must stay above
+ /// the largest legal segment (`segment.size` plus the one batch a segment
+ /// may overshoot it by) or legal segments are rejected deterministically.
+ /// Must be > 0.
+ #[config_env(leaf)]
+ pub transfer_artifact_bytes_max: IggyByteSize,
}
impl Validatable<ConfigurationError> for PartitionConfig {
@@ -121,6 +158,26 @@
);
return Err(ConfigurationError::InvalidConfigurationValue);
}
+ // The FLOOR on `transfer_artifact_bytes_max` cannot live here (it needs
+ // `system.segment.size` and the bus cap); it is enforced in the
+ // `ServerNgConfig` validator, which is what turns that misconfiguration
+ // into a boot error instead of a silent per-partition rejoin livelock.
+ let served_cache = self.transfer_served_cache_bytes_max.as_bytes_u64();
+ if served_cache == 0 || served_cache > MAX_TRANSFER_BYTES {
+ eprintln!(
+ "{COMPONENT_NG} partition.transfer_served_cache_bytes_max ({served_cache} bytes) \
+ must be > 0 and <= {MAX_TRANSFER_BYTES} bytes"
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
+ let artifact_bytes = self.transfer_artifact_bytes_max.as_bytes_u64();
+ if artifact_bytes == 0 || artifact_bytes > MAX_TRANSFER_BYTES {
+ eprintln!(
+ "{COMPONENT_NG} partition.transfer_artifact_bytes_max ({artifact_bytes} bytes) \
+ must be > 0 and <= {MAX_TRANSFER_BYTES} bytes"
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
let ring_bytes = self.evicted_ring_bytes_max.as_bytes_u64();
if ring_bytes == 0 {
eprintln!("{COMPONENT_NG} partition.evicted_ring_bytes_max must be > 0");
@@ -147,6 +204,25 @@
assert!(PartitionConfig::default().validate().is_ok());
}
+ /// The shipped TOML strings are the only thing an operator sees, and nothing
+ /// else ties them to the constants the code sizes itself against -- a
+ /// decimal/binary slip ("1088 MB" for 1088 MiB) parses fine and ships a cap
+ /// BELOW the largest legal segment, which livelocks a rejoin per partition.
+ #[test]
+ fn shipped_transfer_defaults_match_the_runtime_constants() {
+ let config = PartitionConfig::default();
+ assert_eq!(
+ config.transfer_artifact_bytes_max.as_bytes_u64(),
+ DEFAULT_TRANSFER_ARTIFACT_BYTES_MAX,
+ "config.toml transfer_artifact_bytes_max drifted from the runtime default"
+ );
+ assert_eq!(
+ config.transfer_served_cache_bytes_max.as_bytes_u64(),
+ DEFAULT_TRANSFER_SERVED_CACHE_BYTES_MAX,
+ "config.toml transfer_served_cache_bytes_max drifted from the runtime default"
+ );
+ }
+
#[test]
fn rejects_zero_prepare_queue_depth() {
let config = PartitionConfig {
diff --git a/core/configs/src/server_ng_config/validators.rs b/core/configs/src/server_ng_config/validators.rs
index 8c6941f..f7408b7 100644
--- a/core/configs/src/server_ng_config/validators.rs
+++ b/core/configs/src/server_ng_config/validators.rs
@@ -183,6 +183,34 @@
return Err(ConfigurationError::InvalidConfigurationValue);
}
+ // A received segment artifact can be one whole batch larger than the
+ // segment cap (rotation checks the cap AFTER appending), and the real
+ // batch bound is the BUS frame cap -- server-ng never enforces
+ // `MAX_PAYLOAD_SIZE`. An artifact ceiling under that floor refuses a
+ // legal segment, and the manifest check is all-or-nothing, so the
+ // partition livelocks re-requesting the same segment from every peer at
+ // the backoff ceiling. Caught here so it is a boot error rather than one
+ // partition that silently never rejoins.
+ let artifact_floor = self
+ .system
+ .segment
+ .size
+ .as_bytes_u64()
+ .saturating_add(self.message_bus.max_message_size.as_bytes_u64());
+ if self.partition.transfer_artifact_bytes_max.as_bytes_u64() < artifact_floor {
+ eprintln!(
+ "{COMPONENT_NG} partition.transfer_artifact_bytes_max ({} B) must be at least \
+ system.segment.size ({} B) + message_bus.max_message_size ({} B) = \
+ {artifact_floor} B: a segment may close one whole batch past its cap, and an \
+ artifact ceiling below that refuses a legal segment and livelocks the \
+ partition's rejoin",
+ self.partition.transfer_artifact_bytes_max.as_bytes_u64(),
+ self.system.segment.size.as_bytes_u64(),
+ self.message_bus.max_message_size.as_bytes_u64(),
+ );
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
+
self.message_bus
.validate()
.error(|e: &ConfigurationError| {
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 63beade..d23378f 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -1527,6 +1527,9 @@
commit_max: self.commit_max.get(),
checkpoint_op,
checkpoint_checksum,
+ // Consensus mints no message offsets: the PARTITION plane stamps
+ // this in before it writes (`IggyPartition::write_superblock`).
+ offset_frontier: 0,
}
}
@@ -3046,7 +3049,11 @@
// never re-stamped (`restamp_prepare_view` patches only `view`), so this
// survives view-change retransmits. The header `checksum` and its `parent`
// chain stay `0`: activating them needs the retransmit path to re-seal a
- // re-stamped header, a separate change.
+ // re-stamped header, a separate change. Whoever activates it must also
+ // audit every `set_last_prepare_checksum` caller for cross-plane carry --
+ // the repair router in `shard` drops metadata-plane frames it cannot
+ // journal precisely so one cannot stamp a PARTITION consensus, which is
+ // inert only while these values are structurally zero.
//
// Metadata plane only. A partition produce prepare already carries a verified
// `batch_checksum` over the same bytes, so a second full-payload pass is pure
@@ -3079,7 +3086,12 @@
op,
timestamp,
operation: old.operation,
- namespace: old.namespace,
+ // The GROUP's namespace, never the request's: a client
+ // RequestHeader carries namespace 0, and journaling that
+ // would make the stored prepare route to the wrong plane
+ // when repair later ships it verbatim (live replication
+ // masked this; repair replay is what broke).
+ namespace: consensus.namespace,
checksum_body,
// Copied verbatim: carries the stamped acting user for client
// ops (and the authenticated user on Register), so the in-apply
diff --git a/core/consensus/src/lib.rs b/core/consensus/src/lib.rs
index 7b5457a..4d7a53d 100644
--- a/core/consensus/src/lib.rs
+++ b/core/consensus/src/lib.rs
@@ -155,8 +155,13 @@
};
pub mod state_manifest;
pub use state_manifest::{
- StateArtifact, StateManifestError, artifact_kind, decode_state_manifest, encode_state_manifest,
- state_artifact_checksum,
+ StateArtifact, StateArtifactHasher, StateManifestError, artifact_kind, decode_state_manifest,
+ encode_state_manifest, state_artifact_checksum,
+};
+pub mod state_transfer;
+pub use state_transfer::{
+ ArtifactProgress, ChunkProgress, STATE_TRANSFER_MAX_DECODE_RETRIES,
+ STATE_TRANSFER_MAX_STALL_RETRIES, append_chunk, next_pending_chunk, verify_state_artifact,
};
// One-shot per `PipelineEntry` for in-process commit awaiters.
pub(crate) mod oneshot;
diff --git a/core/consensus/src/state_manifest.rs b/core/consensus/src/state_manifest.rs
index 2841c6c..be5a530 100644
--- a/core/consensus/src/state_manifest.rs
+++ b/core/consensus/src/state_manifest.rs
@@ -45,8 +45,14 @@
pub const METADATA_SNAPSHOT: u8 = 0;
/// Metadata plane: [`crate::ClientTable::encode`] bytes.
pub const CLIENT_TABLE: u8 = 1;
- // Partition plane (reserved, not served yet):
- // SEGMENT_LOG = 2, CONSUMER_OFFSETS = 3.
+ /// Partition plane: one retained segment's `.log` bytes verbatim
+ /// (prepare-stripped `SendMessages2` records); `frontier` = the
+ /// segment's base offset.
+ pub const SEGMENT_LOG: u8 = 2;
+ /// Partition plane: the encoded consumer + consumer-group offset table
+ /// (plus the applied purge generation); `frontier` = the offer's
+ /// `commit_op`.
+ pub const CONSUMER_OFFSETS: u8 = 3;
}
/// One artifact a serving peer offers: what it is, where its receiver-side
@@ -88,6 +94,32 @@
hasher.finish()
}
+/// Streaming form of [`state_artifact_checksum`].
+///
+/// For payloads too large to hold resident (a serving primary hashing
+/// multi-GiB segment files in chunks between reactor yields). Feeding the
+/// same bytes in any chunking produces the same stamp as the one-shot form.
+#[derive(Default)]
+pub struct StateArtifactHasher {
+ inner: XxHash3_64,
+}
+
+impl StateArtifactHasher {
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn update(&mut self, bytes: &[u8]) {
+ self.inner.write(bytes);
+ }
+
+ #[must_use]
+ pub fn finish(&self) -> u64 {
+ self.inner.finish()
+ }
+}
+
/// Failure decoding an encoded manifest.
#[derive(Debug)]
pub enum StateManifestError {
diff --git a/core/consensus/src/state_transfer.rs b/core/consensus/src/state_transfer.rs
new file mode 100644
index 0000000..f5cf14e
--- /dev/null
+++ b/core/consensus/src/state_transfer.rs
@@ -0,0 +1,279 @@
+// 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.
+
+//! Receiver-side state-transfer session math shared by both planes.
+//!
+//! A transfer pulls the artifacts named by a [`crate::StateArtifact`]
+//! manifest in manifest order, lockstep with one chunk in flight; each
+//! artifact's `buf.len()` doubles as the next request offset. The functions
+//! here are the pure parts of that session -- chunk cursor arithmetic and the
+//! frame-acceptance guards -- so the metadata and partition receivers cannot
+//! drift on the invariants that were review findings on the metadata plane
+//! (sequential offsets, overrun refusal, the zero-byte-payload livelock).
+
+use crate::state_manifest::{StateArtifact, state_artifact_checksum};
+
+/// 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 node; repair then re-picks
+/// a target and re-arms a transfer if the gap is still below the new peer's
+/// retained floor.
+pub const STATE_TRANSFER_MAX_STALL_RETRIES: u32 = 5;
+
+/// Decode-failure rounds a receiver spends on ONE offered generation
+/// before refusing to pull it again.
+///
+/// Read by the METADATA arm only -- it lives here because the chunk cursor it
+/// pairs with is plane-agnostic, not because both planes use it.
+///
+/// Keyed on `snapshot_seq`: a peer whose snapshot
+/// generation advances resets the budget (new bytes are worth full
+/// retries), while a generation this build cannot decode costs one refused
+/// descriptor per repair round instead of a full pull. The partition plane
+/// deliberately does NOT use a generation-keyed budget -- a committing
+/// origin advances `commit_op` every round, which pinned such a count at
+/// 1 forever; it counts consecutive failures on the partition and backs
+/// its re-arm off instead.
+pub const STATE_TRANSFER_MAX_DECODE_RETRIES: u32 = 5;
+
+/// 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. A receiver that spills completed artifacts to
+/// disk empties `buf` afterwards and tracks completion out of band.
+#[derive(Debug)]
+pub struct ArtifactProgress {
+ pub entry: StateArtifact,
+ pub buf: Vec<u8>,
+}
+
+/// What [`next_pending_chunk`] and [`append_chunk`] need from one slot.
+///
+/// Exists so a plane can track completion in richer shapes -- the
+/// partition receiver spills finished segments to disk and replaces the
+/// buffer with staged metadata -- while both planes share one chunk cursor:
+/// a spilled artifact simply reports itself complete and is skipped.
+pub trait ChunkProgress {
+ fn declared_len(&self) -> u64;
+ fn received_len(&self) -> u64;
+ /// Only called after the cursor checks `received + payload <= declared`,
+ /// so an impl whose slot cannot grow (already complete) never sees it.
+ fn extend_from_chunk(&mut self, payload: &[u8]);
+ /// Reserve room for the whole declared length, called once per artifact on
+ /// its FIRST chunk (see [`append_chunk`]). Default: nothing, for slots that
+ /// do not accumulate in memory. Reserving at accept time instead would
+ /// commit address space for every manifest entry at once.
+ fn reserve_declared(&mut self) {}
+ fn complete(&self) -> bool {
+ self.received_len() == self.declared_len()
+ }
+}
+
+impl ChunkProgress for ArtifactProgress {
+ fn declared_len(&self) -> u64 {
+ self.entry.len
+ }
+
+ fn received_len(&self) -> u64 {
+ self.buf.len() as u64
+ }
+
+ fn extend_from_chunk(&mut self, payload: &[u8]) {
+ self.buf.extend_from_slice(payload);
+ }
+
+ fn reserve_declared(&mut self) {
+ // Exact rather than geometric: `entry.len` already passed the caller's
+ // per-kind caps, and doubling to gigabyte sizes copies roughly twice the
+ // bytes at a ~1.5x transient peak.
+ #[allow(clippy::cast_possible_truncation)]
+ self.buf.reserve_exact(self.entry.len as usize);
+ }
+}
+
+/// Next `(artifact index, offset, len)` to request.
+///
+/// The first incomplete artifact in manifest order, asked from its current
+/// frontier, clamped to `chunk_len_max`. `None` when every artifact is complete (or the manifest
+/// is empty).
+#[must_use]
+pub fn next_pending_chunk<T: ChunkProgress>(
+ artifacts: &[T],
+ chunk_len_max: u64,
+) -> Option<(u32, u64, u32)> {
+ let (index, artifact) = artifacts
+ .iter()
+ .enumerate()
+ .find(|(_, artifact)| !artifact.complete())?;
+ let offset = artifact.received_len();
+ let remaining = artifact.declared_len() - offset;
+ #[allow(clippy::cast_possible_truncation)]
+ let len = remaining.min(chunk_len_max) as u32;
+ #[allow(clippy::cast_possible_truncation)]
+ Some((index as u32, offset, len))
+}
+
+/// Append one received chunk; `true` only when bytes actually landed, which
+/// is the caller's cue to reset its liveness counters and re-drive progress.
+///
+/// Everything else is dropped without side effects: an artifact that is not
+/// the FIRST incomplete one, a non-sequential offset (chunks are pulled
+/// lockstep, so anything else is a duplicate or reorder -- the stall retry
+/// re-requests from the current frontier), an overrun past the declared
+/// length, and a zero-byte payload. The first-incomplete restriction mirrors
+/// what [`next_pending_chunk`] would have requested, and bounds the
+/// reservation below to ONE artifact at a time: without it a peer that pushes
+/// one byte into every manifest entry would make each slot reserve its whole
+/// declared length, committing address space for the sum of the manifest, and
+/// a failed `Vec` reservation aborts the process rather than erroring. A
+/// zero-byte payload is not progress: it extends nothing and the same offset is re-requested
+/// immediately, and resetting liveness counters on one is what turned a
+/// short rebuilt offer into an unbounded empty-frame ping-pong on the
+/// metadata plane. The serving side refuses to produce these now; the guard
+/// stays because a peer running an older build still can.
+#[must_use]
+pub fn append_chunk<T: ChunkProgress>(
+ artifacts: &mut [T],
+ artifact_index: u32,
+ offset: u64,
+ payload: &[u8],
+) -> bool {
+ let first_incomplete = artifacts.iter().position(|artifact| !artifact.complete());
+ if first_incomplete != Some(artifact_index as usize) {
+ return false;
+ }
+ let Some(artifact) = artifacts.get_mut(artifact_index as usize) else {
+ return false;
+ };
+ if offset != artifact.received_len() {
+ return false;
+ }
+ if artifact.received_len() + payload.len() as u64 > artifact.declared_len() {
+ tracing::warn!(
+ artifact = artifact_index,
+ declared_len = artifact.declared_len(),
+ "state chunk overruns the declared artifact length; dropping frame"
+ );
+ return false;
+ }
+ if payload.is_empty() {
+ return false;
+ }
+ // First chunk of this artifact: give the slot its full declared length in
+ // one allocation, so a segment-sized artifact is not grown by doubling.
+ if artifact.received_len() == 0 {
+ artifact.reserve_declared();
+ }
+ artifact.extend_from_chunk(payload);
+ true
+}
+
+/// Whether `bytes` is exactly the artifact the manifest promised:
+/// declared length and `XxHash3_64` checksum.
+///
+/// This proves transit integrity only -- the payload still needs its own
+/// format validation, since the checksum does not prove the peer computed
+/// it over sane bytes.
+#[must_use]
+pub fn verify_state_artifact(entry: &StateArtifact, bytes: &[u8]) -> bool {
+ bytes.len() as u64 == entry.len && state_artifact_checksum(bytes) == entry.checksum
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn progress(kind: u8, len: u64) -> ArtifactProgress {
+ ArtifactProgress {
+ entry: StateArtifact {
+ kind,
+ frontier: 0,
+ len,
+ checksum: 0,
+ },
+ buf: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn given_partial_artifacts_when_next_chunk_requested_should_resume_first_incomplete() {
+ let mut artifacts = vec![progress(0, 4), progress(1, 10)];
+ artifacts[0].buf = vec![0; 4];
+ artifacts[1].buf = vec![0; 3];
+
+ assert_eq!(next_pending_chunk(&artifacts, 5), Some((1, 3, 5)));
+ }
+
+ #[test]
+ fn given_short_tail_when_next_chunk_requested_should_clamp_to_remaining() {
+ let mut artifacts = vec![progress(0, 8)];
+ artifacts[0].buf = vec![0; 6];
+
+ assert_eq!(next_pending_chunk(&artifacts, 64), Some((0, 6, 2)));
+ }
+
+ #[test]
+ fn given_complete_or_empty_manifest_when_next_chunk_requested_should_yield_none() {
+ assert_eq!(next_pending_chunk::<ArtifactProgress>(&[], 64), None);
+ let mut artifacts = vec![progress(0, 2)];
+ artifacts[0].buf = vec![0; 2];
+ assert_eq!(next_pending_chunk(&artifacts, 64), None);
+ }
+
+ #[test]
+ fn given_zero_length_artifact_when_scanned_should_read_complete() {
+ // A zero-length artifact must never be asked for: `start >= len` is
+ // the empty-chunk exchange the serving side refuses.
+ let artifacts = vec![progress(0, 0), progress(1, 1)];
+ assert_eq!(next_pending_chunk(&artifacts, 64), Some((1, 0, 1)));
+ }
+
+ #[test]
+ fn given_sequential_chunks_when_appended_should_accumulate() {
+ let mut artifacts = vec![progress(0, 4)];
+ assert!(append_chunk(&mut artifacts, 0, 0, b"ab"));
+ assert!(append_chunk(&mut artifacts, 0, 2, b"cd"));
+ assert!(artifacts[0].complete());
+ }
+
+ #[test]
+ fn given_bad_frames_when_appended_should_drop_without_side_effects() {
+ let mut artifacts = vec![progress(0, 4)];
+ assert!(append_chunk(&mut artifacts, 0, 0, b"ab"));
+
+ assert!(!append_chunk(&mut artifacts, 1, 0, b"xx"), "index OOB");
+ assert!(!append_chunk(&mut artifacts, 0, 0, b"xx"), "stale offset");
+ assert!(!append_chunk(&mut artifacts, 0, 3, b"xx"), "future offset");
+ assert!(!append_chunk(&mut artifacts, 0, 2, b"xyz"), "overrun");
+ assert!(!append_chunk(&mut artifacts, 0, 2, b""), "empty payload");
+ assert_eq!(artifacts[0].buf, b"ab", "rejected frames must not mutate");
+ }
+
+ #[test]
+ fn given_artifact_bytes_when_verified_should_match_len_and_checksum() {
+ let bytes = b"state transfer artifact".to_vec();
+ let entry = StateArtifact::for_bytes(2, 7, &bytes);
+ assert!(verify_state_artifact(&entry, &bytes));
+
+ let mut flipped = bytes.clone();
+ flipped[0] ^= 1;
+ assert!(!verify_state_artifact(&entry, &flipped));
+ assert!(!verify_state_artifact(&entry, &bytes[1..]));
+ }
+}
diff --git a/core/consensus/src/vsr_state.rs b/core/consensus/src/vsr_state.rs
index 6523f38..b9e2701 100644
--- a/core/consensus/src/vsr_state.rs
+++ b/core/consensus/src/vsr_state.rs
@@ -28,11 +28,23 @@
use std::fmt;
-/// Number of bytes [`VsrState::to_bytes`] produces and [`VsrState::try_from`]
-/// expects: `cluster`(16) + `replica_id`(1) + `replica_count`(1) + `view`(4)
-/// + `log_view`(4) + `commit_max`(8) + `checkpoint_op`(8)
-/// + `checkpoint_checksum`(16).
-pub const ENCODED_LEN: usize = 58;
+/// Number of bytes [`VsrState::to_bytes`] produces: `cluster`(16) +
+/// `replica_id`(1) + `replica_count`(1) + `view`(4) + `log_view`(4) +
+/// `commit_max`(8) + `checkpoint_op`(8) + `checkpoint_checksum`(16) +
+/// `offset_frontier`(8).
+pub const ENCODED_LEN: usize = 66;
+
+/// The layout before `offset_frontier` was appended.
+///
+/// [`VsrState::try_from`] still accepts records of this length and zero-fills
+/// the new field. Without it every superblock already on disk -- the metadata
+/// plane writes one on every view change and checkpoint, single-node included --
+/// would decode as [`VsrStateError::WrongLength`] and refuse boot as a
+/// durability violation. A version bump instead of this would not help on its
+/// own: `classify` compares the version for exact equality, so a v2 build turns
+/// every v1 record into `Unreadable`, which is the same refusal wearing a
+/// different name.
+pub const ENCODED_LEN_WITHOUT_FRONTIER: usize = 58;
/// The durable consensus state of one replica for one consensus group.
///
@@ -69,6 +81,22 @@
/// Integrity tag of the paired checkpoint, detecting a torn
/// snapshot/superblock pairing across a crash.
pub checkpoint_checksum: u128,
+ /// PARTITION plane: the next message offset this replica will mint, or `0`
+ /// for a group whose offset space is still empty.
+ ///
+ /// A durable LOWER BOUND, not a completeness claim: boot takes the max of
+ /// this and whatever the recovered segments prove. It exists because
+ /// nothing else durably names the frontier once the segments that carried
+ /// it are gone -- a state-transfer install of an all-GC'd origin, a crash
+ /// inside the install's swap window, and the fence-and-rebuild path all
+ /// leave a replica whose counter would otherwise restart at 0 while the
+ /// group is at N. That is not a lag: replicas re-stamp `base_offset` from
+ /// this counter and recompute `batch_checksum` over it, so the next
+ /// replicated prepare would persist different bytes here than on every
+ /// peer, silently.
+ ///
+ /// Always `0` on the metadata plane, which mints no message offsets.
+ pub offset_frontier: u64,
}
impl VsrState {
@@ -84,6 +112,7 @@
out[26..34].copy_from_slice(&self.commit_max.to_le_bytes());
out[34..42].copy_from_slice(&self.checkpoint_op.to_le_bytes());
out[42..58].copy_from_slice(&self.checkpoint_checksum.to_le_bytes());
+ out[58..66].copy_from_slice(&self.offset_frontier.to_le_bytes());
out
}
}
@@ -92,13 +121,25 @@
type Error = VsrStateError;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
- // One length check up front puts every field slice below in bounds by
- // construction, so the `try_into`s cannot fail.
- let bytes: &[u8; ENCODED_LEN] =
- bytes.try_into().map_err(|_| VsrStateError::WrongLength {
- expected: ENCODED_LEN,
- actual: bytes.len(),
- })?;
+ // Length-tolerant: a pre-`offset_frontier` record is padded out and the
+ // new field reads as 0, which is exactly "no recorded frontier" (the
+ // read sites filter it). One length check up front then puts every
+ // field slice below in bounds by construction, so the `try_into`s
+ // cannot fail.
+ let mut padded = [0u8; ENCODED_LEN];
+ match bytes.len() {
+ ENCODED_LEN => padded.copy_from_slice(bytes),
+ ENCODED_LEN_WITHOUT_FRONTIER => {
+ padded[..ENCODED_LEN_WITHOUT_FRONTIER].copy_from_slice(bytes);
+ }
+ actual => {
+ return Err(VsrStateError::WrongLength {
+ expected: ENCODED_LEN,
+ actual,
+ });
+ }
+ }
+ let bytes = &padded;
let state = Self {
cluster: u128::from_le_bytes(field(bytes, 0)),
replica_id: bytes[16],
@@ -108,6 +149,7 @@
commit_max: u64::from_le_bytes(field(bytes, 26)),
checkpoint_op: u64::from_le_bytes(field(bytes, 34)),
checkpoint_checksum: u128::from_le_bytes(field(bytes, 42)),
+ offset_frontier: u64::from_le_bytes(field(bytes, 58)),
};
// A record violating `log_view <= view` decodes into a replica that looks
// healthy locally while `DoViewChangeHeader::validate` makes every peer drop
@@ -146,7 +188,11 @@
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WrongLength { expected, actual } => {
- write!(f, "VsrState needs {expected} bytes, got {actual}")
+ write!(
+ f,
+ "VsrState needs {expected} bytes (or {ENCODED_LEN_WITHOUT_FRONTIER}, \
+ the layout before the offset frontier), got {actual}"
+ )
}
Self::LogViewAheadOfView { view, log_view } => write!(
f,
@@ -178,6 +224,7 @@
commit_max: 6,
checkpoint_op: 7,
checkpoint_checksum: 8,
+ offset_frontier: 0,
};
let bytes = state.to_bytes();
assert_eq!(bytes.len(), ENCODED_LEN);
@@ -194,6 +241,41 @@
assert!(VsrState::try_from(&bytes[..ENCODED_LEN - 1]).is_err());
}
+ /// A superblock written before `offset_frontier` existed must still decode:
+ /// the metadata plane writes one on every view change, so an exact-length
+ /// decode turns an in-place upgrade into a boot refusal on every deployment
+ /// that ever ran.
+ #[test]
+ fn given_pre_frontier_record_when_decoded_should_accept_and_zero_fill() {
+ let full = VsrState {
+ cluster: 3,
+ replica_id: 1,
+ replica_count: 3,
+ view: 9,
+ log_view: 8,
+ commit_max: 41,
+ checkpoint_op: 7,
+ checkpoint_checksum: 5,
+ offset_frontier: 77,
+ }
+ .to_bytes();
+
+ let legacy = &full[..ENCODED_LEN_WITHOUT_FRONTIER];
+ let decoded = VsrState::try_from(legacy).expect("a pre-frontier record must decode");
+ assert_eq!(decoded.offset_frontier, 0, "the new field zero-fills");
+ assert_eq!(decoded.view, 9);
+ assert_eq!(decoded.log_view, 8);
+ assert_eq!(decoded.commit_max, 41);
+ assert_eq!(decoded.checkpoint_op, 7);
+ assert_eq!(decoded.checkpoint_checksum, 5);
+
+ // Anything that is neither layout is still refused.
+ assert!(matches!(
+ VsrState::try_from(&full[..40]),
+ Err(VsrStateError::WrongLength { .. })
+ ));
+ }
+
#[test]
fn given_log_view_past_view_when_decoded_should_reject() {
// Corruption inside the checksummed region can produce a length-valid record
@@ -209,8 +291,12 @@
commit_max: 0,
checkpoint_op: 0,
checkpoint_checksum: 0,
+ // Distinct and nonzero: with 0 here a transposed write over the
+ // trailing field would still satisfy every assertion below.
+ offset_frontier: 9,
}
.to_bytes();
+ assert_eq!(bytes[58], 9, "offset_frontier must occupy bytes 58..66");
bytes[22] = 5; // log_view = 5, view stays 4
assert_eq!(
diff --git a/core/integration/src/bench_utils.rs b/core/integration/src/bench_utils.rs
index 0262420..de2de07 100644
--- a/core/integration/src/bench_utils.rs
+++ b/core/integration/src/bench_utils.rs
@@ -20,10 +20,10 @@
use iggy::prelude::*;
use iggy_common::TransportProtocol;
use std::{
- fs::{self, File, OpenOptions},
- io::Write,
+ fs::{self, File},
process::{Command, Stdio},
thread::{self, panicking},
+ time::{Duration, Instant},
};
use uuid::Uuid;
@@ -31,6 +31,14 @@
const MESSAGE_BATCHES: u64 = 100;
const MESSAGES_PER_BATCH: u64 = 100;
const DEFAULT_NUMBER_OF_STREAMS: u64 = 8;
+// Generous for a few MB of traffic even in debug builds, and deliberately
+// UNDER nextest's harness timeout (`.config/nextest.toml` sigkills at
+// 60s x 5): a longer wait here would never fire, taking the capture dump and
+// the `--features vsr` hint below with it. Exists because a protocol mismatch
+// (an SDK framing the server does not speak, e.g. a default-features
+// iggy-bench against a vsr cluster) hangs both sides silently instead of
+// erroring.
+const BENCH_WAIT_TIMEOUT: Duration = Duration::from_secs(240);
pub fn run_bench_and_wait_for_finish(
server_addr: &str,
@@ -102,48 +110,40 @@
}
let mut child = command.spawn().unwrap();
- let result = child.wait().unwrap();
-
- // Cleanup
- if let Ok(output) = child.wait_with_output() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- let stdout = String::from_utf8_lossy(&output.stdout);
- if let Some(stderr_file_path) = &stderr_file_path {
- OpenOptions::new()
- .append(true)
- .create(true)
- .open(stderr_file_path)
- .unwrap()
- .write_all(stderr.as_bytes())
- .unwrap();
+ let deadline = Instant::now() + BENCH_WAIT_TIMEOUT;
+ // A timeout does NOT panic here: doing so jumped over the capture dump and
+ // the temp-file cleanup below, so every timed-out run leaked both files and
+ // printed only stderr -- while iggy-bench writes its progress to stdout,
+ // the one capture that explains a hang. The verdict is the assert at the end.
+ let mut timed_out = false;
+ let status = loop {
+ match child.try_wait().unwrap() {
+ Some(status) => break Some(status),
+ None if Instant::now() >= deadline => {
+ let _ = child.kill();
+ let _ = child.wait();
+ timed_out = true;
+ break None;
+ }
+ None => thread::sleep(Duration::from_millis(200)),
}
+ };
- if let Some(stdout_file_path) = &stdout_file_path {
- OpenOptions::new()
- .append(true)
- .create(true)
- .open(stdout_file_path)
- .unwrap()
- .write_all(stdout.as_bytes())
- .unwrap();
- }
- } else {
- panic!("Failed to get output from iggy-bench");
- }
-
- if panicking() {
- if let Some(stdout_file_path) = &stdout_file_path {
- eprintln!(
- "Iggy bench stdout:\n{}",
- fs::read_to_string(stdout_file_path).unwrap()
- );
- }
-
- if let Some(stderr_file_path) = &stderr_file_path {
- eprintln!(
- "Iggy bench stderr:\n{}",
- fs::read_to_string(stderr_file_path).unwrap()
- );
+ // Nothing to drain, by construction: both branches above redirect the
+ // child's stdout and stderr -- to files, or inherited under
+ // `IGGY_TEST_VERBOSE` -- so no pipe exists for the poll loop to deadlock
+ // against. The old `wait_with_output` capture here could only ever return
+ // empty buffers for the same reason; the captures the failure path prints
+ // are the redirect FILES.
+ let failed = timed_out || status.is_none_or(|status| !status.success());
+ if failed || panicking() {
+ for (stream, path) in [("stdout", &stdout_file_path), ("stderr", &stderr_file_path)] {
+ if let Some(path) = path {
+ eprintln!(
+ "Iggy bench {stream}:\n{}",
+ fs::read_to_string(path).unwrap_or_default()
+ );
+ }
}
}
@@ -154,7 +154,13 @@
fs::remove_file(stderr_file_path).unwrap();
}
- assert!(result.success());
+ assert!(
+ !timed_out,
+ "iggy-bench did not finish within {BENCH_WAIT_TIMEOUT:?}; if the server \
+ runs in vsr mode, make sure iggy-bench was built with --features vsr \
+ (the SDK framing is chosen at compile time)"
+ );
+ assert!(status.is_some_and(|status| status.success()));
}
pub fn get_random_path() -> String {
diff --git a/core/integration/src/harness/orchestrator/harness.rs b/core/integration/src/harness/orchestrator/harness.rs
index 7f14cc4..4a66157 100644
--- a/core/integration/src/harness/orchestrator/harness.rs
+++ b/core/integration/src/harness/orchestrator/harness.rs
@@ -330,6 +330,20 @@
Ok(())
}
+ /// Restart node `index` with its data directory INTACT, so it rejoins the
+ /// still-live cluster from its own recovered state (superblock, segments,
+ /// offset files). The counterpart of
+ /// [`Self::restart_node_from_clean_slate`] for the crash-and-return
+ /// shape rather than the provisioned-replacement one.
+ pub fn restart_node(&mut self, index: usize) -> Result<(), TestBinaryError> {
+ let server = self
+ .servers
+ .get_mut(index)
+ .ok_or(TestBinaryError::MissingServer)?;
+ server.stop()?;
+ server.start()
+ }
+
/// Restart node `index` with its data directory wiped, so it rejoins the
/// still-live cluster as a fresh replica with no local history. The other
/// nodes stay up throughout (they hold quorum and keep committing), which
@@ -504,6 +518,23 @@
.await
}
+ /// Root-authenticated TCP client bound to ONE node of a cluster, unlike
+ /// [`Self::root_client_for`], which always targets node 0.
+ ///
+ /// # Errors
+ ///
+ /// [`TestBinaryError::MissingServer`] when `index` is out of range, or the
+ /// underlying connect/login failure.
+ pub async fn root_client_for_node(&self, index: usize) -> Result<IggyClient, TestBinaryError> {
+ self.servers
+ .get(index)
+ .ok_or(TestBinaryError::MissingServer)?
+ .tcp_client()?
+ .with_root_login()
+ .connect()
+ .await
+ }
+
/// Create a new client logged in as root for the specified transport.
pub fn client_builder_for(
&self,
diff --git a/core/integration/tests/cluster/mod.rs b/core/integration/tests/cluster/mod.rs
index 8e5f267..673d9a7 100644
--- a/core/integration/tests/cluster/mod.rs
+++ b/core/integration/tests/cluster/mod.rs
@@ -19,3 +19,4 @@
mod metadata_checkpoint_restart;
mod metadata_state_transfer;
mod multi_shard_partition_convergence;
+mod partition_state_transfer;
diff --git a/core/integration/tests/cluster/partition_state_transfer.rs b/core/integration/tests/cluster/partition_state_transfer.rs
new file mode 100644
index 0000000..0d36479
--- /dev/null
+++ b/core/integration/tests/cluster/partition_state_transfer.rs
@@ -0,0 +1,599 @@
+// 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.
+
+//! Partition-plane state transfer: a rejoining replica whose journal repair
+//! cannot close the gap (the peer's evicted ring moved past it) pulls the
+//! partition's retained segments + consumer offsets from the caught-up
+//! primary, installs them, and hands the live tail to ordinary repair.
+//!
+//! Forcing function: `messages_required_to_save = 1` flushes (and ring-
+//! evicts) every committed batch, and `evicted_ring_capacity = 64` keeps the
+//! repair window shallow, so ~200 produced batches push `repair_retained_from`
+//! far past a rejoiner's durable end. Its gap-fill repair then gets
+//! `RangeEvicted`, the repaired window cannot connect to recovered state,
+//! and `complete_repair` returns the `FloorRefused` conversion trigger.
+
+#![cfg(feature = "vsr")]
+
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+
+use iggy::prelude::*;
+use integration::harness::TestHarness;
+use integration::iggy_harness;
+use tokio::time::sleep;
+
+const STREAM_NAME: &str = "partition-transfer-stream";
+const TOPIC_NAME: &str = "partition-transfer-topic";
+/// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+/// Enough batches to push the evicted ring (capacity 64) well past the
+/// window a rejoiner could repair from op 1.
+const MESSAGES_COUNT: u32 = 200;
+const STORED_CONSUMER_OFFSET: u64 = 17;
+
+/// Dead-peer spec sizing: enough 256 KiB payloads (64 MiB total) that the
+/// transfer spans hundreds of 256 KiB chunk round-trips, keeping the pull
+/// in flight long enough for a marker-gated kill to land mid-transfer (a
+/// 16 MiB pull completed in ~120ms on a release build, inside one marker
+/// poll). Also enough commits (> ring capacity 64) that the fresh
+/// rejoiner's floor refuses.
+const BULKY_MESSAGES_COUNT: u32 = 256;
+const BULKY_PAYLOAD_LEN: usize = 256 * 1024;
+/// Poll for the kill gate: the serving marker appears at descriptor-serve
+/// time and the kill must land within the pull, so the ordinary 200ms
+/// cadence is too coarse here.
+const KILL_GATE_POLL: Duration = Duration::from_millis(10);
+
+const CONVERSION_MARKER: &str = "partition repair floor unreachable; converting to state transfer";
+const INSTALL_MARKER: &str = "partition state transfer installed";
+/// Logged once per transfer, at the last byte of the last artifact. The
+/// descriptor-time "serving" line only proves a request ARRIVED; this proves the
+/// pull ran.
+const FULLY_SERVED_MARKER: &str = "partition state transfer fully served";
+const ABANDON_MARKER: &str =
+ "partition state transfer stalled past its retry budget; abandoning with a backed-off re-arm";
+
+/// Transfer end-to-end: adoption, repair round-trip, conversion, chunk pull,
+/// install, tail repair. CI runners are slow; bound without hanging the suite.
+const TRANSFER_BUDGET: Duration = Duration::from_secs(60);
+const MARKER_POLL: Duration = Duration::from_millis(200);
+
+#[iggy_harness(
+ cluster_nodes = 3,
+ server(
+ system.sharding.cpu_allocation = "0..1",
+ partition.evicted_ring_capacity = "64",
+ system.partition.messages_required_to_save = "1"
+ )
+)]
+async fn given_evicted_ring_when_fresh_node_joins_late_should_state_transfer_partition(
+ harness: &mut TestHarness,
+) {
+ let client = harness
+ .root_client_for_node(0)
+ .await
+ .expect("connect a root client to the node");
+ seed_partition(&client).await;
+ client
+ .store_consumer_offset(
+ &Consumer::default(),
+ &Identifier::named(STREAM_NAME).expect("stream identifier"),
+ &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+ Some(PARTITION_ID),
+ STORED_CONSUMER_OFFSET,
+ )
+ .await
+ .expect("store a consumer offset before the wipe");
+ sleep(Duration::from_secs(1)).await;
+ // Deliberately NOT dropped before the wipe: a disconnect commits a
+ // Logout, and a fresh (never-checkpointed) metadata rejoin currently
+ // wedges repairing session-scoped ops. Keeping the seed session alive
+ // caps the metadata window at ops a fresh joiner provably replays, so
+ // this spec isolates the PARTITION plane.
+ let _seed_client = client;
+
+ // Wipe node 2 and rejoin: no local history at all, so repair cannot
+ // connect any floor and the refusal converts to a transfer.
+ harness
+ .restart_node_from_clean_slate(2)
+ .expect("clean-slate restart of node 2");
+
+ await_marker(harness, 2, CONVERSION_MARKER).await;
+ await_marker(harness, 2, INSTALL_MARKER).await;
+
+ // Disk proof on the rejoined node: transferred segment bytes and a
+ // persisted consumer-offset file (a single LE u64).
+ let data_path = harness.node(2).data_path();
+ // Each transferred batch is at least its 256-byte header; anything below
+ // this floor is a truncated install, not the seeded 200 batches.
+ let transferred_floor = u64::from(MESSAGES_COUNT) * 256;
+ let deadline = Instant::now() + TRANSFER_BUDGET;
+ loop {
+ if total_partition_log_bytes(&data_path) >= transferred_floor {
+ break;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "node 2 never materialized the transferred segment bytes \
+ (expected at least {transferred_floor})"
+ );
+ sleep(MARKER_POLL).await;
+ }
+ let offsets_file = find_consumer_offset_file(&data_path)
+ .expect("transferred consumer offset file exists on node 2");
+ let bytes = std::fs::read(&offsets_file).expect("read transferred consumer offset");
+ assert_eq!(
+ u64::from_le_bytes(bytes.as_slice().try_into().expect("offset file is one u64")),
+ STORED_CONSUMER_OFFSET,
+ "the stored consumer offset must survive the transfer"
+ );
+
+ // Functional capstone: with node 1 down, quorum is node 0 + the
+ // transferred node 2, so one more produce+poll round-trip cannot commit
+ // unless node 2 PrepareOks from its transferred state.
+ harness.stop_node(1).expect("stop node 1");
+ let deadline = Instant::now() + TRANSFER_BUDGET;
+ loop {
+ if let Some(client) = connect_any(harness, &[2, 0]).await {
+ let mut extra = vec![IggyMessage::from_str("post-transfer").expect("message")];
+ if client
+ .send_messages(
+ &Identifier::named(STREAM_NAME).expect("stream identifier"),
+ &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+ &Partitioning::partition_id(PARTITION_ID),
+ &mut extra,
+ )
+ .await
+ .is_ok()
+ && poll_count(&client, MESSAGES_COUNT + 1).await == Ok(MESSAGES_COUNT + 1)
+ {
+ return;
+ }
+ }
+ assert!(
+ Instant::now() < deadline,
+ "the pre-wipe batch plus one post-transfer message never became pollable \
+ with only node 0 and the transferred node 2 alive"
+ );
+ sleep(MARKER_POLL).await;
+ }
+}
+
+#[iggy_harness(
+ cluster_nodes = 3,
+ server(
+ system.sharding.cpu_allocation = "0..1",
+ partition.evicted_ring_capacity = "64",
+ system.partition.messages_required_to_save = "1"
+ )
+)]
+async fn given_evicted_ring_when_node_restarts_with_data_should_state_transfer_partition(
+ harness: &mut TestHarness,
+) {
+ // Node 2 holds a durable prefix, then misses enough traffic that the
+ // survivors' ring moves past its durable end: its repaired window cannot
+ // connect, which is exactly the refusal-site trigger.
+ let client = harness
+ .root_client_for_node(0)
+ .await
+ .expect("connect a root client to the node");
+ seed_topic(&client).await;
+ produce(&client, 40).await;
+ sleep(Duration::from_secs(1)).await;
+ harness.stop_node(2).expect("stop node 2");
+
+ produce(&client, MESSAGES_COUNT).await;
+ let _seed_client = client;
+
+ harness.restart_node(2).expect("restart node 2 with data");
+
+ await_marker(harness, 2, CONVERSION_MARKER).await;
+ await_marker(harness, 2, INSTALL_MARKER).await;
+ // The serving side proves the pull actually ran (not a vacuous install).
+ // Retried like every other marker check here: stdout goes through the
+ // buffered non-blocking appender, so a survivor's line can trail node 2's
+ // install by more than one poll.
+ await_marker_any(harness, &[0, 1], FULLY_SERVED_MARKER).await;
+
+ // The markers only say the machinery ran. Read node 2's OWN segment bytes
+ // and check every produced payload is there, in ascending file order: a
+ // truncated, short or reordered install fails here and passes above.
+ //
+ // Read off disk rather than polled: the SDK is leader-aware and redirects
+ // a poll to the primary, so no client-side read can be pinned to the
+ // rejoined node.
+ // Two produce runs, each numbering its payloads from 0, so the expected
+ // chain is 0..40 followed by 0..MESSAGES_COUNT.
+ let expected: Vec<String> = (0..40)
+ .chain(0..MESSAGES_COUNT)
+ .map(|sequence| format!("message-{sequence}"))
+ .collect();
+ await_installed_payloads(harness, 2, &expected).await;
+}
+
+#[iggy_harness(
+ cluster_nodes = 3,
+ server(
+ system.sharding.cpu_allocation = "0..1",
+ partition.evicted_ring_capacity = "64",
+ system.partition.messages_required_to_save = "1",
+ system.segment.size = "8MiB"
+ )
+)]
+// The 8 MiB segment cap makes the 64 MiB bulky seed span several sealed
+// segments, so unlike the other specs (single-segment under the default 1 GiB
+// cap) this one installs from a MULTI-ARTIFACT manifest, one spill per artifact.
+// The installed segment count at the end is what asserts that. The re-armed
+// pull also runs the staged-segment reuse scan, but how much it can adopt
+// depends on how many artifacts completed before the kill, so nothing here
+// asserts on reuse.
+async fn given_transfer_peer_dies_when_stalled_should_abandon_and_recover_partition(
+ harness: &mut TestHarness,
+) {
+ let client = harness
+ .root_client_for_node(0)
+ .await
+ .expect("connect a root client to the node");
+ seed_topic(&client).await;
+ // Bulky payloads so the pull spans many 256 KiB chunks: the kill below
+ // must land while the transfer is provably in flight, and a small
+ // partition finishes inside the marker-poll latency, leaving the
+ // abandon path untested.
+ produce_bulky(&client, BULKY_MESSAGES_COUNT, BULKY_PAYLOAD_LEN).await;
+ sleep(Duration::from_secs(1)).await;
+ let _seed_client = client;
+
+ // Wipe node 2, wait until its rejoin CONVERTED to a transfer and the
+ // serving peer (the view-0 primary, node 0) proved it started serving
+ // the pull, then kill that peer mid-pull. Node 2 must not retry into
+ // the corpse forever: the stall budget abandons with a backed-off
+ // re-arm against the next peer, the survivors elect past node 0, and
+ // the transfer re-runs against the new primary (node 1).
+ harness
+ .restart_node_from_clean_slate(2)
+ .expect("clean-slate restart of node 2");
+ // Kill node 0 the moment node 2 CONVERTED: the transfer is then armed
+ // at node 0 but the 64 MiB pull cannot possibly finish inside the kill
+ // latency, so node 2 deterministically ends up stalling against a dead
+ // peer -- whether the descriptor made it out or not, both funnels land
+ // in the stall budget. (Gating on the serving marker instead raced the
+ // pull itself: a release-build pull finishes in a few hundred ms.)
+ let deadline = Instant::now() + TRANSFER_BUDGET;
+ while !harness.node(2).stdout_contains(CONVERSION_MARKER) {
+ assert!(
+ Instant::now() < deadline,
+ "node 2 never converted its refused repair floor to a transfer"
+ );
+ sleep(KILL_GATE_POLL).await;
+ }
+ // Baselines BEFORE the kill. Every marker check below counts occurrences
+ // against these instead of scanning the whole accumulated log: node 2 can
+ // have installed and node 1 can have fully served an earlier attempt while
+ // node 0 was still up, and a `contains` would call those the recovery.
+ let installs_before_kill = harness.node(2).stdout_occurrences(INSTALL_MARKER);
+ let served_before_kill = harness.node(1).stdout_occurrences(FULLY_SERVED_MARKER);
+ let abandons_before_kill = harness.node(2).stdout_occurrences(ABANDON_MARKER);
+ harness
+ .stop_node(0)
+ .expect("stop the serving peer (node 0)");
+
+ // The abandon is now deterministic: the pull was in flight against a
+ // peer that is gone, so the stall budget must exhaust.
+ await_new_marker(harness, 2, ABANDON_MARKER, abandons_before_kill).await;
+
+ // Recovery: the scheduled re-arm targets the surviving primary. No
+ // follow-up commit is asserted -- the cluster is quorum-marginal with
+ // one node down, and an unanswered read mid-election is not a verdict.
+ //
+ // Node 1 is named explicitly, not "any survivor": with node 0 dead it is
+ // the only replica left that can serve, so a marker from it is proof the
+ // re-arm found a new peer rather than proof of the pre-kill attempt.
+ await_new_marker(harness, 2, INSTALL_MARKER, installs_before_kill).await;
+ await_new_marker(harness, 1, FULLY_SERVED_MARKER, served_before_kill).await;
+
+ // The manifest the install consumed carried one artifact per sealed
+ // segment, and each was spilled and renamed separately: the seeded 64 MiB
+ // over an 8 MiB segment cap cannot land as one file.
+ let data_path = harness.node(2).data_path();
+ let deadline = Instant::now() + TRANSFER_BUDGET;
+ loop {
+ let installed = segment_log_count(&data_path);
+ if installed > 1 {
+ return;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "node 2 installed {installed} segment file(s); a 64 MiB transfer under an \
+ 8 MiB segment cap must arrive as a multi-artifact manifest"
+ );
+ sleep(MARKER_POLL).await;
+ }
+}
+
+async fn connect_any(harness: &TestHarness, nodes: &[usize]) -> Option<IggyClient> {
+ for &node in nodes {
+ if let Ok(builder) = harness.node(node).tcp_client()
+ && let Ok(client) = builder.with_root_login().connect().await
+ {
+ return Some(client);
+ }
+ }
+ None
+}
+
+async fn seed_topic(client: &IggyClient) {
+ client
+ .create_stream(STREAM_NAME)
+ .await
+ .expect("create stream");
+ client
+ .create_topic(
+ &Identifier::named(STREAM_NAME).expect("stream identifier"),
+ TOPIC_NAME,
+ 1,
+ CompressionAlgorithm::None,
+ None,
+ IggyExpiry::NeverExpire,
+ MaxTopicSize::ServerDefault,
+ )
+ .await
+ .expect("create topic with one partition");
+}
+
+async fn produce(client: &IggyClient, count: u32) {
+ // One message per send: each commit flushes (messages_required_to_save=1)
+ // and ring-evicts, which is what marches `repair_retained_from` forward.
+ for sequence in 0..count {
+ let mut messages =
+ vec![IggyMessage::from_str(&format!("message-{sequence}")).expect("message")];
+ client
+ .send_messages(
+ &Identifier::named(STREAM_NAME).expect("stream identifier"),
+ &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+ &Partitioning::partition_id(PARTITION_ID),
+ &mut messages,
+ )
+ .await
+ .expect("send message");
+ }
+}
+
+/// Like [`produce`], one commit per send, but with a payload of
+/// `payload_len` filler bytes so the on-disk segment grows fast.
+async fn produce_bulky(client: &IggyClient, count: u32, payload_len: usize) {
+ for sequence in 0..count {
+ let payload = format!("bulky-{sequence}-{}", "x".repeat(payload_len));
+ let mut messages = vec![IggyMessage::from_str(&payload).expect("message")];
+ client
+ .send_messages(
+ &Identifier::named(STREAM_NAME).expect("stream identifier"),
+ &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+ &Partitioning::partition_id(PARTITION_ID),
+ &mut messages,
+ )
+ .await
+ .expect("send bulky message");
+ }
+}
+
+async fn seed_partition(client: &IggyClient) {
+ seed_topic(client).await;
+ produce(client, MESSAGES_COUNT).await;
+ assert_eq!(
+ poll_count(client, MESSAGES_COUNT).await,
+ Ok(MESSAGES_COUNT),
+ "the seed batch must commit before the fault is injected"
+ );
+}
+
+async fn poll_count(client: &IggyClient, count: u32) -> Result<u32, IggyError> {
+ let polled = client
+ .poll_messages(
+ &Identifier::named(STREAM_NAME).expect("stream identifier"),
+ &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+ Some(PARTITION_ID),
+ &Consumer::default(),
+ &PollingStrategy::offset(0),
+ count,
+ false,
+ )
+ .await?;
+ #[allow(clippy::cast_possible_truncation)]
+ Ok(polled.messages.len() as u32)
+}
+
+/// Polls node `node`'s installed segment files until every payload in
+/// `expected` is present, in the order given.
+///
+/// The payloads are the oracle the markers are not: a short install is missing
+/// the tail, a torn one is missing a middle, and a reordered one fails the
+/// ascending-position check.
+async fn await_installed_payloads(harness: &TestHarness, node: usize, expected: &[String]) {
+ let data_path = harness.node(node).data_path();
+ let deadline = Instant::now() + TRANSFER_BUDGET;
+ loop {
+ if let Err(missing) = installed_payloads_complete(&data_path, expected) {
+ assert!(
+ Instant::now() < deadline,
+ "node {node}'s installed segments never held the whole produced batch: {missing}"
+ );
+ sleep(MARKER_POLL).await;
+ continue;
+ }
+ return;
+ }
+}
+
+/// `Ok(())` when every payload in `expected` appears in node-local segment
+/// bytes at a non-decreasing position, otherwise the first discrepancy.
+fn installed_payloads_complete(data_path: &Path, expected: &[String]) -> Result<(), String> {
+ let mut chain = Vec::new();
+ let mut paths = Vec::new();
+ let _ = walk(data_path, &mut |path| {
+ if is_segment_log(path) {
+ paths.push(path.to_path_buf());
+ }
+ false
+ });
+ // Segment files are named for their zero-padded base offset, so lexical
+ // order is offset order.
+ paths.sort();
+ for path in paths {
+ let Ok(bytes) = std::fs::read(&path) else {
+ return Err(format!("{} could not be read", path.display()));
+ };
+ chain.extend_from_slice(&bytes);
+ }
+ let mut searched_from = 0;
+ for payload in expected {
+ let found = chain[searched_from..]
+ .windows(payload.len())
+ .position(|window| window == payload.as_bytes())
+ // A bare find would match `message-1` inside `message-10`.
+ .map(|offset| searched_from + offset)
+ .filter(|start| {
+ chain
+ .get(start + payload.len())
+ .is_none_or(|byte| !byte.is_ascii_digit())
+ });
+ let Some(start) = found else {
+ return Err(format!(
+ "{payload:?} is absent from the {} installed bytes after position {searched_from}",
+ chain.len()
+ ));
+ };
+ searched_from = start;
+ }
+ Ok(())
+}
+
+/// [`await_marker`], but satisfied only by an occurrence beyond `baseline` -
+/// the whole-log scan cannot distinguish a line from before the fault.
+async fn await_new_marker(harness: &TestHarness, node: usize, marker: &str, baseline: usize) {
+ let deadline = Instant::now() + TRANSFER_BUDGET;
+ while harness.node(node).stdout_occurrences(marker) <= baseline {
+ assert!(
+ Instant::now() < deadline,
+ "node {node} never logged {marker:?} again after the fault \
+ (still at the pre-fault count of {baseline}) within {TRANSFER_BUDGET:?}"
+ );
+ sleep(MARKER_POLL).await;
+ }
+}
+
+/// [`await_marker`] over a set of nodes: satisfied by the first one to log it.
+async fn await_marker_any(harness: &TestHarness, nodes: &[usize], marker: &str) {
+ let deadline = Instant::now() + TRANSFER_BUDGET;
+ while !nodes
+ .iter()
+ .any(|&node| harness.node(node).stdout_contains(marker))
+ {
+ assert!(
+ Instant::now() < deadline,
+ "none of {nodes:?} logged {marker:?} within {TRANSFER_BUDGET:?}"
+ );
+ sleep(MARKER_POLL).await;
+ }
+}
+
+async fn await_marker(harness: &TestHarness, node: usize, marker: &str) {
+ let deadline = Instant::now() + TRANSFER_BUDGET;
+ while !harness.node(node).stdout_contains(marker) {
+ assert!(
+ Instant::now() < deadline,
+ "node {node} never logged {marker:?} within {TRANSFER_BUDGET:?}"
+ );
+ sleep(MARKER_POLL).await;
+ }
+}
+
+/// Total transferred segment payload under `root`. Walked rather than
+/// path-derived so the test does not hard-code the
+/// `streams/<s>/topics/<t>/partitions/<p>` layout.
+///
+/// Matches the segment file NAME shape, not the `.log` extension alone: the
+/// server's own text log sits under the same data root (~15 KiB on a local run,
+/// about a third of the floor this feeds), and the child inherits `RUST_LOG`, so
+/// an extension-only sum let a debug run satisfy the caller with zero
+/// transferred segment bytes.
+fn total_partition_log_bytes(root: &Path) -> u64 {
+ let mut total = 0;
+ let _ = walk(root, &mut |path| {
+ if is_segment_log(path)
+ && let Ok(metadata) = std::fs::metadata(path)
+ {
+ total += metadata.len();
+ }
+ false
+ });
+ total
+}
+
+/// Number of segment files under `root`; more than one proves the install came
+/// from a multi-artifact manifest, each artifact spilled and renamed in turn.
+fn segment_log_count(root: &Path) -> usize {
+ let mut count = 0;
+ let _ = walk(root, &mut |path| {
+ if is_segment_log(path) {
+ count += 1;
+ }
+ false
+ });
+ count
+}
+
+/// A segment `.log`, named for its 20-digit zero-padded base offset (see
+/// `partitions::state_transfer`'s path builders).
+fn is_segment_log(path: &Path) -> bool {
+ path.extension().is_some_and(|extension| extension == "log")
+ && path
+ .file_stem()
+ .and_then(|stem| stem.to_str())
+ .is_some_and(|stem| stem.len() == 20 && stem.bytes().all(|byte| byte.is_ascii_digit()))
+}
+
+fn find_consumer_offset_file(root: &Path) -> Option<PathBuf> {
+ walk(root, &mut |path| {
+ path.parent()
+ .and_then(Path::file_name)
+ .is_some_and(|name| name == "consumers")
+ && std::fs::metadata(path).is_ok_and(|metadata| metadata.len() == 8)
+ })
+}
+
+fn walk(root: &Path, matches: &mut dyn FnMut(&Path) -> bool) -> Option<PathBuf> {
+ let mut pending = vec![root.to_path_buf()];
+ while let Some(dir) = pending.pop() {
+ if dir.file_name().is_some_and(|name| name == "metadata") {
+ continue;
+ }
+ let Ok(entries) = std::fs::read_dir(&dir) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ pending.push(path);
+ } else if matches(&path) {
+ return Some(path);
+ }
+ }
+ }
+ None
+}
diff --git a/core/integration/tests/data_integrity/verify_after_server_restart.rs b/core/integration/tests/data_integrity/verify_after_server_restart.rs
index 9d75af2..71a6164 100644
--- a/core/integration/tests/data_integrity/verify_after_server_restart.rs
+++ b/core/integration/tests/data_integrity/verify_after_server_restart.rs
@@ -16,65 +16,58 @@
// under the License.
use iggy::prelude::*;
-#[cfg(not(feature = "vsr"))]
use integration::bench_utils::run_bench_and_wait_for_finish;
use integration::harness::{TestHarness, TestServerConfig};
use serial_test::parallel;
-#[cfg(not(feature = "vsr"))]
use std::{collections::HashMap, str::FromStr};
-#[cfg(not(feature = "vsr"))]
use test_case::test_matrix;
-#[cfg(not(feature = "vsr"))]
fn cache_open_segment() -> &'static str {
"open_segment"
}
-#[cfg(not(feature = "vsr"))]
fn cache_all() -> &'static str {
"all"
}
-#[cfg(not(feature = "vsr"))]
fn cache_none() -> &'static str {
"none"
}
-#[cfg(not(feature = "vsr"))]
fn build_server_config(cache_setting: &str) -> TestServerConfig {
let mut extra_envs = HashMap::new();
extra_envs.insert(
"IGGY_SYSTEM_SEGMENT_CACHE_INDEXES".to_string(),
cache_setting.to_string(),
);
- // Under vsr this config must ALSO set the eager-flush envs
- // (`IGGY_SYSTEM_PARTITION_MESSAGES_REQUIRED_TO_SAVE=1`,
- // `IGGY_SYSTEM_PARTITION_ENFORCE_FSYNC=true`, see
- // `encryption_scenario::build_server_config`): server-ng serves no
- // `flush_unsaved_buffer` (the command is slated for removal), so the
- // flush calls below must be cfg'd out and replaced by eager persistence
- // when the fill test is ungated.
+ // server-ng flushes on the journal thresholds (no flush primitive), so
+ // force every committed batch straight to disk: the restart asserts
+ // below need everything durable, and the explicit flush calls are
+ // cfg'd out under vsr (`flush_unsaved_buffer` answers
+ // FeatureUnavailable there and is slated for removal). Legacy keeps its
+ // shipped buffered defaults; the flush loops below are its barrier.
+ #[cfg(feature = "vsr")]
+ extra_envs.insert(
+ "IGGY_SYSTEM_PARTITION_MESSAGES_REQUIRED_TO_SAVE".to_string(),
+ "1".to_string(),
+ );
+ #[cfg(feature = "vsr")]
+ extra_envs.insert(
+ "IGGY_SYSTEM_PARTITION_ENFORCE_FSYNC".to_string(),
+ "true".to_string(),
+ );
TestServerConfig::builder().extra_envs(extra_envs).build()
}
// TODO(numminex) - Move the message generation method from benchmark run to a special method.
//
-// vsr-gated: requires PARTITION-plane state transfer. The 5 MB bench fill is
-// thousands of ops while the partition journal's evicted ring retains only
-// the last 4096, so a restarted replica's rejoin window exceeds what journal
-// repair can serve. The commit floor lets recovered segments stand in for
-// the evicted prefix, but the sub-floor stats/offset seeding this test
-// asserts (exact messages_count / size_bytes across the restart) needs the
-// partition-plane transfer to carry them; the metadata-plane transfer's
-// snapshot stats cannot be stitched to the partition repair window without
-// double-counting.
-//
-// NOT gated on `flush_unsaved_buffer`: this test only uses flush as a
-// durability barrier, and under vsr the calls are replaced by the eager-flush
-// server envs (see `build_server_config`). The other blocker to clear when
-// ungating is the bench harness (`run_bench_and_wait_for_finish`), which is
-// out of the vsr test pass today.
-#[cfg(not(feature = "vsr"))]
+// Under vsr this runs against a 3-node cluster and needs two adaptations:
+// the durability barrier is the eager-flush envs in `build_server_config`
+// (`flush_unsaved_buffer` answers FeatureUnavailable there, so the explicit
+// flush loops are cfg'd out), and `iggy-bench` must be built with
+// `--features vsr` because the SDK framing is chosen at compile time. A
+// default-features bench binary never completes a frame against server-ng
+// and the run trips the bench timeout in `run_bench_and_wait_for_finish`.
#[test_matrix(
[cache_all(), cache_open_segment(), cache_none()]
)]
@@ -111,6 +104,9 @@
let client = harness.tcp_root_client().await.unwrap();
let topic_id = Identifier::numeric(0).unwrap();
+ // Durability barrier on the legacy server only; server-ng persists
+ // eagerly via the config envs and answers FeatureUnavailable here.
+ #[cfg(not(feature = "vsr"))]
for i in 0..7 {
let stream_id = Identifier::numeric(i).unwrap();
client
@@ -224,14 +220,17 @@
// Connect and login to server
let client = harness.tcp_root_client().await.unwrap();
- // Flush unsaved buffer
- let topic_id = Identifier::numeric(0).unwrap();
- for i in 0..7 {
- let stream_id = Identifier::numeric(i).unwrap();
- client
- .flush_unsaved_buffer(&stream_id, &topic_id, 0, true)
- .await
- .unwrap();
+ // Durability barrier on the legacy server only (see the first loop).
+ #[cfg(not(feature = "vsr"))]
+ {
+ let topic_id = Identifier::numeric(0).unwrap();
+ for i in 0..7 {
+ let stream_id = Identifier::numeric(i).unwrap();
+ client
+ .flush_unsaved_buffer(&stream_id, &topic_id, 0, true)
+ .await
+ .unwrap();
+ }
}
// Save stats from the second server (should have double the data)
diff --git a/core/integration/tests/sdk/hello_world.rs b/core/integration/tests/sdk/hello_world.rs
index 7d6db41..c7ad212 100644
--- a/core/integration/tests/sdk/hello_world.rs
+++ b/core/integration/tests/sdk/hello_world.rs
@@ -18,7 +18,6 @@
use iggy::prelude::*;
use integration::iggy_harness;
-#[cfg(not(feature = "vsr"))]
#[iggy_harness]
async fn hello_world(harness: &TestHarness) {
let client = harness.root_client().await.unwrap();
diff --git a/core/integration/tests/server/cluster_view_durability_vsr.rs b/core/integration/tests/server/cluster_view_durability_vsr.rs
index d69d673..a2cc801 100644
--- a/core/integration/tests/server/cluster_view_durability_vsr.rs
+++ b/core/integration/tests/server/cluster_view_durability_vsr.rs
@@ -73,7 +73,10 @@
// the primary of view 0 is replica 0). Commit a stream through it, so the
// metadata group has committed state to recover later and exactly one leader
// is visible.
- let client = connect(harness, 0).await;
+ let client = harness
+ .root_client_for_node(0)
+ .await
+ .expect("connect a root client to the node");
client
.create_stream(STREAM_NAME)
.await
@@ -155,18 +158,6 @@
);
}
-/// Connect a root-authenticated TCP client to a specific node.
-async fn connect(harness: &TestHarness, node: usize) -> IggyClient {
- harness
- .node(node)
- .tcp_client()
- .expect("tcp client builder")
- .with_root_login()
- .connect()
- .await
- .unwrap_or_else(|e| panic!("connect to node {node}: {e}"))
-}
-
/// Connect to the first node in `nodes` that accepts a connection, `None` when none
/// do (mid-election, or a node still restarting).
async fn connect_any(harness: &TestHarness, nodes: &[usize]) -> Option<IggyClient> {
diff --git a/core/integration/tests/server/mod.rs b/core/integration/tests/server/mod.rs
index 8c3f6e0..b93b005 100644
--- a/core/integration/tests/server/mod.rs
+++ b/core/integration/tests/server/mod.rs
@@ -48,6 +48,10 @@
// across a replica restart.
#[cfg(feature = "vsr")]
mod cluster_view_durability_vsr;
+// A partition view change must persist the advanced view in that group's own
+// superblock and recover it from disk across a replica restart.
+#[cfg(feature = "vsr")]
+mod partition_view_durability_vsr;
// 80-case race matrix with hardcoded HTTP variants (test_matrix bypasses
// the harness transport filter).
mod concurrent_addition;
diff --git a/core/integration/tests/server/partition_view_durability_vsr.rs b/core/integration/tests/server/partition_view_durability_vsr.rs
new file mode 100644
index 0000000..0071473
--- /dev/null
+++ b/core/integration/tests/server/partition_view_durability_vsr.rs
@@ -0,0 +1,330 @@
+// 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.
+
+//! Partition-plane view durability across a real view change and process restarts,
+//! over the per-partition `PingPongSuperblock` path.
+//!
+//! The partition-plane sibling of `cluster_view_durability_vsr`: every partition
+//! consensus group now records its `(view, log_view)` in a superblock inside its own
+//! partition directory, and every view-scoped send for that group is gated on the
+//! record being durable. This drives a genuine partition view change, crashing the
+//! view-0 primary so the survivors elect a new one, then proves the elected view is
+//! durable on the PARTITION group's own record: it lands in a survivor's on-disk
+//! `VsrState` as `view` / `log_view >= 1` and survives that survivor's own process
+//! restart. Messages committed before the crash still poll back at the end, so the
+//! partition data path rides along.
+//!
+//! vsr-only: partition consensus groups and their superblocks exist only on
+//! `iggy-server-ng`.
+
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+
+use consensus::VsrState;
+use iggy::prelude::*;
+use integration::harness::{TestBinary, TestHarness};
+use integration::iggy_harness;
+use journal::superblock::{SLOT_FILE_NAMES, SuperblockContents, decode_slots};
+use tokio::time::sleep;
+
+const STREAM_NAME: &str = "partition-view-durability-stream";
+const TOPIC_NAME: &str = "partition-view-durability-topic";
+/// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+const MESSAGES_COUNT: u32 = 10;
+
+/// A view change plus a rejoin is not instant (primary timeout, SVC, DVC, StartView,
+/// then the restarted node's probe and repair), and CI runners are slow. 60s bounds
+/// the worst case without hanging the suite.
+const CONVERGE_TIMEOUT: Duration = Duration::from_secs(60);
+/// Boot line reporting the `(view, log_view)` a partition group restored from its
+/// superblock: the recovered replica's own account of what it read back.
+const RESTORED_VIEW_MARKER: &str = "restored partition view from its superblock";
+const POLL_INTERVAL: Duration = Duration::from_millis(250);
+
+#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+async fn given_advanced_partition_view_when_survivor_restarts_should_recover_view_from_superblock(
+ harness: &mut TestHarness,
+) {
+ // Baseline: node 0 is the view-0 primary of every group (its replica id is 0).
+ // Commit a topic with ONE partition and a batch of messages through it, so
+ // exactly one partition consensus group exists and holds committed state.
+ let client = harness
+ .root_client_for_node(0)
+ .await
+ .expect("connect a root client to the node");
+ client
+ .create_stream(STREAM_NAME)
+ .await
+ .expect("create stream on the view-0 primary");
+ let stream_id = Identifier::named(STREAM_NAME).expect("stream identifier");
+ client
+ .create_topic(
+ &stream_id,
+ TOPIC_NAME,
+ 1,
+ CompressionAlgorithm::None,
+ None,
+ IggyExpiry::NeverExpire,
+ MaxTopicSize::ServerDefault,
+ )
+ .await
+ .expect("create topic with one partition");
+ let topic_id = Identifier::named(TOPIC_NAME).expect("topic identifier");
+ let mut messages: Vec<IggyMessage> = (0..MESSAGES_COUNT)
+ .map(|sequence| IggyMessage::from_str(&format!("message-{sequence}")).expect("message"))
+ .collect();
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(PARTITION_ID),
+ &mut messages,
+ )
+ .await
+ .expect("send messages through the view-0 partition primary");
+ assert_eq!(
+ poll_all(&client).await.expect("poll on the view-0 primary"),
+ MESSAGES_COUNT,
+ "the pre-crash batch must commit before the view change"
+ );
+ drop(client);
+ // Let the committed prepares settle on the survivors before the primary dies.
+ sleep(Duration::from_secs(1)).await;
+
+ // Force a partition view change: crash the view-0 primary. The survivors'
+ // primary timeout trips, they run SVC/DVC for the partition group and elect a
+ // new primary at view >= 1, each persisting the advanced view through the
+ // per-partition superblock gate before it casts a view-scoped vote.
+ harness
+ .node_mut(0)
+ .stop()
+ .expect("crash the view-0 primary (node 0)");
+
+ // Wait for the advanced PARTITION view to become durable on a survivor's own
+ // disk. Node 2 has to take part in the election (quorum needs both survivors),
+ // so its partition superblock must reach log_view >= 1. Reading the durable
+ // record directly is the race-free signal, and `log_view >= 1` (not bare
+ // `view >= 1`) is the settled form: the gate persists `view` the moment a
+ // replica advances it to vote, before it adopts the new primary's log.
+ let view_before = wait_for_advanced_partition_view(harness, 2).await;
+
+ // Bring the crashed primary back so it rejoins from its own disk: partition
+ // recovery opens the superblock, restores its recorded view, and the boot
+ // probe adopts the cluster's advanced view from there.
+ harness
+ .node_mut(0)
+ .start()
+ .expect("restart the crashed primary (node 0)");
+ let rejoined = wait_for_advanced_partition_view(harness, 0).await;
+ assert!(
+ rejoined.view >= view_before.view,
+ "the rejoined replica must adopt the advanced partition view (>= {}), not \
+ resume the stale view 0, got {rejoined:?}",
+ view_before.view
+ );
+ // Let the 3/3 mesh settle so the survivor restart below keeps a live quorum.
+ sleep(Duration::from_secs(1)).await;
+
+ // Restart the survivor that advanced its view. It drops all in-memory
+ // consensus state and must recover the advanced partition view from its own
+ // superblock, not reset to a fresh 0.
+ harness
+ .node_mut(2)
+ .stop()
+ .expect("stop the survivor (node 2)");
+ harness
+ .node_mut(2)
+ .start()
+ .expect("restart the survivor (node 2)");
+
+ // NODE 2 specifically must serve the pre-crash batch: the helper returns on
+ // the first node that answers, and nodes 0 and 1 were not restarted, so
+ // asking the whole set would never contact the node under test.
+ wait_until_partition_serves(harness, &[2]).await;
+
+ // The recovered replica's own BEHAVIOR, not the file's contents: the
+ // superblock is read only at boot and written only when the persist gate
+ // fires, so re-reading node 2's own record here would return the
+ // pre-restart bytes even if recovery were broken and node 2 came back at
+ // view 0. Node 2's boot line reports the view it actually restored.
+ //
+ // The expectation is read from NODE 1, which was never restarted: comparing
+ // node 2's restored view against node 2's own file would be `x >= x` and
+ // would pass with the restore path deleted.
+ let expected = read_partition_superblock_state(&harness.node(1).data_path())
+ .expect("the survivor that never restarted holds the cluster's recorded view");
+ let restored = restored_partition_view(harness, 2)
+ .expect("node 2 must log the partition view it restored from its superblock");
+ assert!(
+ restored.0 >= expected.view && restored.1 >= expected.log_view,
+ "node 2 restored (view {}, log_view {}) but the untouched survivor's record is \
+ {expected:?}; a replica that resumes below the view it already acted in can \
+ re-enter it",
+ restored.0,
+ restored.1
+ );
+}
+
+/// `(view, log_view)` the node reports restoring at boot, parsed out of the
+/// structured fields of its restore line. `None` while the line is absent.
+///
+/// Reads the node's OWN log file as well as the harness stdout capture: under
+/// `IGGY_TEST_VERBOSE` the child inherits stdout and the capture is empty, and
+/// an oracle that silently skips in the mode someone debugging this would run
+/// is not an oracle.
+fn restored_partition_view(harness: &TestHarness, node: usize) -> Option<(u32, u32)> {
+ let mut log = harness.node(node).stdout_plain();
+ let own_logs = harness.node(node).data_path().join("logs");
+ if let Ok(entries) = std::fs::read_dir(own_logs) {
+ for entry in entries.flatten() {
+ if let Ok(contents) = std::fs::read_to_string(entry.path()) {
+ log.push_str(&contents);
+ }
+ }
+ }
+ log.lines()
+ .filter(|line| line.contains(RESTORED_VIEW_MARKER))
+ .filter_map(|line| {
+ // Leading space so the `view` key cannot match inside `log_view`.
+ let view = field(line, " view=")?;
+ let log_view = field(line, " log_view=")?;
+ Some((view, log_view))
+ })
+ .max()
+}
+
+/// Value of a space-prefixed `key=<u32>` tracing field.
+fn field(line: &str, key: &str) -> Option<u32> {
+ let start = line.find(key)? + key.len();
+ line[start..]
+ .split(|character: char| !character.is_ascii_digit())
+ .next()
+ .and_then(|digits| digits.parse().ok())
+}
+
+/// Poll the whole pre-crash batch from offset 0; `Ok(count)` of messages seen.
+async fn poll_all(client: &IggyClient) -> Result<u32, IggyError> {
+ let polled = client
+ .poll_messages(
+ &Identifier::named(STREAM_NAME).expect("stream identifier"),
+ &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+ Some(PARTITION_ID),
+ &Consumer::default(),
+ &PollingStrategy::offset(0),
+ MESSAGES_COUNT,
+ false,
+ )
+ .await?;
+ Ok(polled.messages.len() as u32)
+}
+
+/// Find the single partition directory's superblock record on `node`, `None`
+/// while no record exists yet (a partition group that never left view 0 has an
+/// empty superblock, and its slot files may not exist at all).
+///
+/// Located by walking the node's data directory for `superblock.a`, skipping the
+/// metadata plane's record, so the test does not hard-code the
+/// `streams/<s>/topics/<t>/partitions/<p>` layout. Exactly one partition group
+/// exists in this test.
+///
+/// # Panics
+/// If a slot holds bytes that do not verify: no step here corrupts one, so that
+/// is a real durability bug, and reading it as "not written yet" would time the
+/// pollers out on a misleading message.
+fn read_partition_superblock_state(data_path: &Path) -> Option<VsrState> {
+ let dir = find_partition_superblock_dir(data_path)?;
+ let slot_a = std::fs::read(dir.join(SLOT_FILE_NAMES[0])).ok();
+ let slot_b = std::fs::read(dir.join(SLOT_FILE_NAMES[1])).ok();
+ match decode_slots(slot_a.as_deref(), slot_b.as_deref()) {
+ SuperblockContents::Present(payload) => VsrState::try_from(payload.as_slice()).ok(),
+ SuperblockContents::Empty => None,
+ SuperblockContents::Unreadable { version } => panic!(
+ "partition superblock at {} is unreadable (version {version:?}); \
+ no test step corrupts it",
+ dir.display()
+ ),
+ }
+}
+
+fn find_partition_superblock_dir(root: &Path) -> Option<PathBuf> {
+ let mut pending = vec![root.to_path_buf()];
+ while let Some(dir) = pending.pop() {
+ // The metadata plane keeps its own superblock under `<data>/metadata`;
+ // only partition records are under test here.
+ if dir.file_name().is_some_and(|name| name == "metadata") {
+ continue;
+ }
+ let Ok(entries) = std::fs::read_dir(&dir) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ pending.push(path);
+ } else if path
+ .file_name()
+ .is_some_and(|name| name == SLOT_FILE_NAMES[0])
+ {
+ return Some(dir);
+ }
+ }
+ }
+ None
+}
+
+/// Poll `node`'s partition superblock until it holds a settled advanced view
+/// (`log_view >= 1`, which implies `view >= 1`). Panics on timeout.
+async fn wait_for_advanced_partition_view(harness: &TestHarness, node: usize) -> VsrState {
+ let data_path = harness.node(node).data_path();
+ let deadline = Instant::now() + CONVERGE_TIMEOUT;
+ loop {
+ if let Some(state) = read_partition_superblock_state(&data_path)
+ && state.log_view >= 1
+ {
+ return state;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "node {node} did not persist a settled advanced partition view \
+ (log_view >= 1) within {CONVERGE_TIMEOUT:?}"
+ );
+ sleep(POLL_INTERVAL).await;
+ }
+}
+
+/// Poll until some node serves the whole pre-crash batch. Panics on timeout.
+async fn wait_until_partition_serves(harness: &TestHarness, nodes: &[usize]) {
+ let deadline = Instant::now() + CONVERGE_TIMEOUT;
+ loop {
+ for &node in nodes {
+ if let Ok(builder) = harness.node(node).tcp_client()
+ && let Ok(client) = builder.with_root_login().connect().await
+ && poll_all(&client).await == Ok(MESSAGES_COUNT)
+ {
+ return;
+ }
+ }
+ assert!(
+ Instant::now() < deadline,
+ "no node served the {MESSAGES_COUNT} pre-crash messages within \
+ {CONVERGE_TIMEOUT:?}"
+ );
+ sleep(POLL_INTERVAL).await;
+ }
+}
diff --git a/core/integration/tests/server/scenarios/purge_delete_scenario.rs b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
index 5abb681..8c65c3b 100644
--- a/core/integration/tests/server/scenarios/purge_delete_scenario.rs
+++ b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
@@ -975,6 +975,11 @@
.purge_topic(&stream_ident, &topic_ident)
.await
.unwrap();
+ // Sampled BEFORE the restart: if the purge already drained the offset
+ // directories, a restart may not resurrect them, and the assert below stays
+ // instant even in the restart cells. Only the kill-lands-mid-purge case
+ // earns a tolerance.
+ let drained_before_restart = is_dir_empty(&consumers_dir) && is_dir_empty(&groups_dir);
maybe_restart(harness, restart_server).await;
// server-ng purges asynchronously (metadata commit -> reconciler -> pump);
@@ -984,45 +989,57 @@
#[cfg(feature = "vsr")]
await_segment_layout(&partition_path, &[0]).await;
- // --- Verify consumer offsets cleared ---
- let consumer_offset = client
- .get_consumer_offset(&consumer, &stream_ident, &topic_ident, Some(PARTITION_ID))
- .await
- .unwrap();
- assert!(
- consumer_offset.is_none(),
- "Consumer offset must be cleared after purge"
- );
-
- let group_offset = client
- .get_consumer_offset(
- &group_consumer_ref,
- &stream_ident,
- &topic_ident,
- Some(PARTITION_ID),
- )
- .await
- .unwrap();
- assert!(
- group_offset.is_none(),
- "Consumer group offset must be cleared after purge"
- );
-
- // --- Verify offset files deleted from disk ---
- let consumer_files: Vec<_> = read_dir(&consumers_dir)
- .map(|e| e.filter_map(|e| e.ok().map(|e| e.file_name())).collect())
- .unwrap_or_default();
- let group_files: Vec<_> = read_dir(&groups_dir)
- .map(|e| e.filter_map(|e| e.ok().map(|e| e.file_name())).collect())
- .unwrap_or_default();
- assert!(
- consumer_files.is_empty(),
- "Consumer offset files must be deleted after purge, found: {consumer_files:?}"
- );
- assert!(
- group_files.is_empty(),
- "Consumer group offset files must be deleted after purge, found: {group_files:?}"
- );
+ // --- Verify consumer offsets cleared (memory + disk) ---
+ // ZERO tolerance everywhere except one cell: vsr + restart where the kill
+ // landed mid-purge. There boot plants the [0] layout itself (fencing a torn
+ // chain, or recovering an already-drained directory) with the offset files
+ // still present, so the layout gate above is satisfied BEFORE the
+ // reconciler's re-purge clears them (the applied generation is not
+ // persisted, so a restart re-purges). Everywhere else the pump clears
+ // offsets and files in the SAME frame that plants the layout, and a poll
+ // would hide a regression that clears them one frame late. Kept short --
+ // a client-visible stale offset after purge-then-restart is a real
+ // (bounded) window, not something to paper over with a long tolerance.
+ let poll_window = if cfg!(feature = "vsr") && restart_server && !drained_before_restart {
+ std::time::Duration::from_secs(2)
+ } else {
+ std::time::Duration::ZERO
+ };
+ let offsets_deadline = std::time::Instant::now() + poll_window;
+ loop {
+ let consumer_offset = client
+ .get_consumer_offset(&consumer, &stream_ident, &topic_ident, Some(PARTITION_ID))
+ .await
+ .unwrap();
+ let group_offset = client
+ .get_consumer_offset(
+ &group_consumer_ref,
+ &stream_ident,
+ &topic_ident,
+ Some(PARTITION_ID),
+ )
+ .await
+ .unwrap();
+ let consumer_files: Vec<_> = read_dir(&consumers_dir)
+ .map(|e| e.filter_map(|e| e.ok().map(|e| e.file_name())).collect())
+ .unwrap_or_default();
+ let group_files: Vec<_> = read_dir(&groups_dir)
+ .map(|e| e.filter_map(|e| e.ok().map(|e| e.file_name())).collect())
+ .unwrap_or_default();
+ if consumer_offset.is_none()
+ && group_offset.is_none()
+ && consumer_files.is_empty()
+ && group_files.is_empty()
+ {
+ break;
+ }
+ assert!(
+ std::time::Instant::now() < offsets_deadline,
+ "consumer offsets must be cleared after purge: consumer={consumer_offset:?} \
+ group={group_offset:?} consumer_files={consumer_files:?} group_files={group_files:?}"
+ );
+ tokio::time::sleep(std::time::Duration::from_millis(25)).await;
+ }
// --- Verify partition reset: single empty segment at offset 0 ---
assert_fresh_empty_partition(&partition_path);
diff --git a/core/journal/src/lib.rs b/core/journal/src/lib.rs
index 1159573..9de342b 100644
--- a/core/journal/src/lib.rs
+++ b/core/journal/src/lib.rs
@@ -20,6 +20,7 @@
use std::rc::Rc;
pub mod file_storage;
+pub mod local_gate;
pub mod prepare_journal;
pub mod superblock;
diff --git a/core/journal/src/local_gate.rs b/core/journal/src/local_gate.rs
new file mode 100644
index 0000000..ca9998f
--- /dev/null
+++ b/core/journal/src/local_gate.rs
@@ -0,0 +1,207 @@
+// 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.
+
+//! Single-shard async gate for critical sections that hold across an `.await`.
+//!
+//! Many futures can drive the same shard-local resource concurrently (a pump
+//! loop, detached per-client tasks, repair drivers). This gate serializes them
+//! without atomics: the shard is never `Sync`, so a `Cell` flag provides the
+//! exclusion a `tokio::sync::Mutex` would buy with an atomic RMW per acquire.
+//!
+//! Not a general lock: single-threaded (`Cell`/`RefCell`, never `Sync`),
+//! release wakes every waiter and poll order re-races (arrival-order FIFO
+//! under `futures::join!`-style drivers), cancel-safe (dropping the guard
+//! releases; dropping a waiter leaves only a stale waker). Non-reentrant: a
+//! holder that re-acquires deadlocks itself.
+
+use std::cell::{Cell, RefCell};
+
+/// See the module docs. Callers hold the returned guard across the awaited
+/// critical section; dropping it releases the gate and wakes every waiter.
+///
+/// Its exclusion is load-bearing in RELEASE, not only under
+/// `debug_assertions`: this gate is the only enforcement of the superblock
+/// single-writer contract there. The `WritingGuard` tripwire is debug-only,
+/// `PingPongSuperblock::write` takes `&self`, and two overlapping writers
+/// collide on one fixed `.tmp` path and tear a slot while both return `Ok`.
+pub struct LocalGate {
+ busy: Cell<bool>,
+ waiters: RefCell<Vec<std::task::Waker>>,
+}
+
+impl LocalGate {
+ #[must_use]
+ pub const fn new() -> Self {
+ Self {
+ busy: Cell::new(false),
+ waiters: RefCell::new(Vec::new()),
+ }
+ }
+
+ // A manual `Future` impl does not inherit the async-fn unused lint, so
+ // without these `gate.acquire();` and `gate.acquire().await;` both
+ // compile clean as no-op exclusion (the guard drops at the semicolon).
+ #[must_use = "acquire does nothing until awaited"]
+ pub const fn acquire(&self) -> LocalGateAcquire<'_> {
+ LocalGateAcquire { gate: self }
+ }
+}
+
+impl Default for LocalGate {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[must_use = "the acquire future must be awaited to take the gate"]
+pub struct LocalGateAcquire<'a> {
+ gate: &'a LocalGate,
+}
+
+impl<'a> std::future::Future for LocalGateAcquire<'a> {
+ type Output = LocalGateGuard<'a>;
+
+ fn poll(
+ self: std::pin::Pin<&mut Self>,
+ cx: &mut std::task::Context<'_>,
+ ) -> std::task::Poll<Self::Output> {
+ if self.gate.busy.get() {
+ // Deduped, not appended: a waiter driven by a multi-source driver
+ // (`select!`, `join!`) is re-polled on every unrelated wake, and
+ // release wakes the whole list, so a bare push makes draining n
+ // contenders O(n^2) waker clones.
+ let mut waiters = self.gate.waiters.borrow_mut();
+ if !waiters.iter().any(|waiter| waiter.will_wake(cx.waker())) {
+ waiters.push(cx.waker().clone());
+ }
+ std::task::Poll::Pending
+ } else {
+ self.gate.busy.set(true);
+ std::task::Poll::Ready(LocalGateGuard { gate: self.gate })
+ }
+ }
+}
+
+#[must_use = "dropping the guard immediately releases the gate"]
+pub struct LocalGateGuard<'a> {
+ gate: &'a LocalGate,
+}
+
+impl Drop for LocalGateGuard<'_> {
+ fn drop(&mut self) {
+ self.gate.busy.set(false);
+ // Move the waiters out before waking: `wake()` only schedules under
+ // compio today, but a waker that ever polled a waiter inline would
+ // re-enter `acquire`'s `waiters.borrow_mut()` and panic the RefCell.
+ let waiters = std::mem::take(&mut *self.gate.waiters.borrow_mut());
+ for waker in waiters {
+ waker.wake();
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use futures::FutureExt;
+ use futures::future::join;
+ use std::rc::Rc;
+
+ /// Two writers queued on the same gate run one after the other, never
+ /// interleaved -- the property a torn superblock slot depends on.
+ #[compio::test]
+ async fn given_two_waiters_when_acquiring_should_serialize() {
+ let gate = LocalGate::new();
+ let gate = &gate;
+ let log: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
+
+ let first = {
+ let log = Rc::clone(&log);
+ async move {
+ let guard = gate.acquire().await;
+ log.borrow_mut().push("first:enter");
+ // A yield inside the critical section: without exclusion the
+ // second writer would slot in right here.
+ compio::time::sleep(std::time::Duration::from_millis(1)).await;
+ log.borrow_mut().push("first:exit");
+ drop(guard);
+ }
+ };
+ let second = {
+ let log = Rc::clone(&log);
+ async move {
+ let guard = gate.acquire().await;
+ log.borrow_mut().push("second:enter");
+ compio::time::sleep(std::time::Duration::from_millis(1)).await;
+ log.borrow_mut().push("second:exit");
+ drop(guard);
+ }
+ };
+ join(first, second).await;
+
+ let log = log.borrow();
+ assert_eq!(log.len(), 4, "both sections ran: {log:?}");
+ let first_exit = log.iter().position(|entry| *entry == "first:exit").unwrap();
+ let second_enter = log
+ .iter()
+ .position(|entry| *entry == "second:enter")
+ .unwrap();
+ assert!(
+ first_exit < second_enter,
+ "sections must not interleave: {log:?}"
+ );
+ }
+
+ /// Dropping the guard wakes a queued waiter, so the gate does not wedge
+ /// once contended.
+ #[compio::test]
+ async fn given_queued_waiter_when_guard_drops_should_wake_it() {
+ let gate = LocalGate::new();
+ let held = gate.acquire().await;
+
+ let mut waiter = Box::pin(gate.acquire());
+ assert!(
+ waiter.as_mut().now_or_never().is_none(),
+ "the gate is held, so the waiter must park"
+ );
+
+ drop(held);
+ assert!(
+ waiter.now_or_never().is_some(),
+ "dropping the guard must wake the queued waiter"
+ );
+ }
+
+ /// A waiter that goes away leaves only a stale waker behind: the next
+ /// acquirer still gets the gate. Cancel safety is load-bearing here, since
+ /// every acquire site sits in a future a shard can drop.
+ #[compio::test]
+ async fn given_dropped_waiter_when_guard_releases_should_not_wedge() {
+ let gate = LocalGate::new();
+ let held = gate.acquire().await;
+
+ let mut abandoned = Box::pin(gate.acquire());
+ assert!(abandoned.as_mut().now_or_never().is_none());
+ drop(abandoned);
+
+ drop(held);
+ assert!(
+ gate.acquire().now_or_never().is_some(),
+ "a dropped waiter must not keep the gate busy"
+ );
+ }
+}
diff --git a/core/journal/src/superblock.rs b/core/journal/src/superblock.rs
index 9cecb31..fd8b761 100644
--- a/core/journal/src/superblock.rs
+++ b/core/journal/src/superblock.rs
@@ -48,7 +48,6 @@
use std::hash::Hasher;
use std::io;
use std::path::{Path, PathBuf};
-use std::pin::Pin;
use compio::io::{AsyncReadAtExt, AsyncWriteAtExt};
@@ -71,14 +70,28 @@
/// Ceiling on a record's payload, bounding every allocation this module makes from
/// a length it read off disk (`PrepareJournal::MAX_ENTRY_SIZE` bounds the WAL for the
-/// same reason). The only payload today is a 58-byte [`VsrState`]; the headroom is
-/// for a payload that grows fields, not for bulk data. `read_slot` treats a longer
-/// file as corrupt WITHOUT reading it, and `build_record` refuses to write one, so a
-/// length this store could have produced is always in bounds.
+/// same reason). The only payload today is a [`consensus::VsrState`], 66 bytes now
+/// that it carries the offset frontier (58 before it, a length its decode still
+/// accepts); the headroom is for a payload that grows fields, not for bulk data.
+/// `read_slot` treats a longer file as corrupt WITHOUT reading it, and
+/// `build_record` refuses to write one, so a length this store could have
+/// produced is always in bounds.
const MAX_PAYLOAD_LEN: usize = 4096;
/// Largest record `read_slot` will read into memory.
const MAX_RECORD_LEN: usize = HEADER_LEN + MAX_PAYLOAD_LEN + CHECKSUM_LEN;
+/// First retry delay after a failed superblock write, doubling per failure.
+///
+/// Starts near the 10 ms consensus tick, so a one-off failure costs no
+/// latency, and a persistent one stops re-running `atomic_replace` per tick.
+pub const SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS: u64 = 10_000;
+/// Ceiling on the retry delay. A replica fenced this long is not coming back without
+/// operator action, but the retry must stay frequent enough to recover on its own the
+/// moment the disk does.
+pub const SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS: u64 = 1_000_000;
+/// Caps the doubling so the shift cannot overflow before the ceiling clamps it.
+pub const SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT: u64 = 8;
+
const FILE_A: &str = "superblock.a";
const FILE_B: &str = "superblock.b";
@@ -129,33 +142,6 @@
fn read_latest(&self) -> impl Future<Output = io::Result<SuperblockContents>>;
}
-/// A boxed, lifetime-bound future, for the object-safe superblock adapter.
-type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
-
-/// Object-safe adapter over [`SuperblockStore`].
-///
-/// Lets a superblock be held as `Rc<dyn DynSuperblockStore>` without threading a
-/// generic through the consensus and metadata layers. Writes happen only on a
-/// view change or checkpoint, so the boxed future is off every hot path. The
-/// `dyn_` prefix avoids clashing with the inherent methods under the blanket impl.
-pub trait DynSuperblockStore {
- /// See [`SuperblockStore::write`].
- fn dyn_write<'a>(&'a self, payload: &'a [u8]) -> BoxFuture<'a, io::Result<()>>;
-
- /// See [`SuperblockStore::read_latest`].
- fn dyn_read_latest(&self) -> BoxFuture<'_, io::Result<SuperblockContents>>;
-}
-
-impl<T: SuperblockStore> DynSuperblockStore for T {
- fn dyn_write<'a>(&'a self, payload: &'a [u8]) -> BoxFuture<'a, io::Result<()>> {
- Box::pin(self.write(payload))
- }
-
- fn dyn_read_latest(&self) -> BoxFuture<'_, io::Result<SuperblockContents>> {
- Box::pin(self.read_latest())
- }
-}
-
#[derive(Clone, Copy)]
enum Slot {
A,
@@ -211,6 +197,19 @@
/// # Errors
/// I/O error if a slot file exists but cannot be read.
pub async fn open(dir: impl Into<PathBuf>) -> io::Result<Self> {
+ Ok(Self::open_with_latest(dir).await?.0)
+ }
+
+ /// [`Self::open`] plus the latest recorded contents, from the SAME slot
+ /// reads `open` already performs -- boot paths that would otherwise call
+ /// `read_latest` right after `open` pay four slot reads per group
+ /// instead of two.
+ ///
+ /// # Errors
+ /// Same as [`Self::open`]: any slot read failing at the io layer.
+ pub async fn open_with_latest(
+ dir: impl Into<PathBuf>,
+ ) -> io::Result<(Self, SuperblockContents)> {
let dir = dir.into();
let slot_a = read_slot(&dir.join(FILE_A)).await?;
let slot_b = read_slot(&dir.join(FILE_B)).await?;
@@ -232,14 +231,15 @@
};
let next_slot = if a_is_newest { Slot::B } else { Slot::A };
- Ok(Self {
+ let store = Self {
dir,
next_sequence: Cell::new(latest + 1),
next_slot: Cell::new(next_slot),
degraded: has_unreadable_sequence(&slot_a) || has_unreadable_sequence(&slot_b),
#[cfg(debug_assertions)]
writing: Cell::new(false),
- })
+ };
+ Ok((store, combine_slots(slot_a, slot_b)))
}
}
diff --git a/core/metadata/src/impls/metadata.rs b/core/metadata/src/impls/metadata.rs
index 0290e5b..55d4fb0 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -50,7 +50,11 @@
use iggy_common::UserId;
use iggy_common::calculate_checksum;
use iggy_common::variadic;
-use journal::superblock::DynSuperblockStore;
+use journal::local_gate::LocalGate;
+use journal::superblock::{
+ PingPongSuperblock, SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS, SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS,
+ SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT, SuperblockStore,
+};
use journal::{Journal, JournalHandle};
use message_bus::MessageBus;
use server_common::Message;
@@ -202,17 +206,6 @@
}
}
-/// First retry delay after a failed superblock write, doubling per consecutive
-/// failure. Starts near the 10 ms consensus tick, so a one-off failure costs no
-/// latency, and a persistent one stops re-running `atomic_replace` per tick.
-const SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS: u64 = 10_000;
-/// Ceiling on the retry delay. A replica fenced this long is not coming back without
-/// operator action, but the retry must stay frequent enough to recover on its own the
-/// moment the disk does.
-const SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS: u64 = 1_000_000;
-/// Caps the doubling so the shift cannot overflow before the ceiling clamps it.
-const SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT: u64 = 8;
-
/// Framing marker for the snapshot integrity trailer, "ISNP". Distinguishes a sealed
/// snapshot from one written before the trailer existed, so a MISSING trailer can be
/// accepted (unverified, loudly) while a PRESENT but mismatching one refuses boot. A
@@ -446,81 +439,6 @@
const _: () =
assert!(SnapshotCoordinator::<()>::CHECKPOINT_MARGIN >= consensus::PIPELINE_PREPARE_QUEUE_MAX);
-/// Single-shard async gate serializing the journal-mutation section of
-/// `on_replicate` (forced checkpoint + WAL append).
-///
-/// Many futures can drive `on_replicate` concurrently on one shard (the
-/// pump loop, detached per-client submit tasks, repair). Ungated they race
-/// `SnapshotCoordinator::checkpoint`: every driver crossing the
-/// `remaining_capacity <= CHECKPOINT_MARGIN` boundary runs a full
-/// checkpoint, and the concurrent `journal.drain()` calls collide on the
-/// WAL rewrite — shared `wal.tmp`, ENOENT for every rename that loses,
-/// short reads after the winner's reopen. Appends racing a drain are just
-/// as unsound: the drain's live-set partition misses an append landing
-/// mid-rewrite and the rewrite silently discards it.
-///
-/// Not a general lock: single-threaded (`Cell`/`RefCell`, never `Sync`),
-/// release wakes every waiter and poll order re-races (arrival-order FIFO
-/// under `futures::join!`-style drivers), cancel-safe (dropping the guard
-/// releases; dropping a waiter leaves only a stale waker).
-struct LocalGate {
- busy: Cell<bool>,
- waiters: RefCell<Vec<std::task::Waker>>,
-}
-
-impl LocalGate {
- const fn new() -> Self {
- Self {
- busy: Cell::new(false),
- waiters: RefCell::new(Vec::new()),
- }
- }
-
- const fn acquire(&self) -> LocalGateAcquire<'_> {
- LocalGateAcquire { gate: self }
- }
-}
-
-struct LocalGateAcquire<'a> {
- gate: &'a LocalGate,
-}
-
-impl<'a> std::future::Future for LocalGateAcquire<'a> {
- type Output = LocalGateGuard<'a>;
-
- fn poll(
- self: std::pin::Pin<&mut Self>,
- cx: &mut std::task::Context<'_>,
- ) -> std::task::Poll<Self::Output> {
- if self.gate.busy.get() {
- // Re-polls while still busy push a duplicate waker; the extra
- // wake is spurious and harmless at pipeline-queue scale.
- self.gate.waiters.borrow_mut().push(cx.waker().clone());
- std::task::Poll::Pending
- } else {
- self.gate.busy.set(true);
- std::task::Poll::Ready(LocalGateGuard { gate: self.gate })
- }
- }
-}
-
-struct LocalGateGuard<'a> {
- gate: &'a LocalGate,
-}
-
-impl Drop for LocalGateGuard<'_> {
- fn drop(&mut self) {
- self.gate.busy.set(false);
- // Move the waiters out before waking: `wake()` only schedules under
- // compio today, but a waker that ever polled a waiter inline would
- // re-enter `acquire`'s `waiters.borrow_mut()` and panic the RefCell.
- let waiters = std::mem::take(&mut *self.gate.waiters.borrow_mut());
- for waker in waiters {
- waker.wake();
- }
- }
-}
-
/// Failures shared by the in-process metadata submit helpers.
///
/// Returned by [`IggyMetadata::submit_register_in_process`],
@@ -718,7 +636,7 @@
/// single-thread invariant keeps access safe without [`Sync`].
pub type CommitNotifier = std::rc::Rc<dyn Fn(Operation)>;
-pub struct IggyMetadata<C, J, S, M> {
+pub struct IggyMetadata<C, J, S, M, SB = PingPongSuperblock> {
/// `Some` on shard 0, `None` on other shards. Server-ng bootstrap
/// holds the invariant: only shard 0 owns the metadata consensus
/// replica; every other shard reconstructs `mux_stm` from the
@@ -736,10 +654,11 @@
/// `Some` on shard 0, `None` on other shards.
pub snapshot: Option<S>,
/// Durable VSR-state record (`view`/`log_view`/`commit`). `Some` only on the
- /// shard owning metadata consensus (shard 0); `None` on peer shards and in the
- /// partition plane, which is not yet durable. Behind `Rc<dyn ...>` so the
- /// simulator harness can keep a clone outliving a replica across a restart.
- pub superblock: Option<Rc<dyn DynSuperblockStore>>,
+ /// shard owning metadata consensus (shard 0); `None` on peer shards. Generic
+ /// (`PingPongSuperblock` in production, `SimSuperblock` in the simulator,
+ /// recording doubles in tests) and behind `Rc` so the simulator harness can
+ /// keep a clone outliving a replica across a restart.
+ pub superblock: Option<Rc<SB>>,
/// Serializes superblock writes on shard 0 so at most one is in flight.
/// View-change persists ([`Self::persist_superblock_if_needed`]) and checkpoints
/// ([`Self::checkpoint_if_needed`]) share the one ping-pong superblock, and
@@ -781,7 +700,15 @@
/// Snapshot coordinator - present when persistent checkpointing is configured.
pub coordinator: Option<SnapshotCoordinator<M>>,
/// Serializes `on_replicate`'s journal-mutation section (forced
- /// checkpoint + WAL append) across concurrent drivers. See [`LocalGate`].
+ /// checkpoint + WAL append) across concurrent drivers (the pump loop,
+ /// detached per-client submit tasks, repair). Ungated they race
+ /// `SnapshotCoordinator::checkpoint`: every driver crossing the
+ /// `remaining_capacity <= CHECKPOINT_MARGIN` boundary runs a full
+ /// checkpoint, and the concurrent `journal.drain()` calls collide on the
+ /// WAL rewrite -- shared `wal.tmp`, ENOENT for every rename that loses,
+ /// short reads after the winner's reopen. Appends racing a drain are just
+ /// as unsound: the drain's live-set partition misses an append landing
+ /// mid-rewrite and the rewrite silently discards it. See [`LocalGate`].
journal_gate: LocalGate,
/// Per-client session state (sessions, dedup, eviction). Metadata-only.
pub client_table: RefCell<ClientTable>,
@@ -816,7 +743,7 @@
transfer_offer_cache: RefCell<Option<Rc<StateTransferOffer>>>,
}
-impl<C, J, S, M> IggyMetadata<C, J, S, M>
+impl<C, J, S, M, SB> IggyMetadata<C, J, S, M, SB>
where
M: StreamsFrontend + FillSnapshot<MetadataSnapshot>,
{
@@ -829,7 +756,7 @@
consensus: Option<C>,
journal: Option<J>,
snapshot: Option<S>,
- superblock: Option<Rc<dyn DynSuperblockStore>>,
+ superblock: Option<Rc<SB>>,
mux_stm: M,
data_dir: Option<std::path::PathBuf>,
) -> Self {
@@ -859,7 +786,7 @@
}
}
-impl<C, J, S, M> IggyMetadata<C, J, S, M> {
+impl<C, J, S, M, SB> IggyMetadata<C, J, S, M, SB> {
/// Slot capacity of the LIVE client table, i.e. the largest transferred
/// table this replica can absorb.
///
@@ -996,9 +923,10 @@
}
#[allow(clippy::future_not_send)]
-impl<B, J, S, M> Plane<VsrConsensus<B>> for IggyMetadata<VsrConsensus<B>, J, S, M>
+impl<B, J, S, M, SB> Plane<VsrConsensus<B>> for IggyMetadata<VsrConsensus<B>, J, S, M, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
J: JournalHandle,
J::Target: Journal<J::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
M: StreamsFrontend
@@ -1343,7 +1271,8 @@
}
}
-impl<B, P, J, S, M> PlaneIdentity<VsrConsensus<B, P>> for IggyMetadata<VsrConsensus<B, P>, J, S, M>
+impl<B, P, J, S, M, SB> PlaneIdentity<VsrConsensus<B, P>>
+ for IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
@@ -1359,8 +1288,7 @@
message.header().command(),
Command2::Request | Command2::Prepare | Command2::PrepareOk
));
- let op = message.header().operation();
- op.is_metadata() || matches!(op, Operation::Register | Operation::Logout)
+ message.header().operation().is_metadata_plane()
}
}
@@ -1487,9 +1415,10 @@
pub pairing_durable: bool,
}
-impl<B, J, S, M> IggyMetadata<VsrConsensus<B>, J, S, M>
+impl<B, J, S, M, SB> IggyMetadata<VsrConsensus<B>, J, S, M, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
J: JournalHandle,
J::Target: Journal<J::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
M: StreamsFrontend
@@ -3004,10 +2933,11 @@
}
}
-impl<B, P, J, S, M> IggyMetadata<VsrConsensus<B, P>, J, S, M>
+impl<B, P, J, S, M, SB> IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
+ SB: SuperblockStore,
{
/// Persist the current VSR state to the superblock when the view changed since
/// the last write. The split-brain gate: callers MUST invoke this before
@@ -3084,11 +3014,7 @@
/// from logs. Fail-stopping the process is the TigerBeetle-style answer, but this
/// layer holds no process-lifecycle handle; wire it where the shard owns shutdown.
#[allow(clippy::future_not_send)]
- async fn write_superblock(
- &self,
- consensus: &VsrConsensus<B, P>,
- superblock: &dyn DynSuperblockStore,
- ) -> bool {
+ async fn write_superblock(&self, consensus: &VsrConsensus<B, P>, superblock: &SB) -> bool {
// Carry the last durable pairing forward so a view-change write never
// regresses the `(checkpoint_op, checksum)` a checkpoint recorded. `(0, 0)`
// with no checkpoint taken, or no coordinator (peer shards, the simulator).
@@ -3097,7 +3023,7 @@
.as_ref()
.map_or((0, 0), SnapshotCoordinator::last_checkpoint);
let state = consensus.vsr_state(checkpoint_op, checkpoint_checksum);
- match superblock.dyn_write(&state.to_bytes()).await {
+ match superblock.write(&state.to_bytes()).await {
Ok(()) => {
consensus.mark_superblock_durable(state.view, state.log_view);
self.superblock_write_failures.set(0);
@@ -3141,10 +3067,11 @@
}
}
-impl<B, P, J, S, M> IggyMetadata<VsrConsensus<B, P>, J, S, M>
+impl<B, P, J, S, M, SB> IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
where
B: MessageBus,
P: Pipeline<Entry = PipelineEntry>,
+ SB: SuperblockStore,
J: JournalHandle,
J::Target: Journal<J::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
M: StreamsFrontend
@@ -3829,7 +3756,9 @@
op,
timestamp,
operation,
- namespace: request.namespace,
+ // The group's namespace, never the request's: clients send 0, and a
+ // journaled 0 mis-routes the entry when repair replays it verbatim.
+ namespace: consensus.namespace(),
// Carry the acting user id so the in-apply RBAC gate sees the same
// identity on every replica. The default projection copies it (see
// `Project::project`); this helper builds prepares for the ops it
diff --git a/core/metadata/src/impls/recovery.rs b/core/metadata/src/impls/recovery.rs
index 0ed4cfc..286d286 100644
--- a/core/metadata/src/impls/recovery.rs
+++ b/core/metadata/src/impls/recovery.rs
@@ -781,6 +781,7 @@
commit_max: 100,
checkpoint_op,
checkpoint_checksum,
+ offset_frontier: 0,
}
}
diff --git a/core/metadata/src/lib.rs b/core/metadata/src/lib.rs
index 5c4ba39..4d4d240 100644
--- a/core/metadata/src/lib.rs
+++ b/core/metadata/src/lib.rs
@@ -27,5 +27,9 @@
apply_committed_prepare,
};
+// Recovery vocabulary other crates name in their own signatures and error
+// enums, so they do not have to spell the `impls::` path.
+pub use impls::recovery::{IdentityField, RecoveryError, ReplicaIdentity};
+
// Re-export MuxStateMachine for use in other modules
pub use stm::mux::MuxStateMachine;
diff --git a/core/partitions/Cargo.toml b/core/partitions/Cargo.toml
index 5524ecf..0acc918 100644
--- a/core/partitions/Cargo.toml
+++ b/core/partitions/Cargo.toml
@@ -43,6 +43,7 @@
bytes = { workspace = true }
compio = { workspace = true }
consensus = { workspace = true }
+futures = { workspace = true }
iggy_binary_protocol = { workspace = true }
iggy_common = { workspace = true }
journal = { workspace = true }
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index d7cc733..dad3f70 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -26,7 +26,8 @@
PartitionDirResolution, PollPlan, PollTier, ResidentTailSnapshot,
};
use crate::segment::Segment;
-use crate::types::RepairSession;
+use crate::state_transfer::{PartitionTransferSession, PendingTransferRearm};
+use crate::types::{RepairConclusion, RepairSession};
use crate::{
AppendResult, Partition, PartitionOffsets, PartitionsConfig, PollQueryResult, PollingArgs,
PollingConsumer,
@@ -55,6 +56,11 @@
IggyByteSize, IggyError, IggyExpiry, IggyTimestamp, PartitionStats, PollingKind,
};
use journal::Journal as _;
+use journal::local_gate::LocalGate;
+use journal::superblock::{
+ PingPongSuperblock, SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS, SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS,
+ SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT, SuperblockStore,
+};
use message_bus::{IggyMessageBus, MessageBus, is_auto_commit_client};
use server_common::{
MESSAGE_ALIGN, Message, SegmentStorage,
@@ -65,8 +71,9 @@
},
sharding::IggyNamespace,
};
-use std::cell::RefCell;
-use std::collections::HashMap;
+use std::cell::{Cell, RefCell};
+use std::collections::{HashMap, HashSet};
+use std::fmt;
use std::hash::Hash;
use std::rc::Rc;
use std::sync::Arc;
@@ -80,8 +87,7 @@
// `SendMessages` retries are at-least-once and may commit multiple times.
// Consumers handle duplicate messages via `server_common::MessageDeduplicator`
// (message-id based) if they care.
-#[derive(Debug)]
-pub struct IggyPartition<B = IggyMessageBus>
+pub struct IggyPartition<B = IggyMessageBus, SB = PingPongSuperblock>
where
B: MessageBus,
{
@@ -104,15 +110,15 @@
pub revision_id: u64,
pub should_increment_offset: bool,
pub write_lock: Arc<TokioMutex<()>>,
- consumer_offsets_path: Option<String>,
- consumer_group_offsets_path: Option<String>,
+ pub(crate) consumer_offsets_path: Option<String>,
+ pub(crate) consumer_group_offsets_path: Option<String>,
/// Canonical on-disk partition directory, set at construction by the
/// server builder. Disk polls must not derive this from live writers:
/// sealed segments drop their writer at rotation, so a writer-derived
/// path transiently disappears and silently hides the disk tier.
/// `None` only for in-memory (simulated) partitions.
- partition_dir: Option<String>,
- consumer_offset_enforce_fsync: bool,
+ pub(crate) partition_dir: Option<String>,
+ pub(crate) consumer_offset_enforce_fsync: bool,
/// In-flight journal repair:
/// set when the recovery handshake finds this replica behind the group's
/// commit frontier, cleared when `RepairDone` completes the walk.
@@ -123,7 +129,16 @@
/// re-persisting / re-counting them. Immutable after boot, so live
/// traffic (always above it) is never affected.
pub recovered_durable_offset: Option<u64>,
- pending_consumer_offset_commits: HashMap<u64, PendingConsumerOffsetCommit>,
+ /// Where the group's offset space STARTS on this replica: everything
+ /// below it is represented by a completed state-transfer install (or by
+ /// the empty segment such an install planted at the frontier). Consulted
+ /// only by the repair floor-connect check, so an install with zero
+ /// staged segments does not force one wasted transfer round per rejoin.
+ /// Deliberately separate from [`Self::recovered_durable_offset`], which
+ /// also gates repaired-batch persistence -- overstating THAT field would
+ /// silently drop the `(commit_op, commit_max]` replay window.
+ pub installed_frontier: Option<u64>,
+ pub(crate) pending_consumer_offset_commits: HashMap<u64, PendingConsumerOffsetCommit>,
/// Committed-only mirror of each consumer's persisted offset file: the
/// last value this replica durably wrote per (kind, consumer id). Fed
/// exclusively by the file-writing paths (replicated commit-apply, the
@@ -136,13 +151,125 @@
/// the tracker rebuilds from disk lazily and deterministically.
/// `RefCell`: mutated from `&self` paths on the single shard thread;
/// borrows never cross an await.
- persisted_offsets: RefCell<HashMap<(ConsumerKind, u32), u64>>,
- observed_view: u32,
+ pub(crate) persisted_offsets: RefCell<HashMap<(ConsumerKind, u32), u64>>,
+ pub(crate) observed_view: u32,
/// Highest `PurgeTopic` generation this replica has locally applied (reset
/// the partition to empty). The reconciler compares the committed metadata
/// generation against this and resets only when it advances, so a redundant
/// reconcile pass never re-wipes a partition already at this generation.
- applied_purge_generation: u64,
+ pub(crate) applied_purge_generation: u64,
+ /// Durable superblock for this partition's consensus group, recording
+ /// `(view, log_view)` across a crash so this replica can never
+ /// re-participate in a view older than one it advertised. `None` for
+ /// in-memory / simulated partitions, where the persist gate is a no-op
+ /// and views stay process-lifetime only. Behind `Rc` because the boot
+ /// path opens the store once and hands the same instance here:
+ /// re-opening would fork the ping-pong sequence counter.
+ superblock: Option<Rc<SB>>,
+ /// Serializes this partition's superblock writes so at most one is in
+ /// flight: `PingPongSuperblock::write` picks its slot before it awaits,
+ /// so two overlapping writers would target the same slot and could tear
+ /// it while both report success. Per partition, not per shard -- every
+ /// group owns its own two-file store, so writes to different partitions
+ /// never contend.
+ superblock_lock: LocalGate,
+ /// Consecutive failed superblock writes, and the clock reading after which
+ /// the next attempt may run. A persistent `ENOSPC` / `EIO` would otherwise
+ /// re-run a full `atomic_replace` on every 10 ms consensus tick. Reset on
+ /// the first success. See [`Self::persist_superblock_if_needed`] for the
+ /// terminal policy.
+ superblock_write_failures: Cell<u64>,
+ superblock_retry_after_micros: Cell<u64>,
+ /// A committed purge this replica accepted but could not apply, because it
+ /// could not record the frontier reset first. Withholds `PrepareOk` until
+ /// the purge lands: the counter still names the PRE-purge offset space, so
+ /// every op acked meanwhile would be stamped from a `base_offset` the peers
+ /// that already purged do not share.
+ ///
+ /// The superblock persist gate cannot cover this on its own -- it fires on
+ /// `(view, log_view)` changes, and a replica with a stable view and a full
+ /// disk attempts no write, observes no failure, and fences nothing.
+ pub(crate) purge_deferred: bool,
+ /// The `offset_frontier` the last successful superblock write recorded,
+ /// seeded at boot from the record that write left behind.
+ ///
+ /// The advance direction maxes against THIS as well as the live counter,
+ /// because the two diverge: a failed install leaves the counter at its
+ /// pre-install value while the record already names the incoming frontier,
+ /// and the fence that follows then persists the counter. Maxing against
+ /// the counter alone writes 0 over a recorded N and quarantines the
+ /// segments that were the only other witness, after which the rebuild
+ /// re-mints offsets the group already handed out.
+ durable_offset_frontier: Cell<u64>,
+ /// In-flight state transfer for this group (rejoin whose repair floor was
+ /// refused); tail repair takes over at install. See
+ /// [`PartitionTransferSession`].
+ pub transfer: Option<PartitionTransferSession>,
+ /// Consecutive transfer stall rounds WITHIN one recovery attempt. NOT in the
+ /// session: three of four metadata arming sites re-minted their session, so
+ /// a per-session counter bounded nothing and a permanent failure cycled
+ /// abandon -> repair -> refusal -> re-arm at zero forever. Reset by
+ /// [`Self::note_transfer_progress`] and by
+ /// [`Self::note_transfer_rearm_scheduled`]; livelock across attempts is
+ /// bounded by [`Self::transfer_failures`] and its exponential backoff.
+ transfer_attempts: u32,
+ /// CONSECUTIVE transfer failures of any class (decode, spill, install,
+ /// peer-unavailable, stall exhaustion). Deliberately NOT keyed on the
+ /// offered generation: a committing primary advances its generation
+ /// every round, and a generation-keyed count reset to 1 forever, so a
+ /// deterministic local failure (ENOSPC, an undecodable artifact) looped
+ /// at network round-trip rate. Reset only by
+ /// [`Self::note_transfer_installed`]; drives the re-arm backoff.
+ transfer_failures: u32,
+ /// CONSECUTIVE transient refusals (a peer that cannot serve right now).
+ /// Drives log escalation only -- never the backoff. See
+ /// [`Self::record_transfer_refusal`].
+ transfer_refusals: u32,
+ /// A scheduled transfer re-arm: try `peer` again once `after_ticks`
+ /// consensus ticks elapse. Owned by the shard tick sweep; while one is
+ /// pending, the repair-refusal trigger must not arm concurrently.
+ pub transfer_rearm: Option<PendingTransferRearm>,
+ /// Memoized segment-payload checksum state, keyed by segment base offset.
+ /// Sealed segments are immutable, so their stamp never changes; the active
+ /// segment extends its own hasher over the bytes it gained. Without this,
+ /// EVERY offer build re-reads and re-hashes all retained bytes on the pump
+ /// -- and a committing primary advances `commit_op` each round, so the offer
+ /// cache alone never saves the pass. Swept against the live chain at build
+ /// time; cleared wherever segment files are unlinked-and-recreated (purge,
+ /// install, converge).
+ pub(crate) segment_checksum_cache:
+ RefCell<std::collections::HashMap<u64, crate::state_transfer::SegmentChecksumMemo>>,
+ /// Receiving-side memo of the last staged-segment reuse scan, so a peer
+ /// rotation against the same segment set does not re-read and re-walk every
+ /// staged file. See [`crate::state_transfer::ReuseScanMemo`].
+ pub(crate) reuse_scan_memo: RefCell<Option<crate::state_transfer::ReuseScanMemo>>,
+ /// Serving-side offer cache, keyed by the `commit_op` it was built at, so
+ /// simultaneous rejoiners share one manifest instead of re-reading every
+ /// segment per requester. Invalidated by `purge` (same commit frontier,
+ /// different bytes) and released by the shard's offer-expiry sweep.
+ pub(crate) transfer_offer_cache:
+ RefCell<Option<Rc<crate::state_transfer::PartitionStateTransferOffer>>>,
+}
+
+impl<B, SB> fmt::Debug for IggyPartition<B, SB>
+where
+ B: MessageBus,
+{
+ // Hand-written because `SB` carries no `Debug` bound; the fields listed
+ // are the ones diagnostics actually key on.
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("IggyPartition")
+ .field("namespace", &self.consensus.namespace())
+ .field("offset", &self.offset)
+ .field("dirty_offset", &self.dirty_offset)
+ .field("should_increment_offset", &self.should_increment_offset)
+ .field("partition_dir", &self.partition_dir)
+ .field("repair", &self.repair)
+ .field("recovered_durable_offset", &self.recovered_durable_offset)
+ .field("observed_view", &self.observed_view)
+ .field("applied_purge_generation", &self.applied_purge_generation)
+ .finish_non_exhaustive()
+ }
}
/// Post-preflight dispatch in `on_request`: replicate via VSR or take the
@@ -158,8 +285,62 @@
},
}
+/// Why a purge did not complete, split by whether it had already mutated.
+///
+/// The two need opposite handling, and conflating them is a data-loss bug:
+/// fencing a partition whose purge failed before it touched anything
+/// quarantines a complete healthy chain while the live counter still names the
+/// pre-purge offset space, and the fence's own frontier write then stamps that
+/// stale counter as durable truth.
+#[derive(Debug)]
+pub enum PurgeError {
+ /// The frontier reset could not be recorded. NOTHING was mutated: the
+ /// segments, the counters and `applied_purge_generation` are all untouched,
+ /// so the reconciler's `committed > applied` gate re-issues this purge on
+ /// its next pass. Retry, do not fence.
+ ///
+ /// Sets [`Self::purge_deferred`], which withholds `PrepareOk` for this
+ /// group until the purge lands, so the replica goes quorum-invisible THERE
+ /// while every other partition on the node keeps serving. Without that
+ /// fence the counter would still name the pre-purge offset space and every
+ /// op this replica acked would be stamped from a `base_offset` its purged
+ /// peers do not share. The superblock persist gate does not cover it: that
+ /// fires on `(view, log_view)` changes, and a stable view attempts no write
+ /// and so observes no failure.
+ ///
+ /// Fencing the SEND rather than the whole partition is the point. The
+ /// alternative was fencing a partition whose chain is still whole, which
+ /// quarantines live data and rebuilds it at the pre-purge frontier.
+ ///
+ /// Carries no cause: the write path reports `bool`, and the underlying
+ /// `ENOSPC` / `EIO` is logged by the superblock writer on the first failure
+ /// and at every power-of-two thereafter.
+ FrontierNotRecorded,
+ /// A step after the drain failed, so the partition holds no serviceable
+ /// segment chain and its next append would panic on `active_segment()`.
+ /// The caller must fence this group for rebuild.
+ Unserviceable(IggyError),
+}
+
+impl fmt::Display for PurgeError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::FrontierNotRecorded => write!(
+ f,
+ "could not record the purge's offset-frontier reset; nothing was mutated"
+ ),
+ Self::Unserviceable(source) => write!(
+ f,
+ "purge left the partition without a serviceable chain: {source}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for PurgeError {}
+
#[derive(Debug, Clone, Copy, PartialEq)]
-struct PendingConsumerOffsetCommit {
+pub struct PendingConsumerOffsetCommit {
kind: ConsumerKind,
consumer_id: u32,
mutation: PendingConsumerOffsetMutation,
@@ -223,9 +404,10 @@
}
}
-impl<B> IggyPartition<B>
+impl<B, SB> IggyPartition<B, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
{
pub fn new(stats: Arc<PartitionStats>, consensus: VsrConsensus<B>) -> Self {
let observed_view = consensus.view();
@@ -249,10 +431,25 @@
consumer_offset_enforce_fsync: false,
repair: None,
recovered_durable_offset: None,
+ installed_frontier: None,
pending_consumer_offset_commits: HashMap::new(),
persisted_offsets: RefCell::new(HashMap::new()),
observed_view,
applied_purge_generation: 0,
+ superblock: None,
+ superblock_lock: LocalGate::new(),
+ superblock_write_failures: Cell::new(0),
+ superblock_retry_after_micros: Cell::new(0),
+ purge_deferred: false,
+ durable_offset_frontier: Cell::new(0),
+ transfer: None,
+ transfer_attempts: 0,
+ transfer_failures: 0,
+ transfer_refusals: 0,
+ transfer_rearm: None,
+ segment_checksum_cache: RefCell::new(std::collections::HashMap::new()),
+ reuse_scan_memo: RefCell::new(None),
+ transfer_offer_cache: RefCell::new(None),
};
if single_replica {
partition.log.journal().inner.set_repair_retention(false);
@@ -298,6 +495,414 @@
self.partition_dir = Some(partition_dir);
}
+ /// Attach the durable superblock store the boot path opened for this
+ /// partition's group, along with the record it read back. Boot seeds
+ /// consensus with the recovered `(view, log_view)` and marks them durable
+ /// before attaching; from then on [`Self::persist_superblock_if_needed`]
+ /// keeps the record current.
+ ///
+ /// The record is a PARAMETER rather than a follow-up seeding call because
+ /// the advance direction maxes against its frontier: an attach that left
+ /// that at zero against a record naming N would let the first write after a
+ /// fence lower it, which is the whole defect the field exists to prevent.
+ /// As a separate call it was silently optional, and one of the three attach
+ /// sites dropped it.
+ pub fn set_superblock(&mut self, superblock: Rc<SB>, recovered: Option<&consensus::VsrState>) {
+ self.superblock = Some(superblock);
+ self.durable_offset_frontier
+ .set(recovered.map_or(0, |state| state.offset_frontier));
+ }
+
+ /// Persist this group's VSR state to its superblock when the view changed
+ /// since the last write. The split-brain gate, partition edition: callers
+ /// MUST invoke this before dispatching any view-scoped VSR message for
+ /// this partition, so a replica that acted in a view can never recover an
+ /// older one after a crash.
+ ///
+ /// It fences the SEND, not the ACT. By the time a caller reaches here the
+ /// handler has already moved `view`, `log_view`, `status`, the sequencer
+ /// and the pipeline, and the commit walk runs outside the gate, so a
+ /// failed persist still applies committed ops locally. That is the VSR
+ /// fence and it is sufficient: local state a crash forgets is state no
+ /// peer ever saw, whereas an externalized view must be recoverable.
+ ///
+ /// `true` when the send may proceed, either because the state is now
+ /// durable or because there was nothing to persist (no store attached --
+ /// in-memory / simulated partitions -- or an unchanged view). `false`
+ /// only when a write was attempted and failed, and the caller must
+ /// withhold the send. The in-memory view stays ahead of the durable one,
+ /// which a crash safely rolls back, and the next tick retries.
+ #[allow(clippy::future_not_send)]
+ #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+ pub async fn persist_superblock_if_needed(&self) -> bool {
+ let Some(superblock) = self.superblock.as_ref() else {
+ // No store (in-memory / simulated partitions): nothing can be
+ // recorded, so keep the durable cells current instead. The
+ // dispatch tripwire asserts `needs_superblock_persist()` is clear
+ // on every view-scoped send, and for a storeless group "current"
+ // is trivially true -- leaving the cells behind would trip it on
+ // the first view change.
+ self.consensus
+ .mark_superblock_durable(self.consensus.view(), self.consensus.log_view());
+ return true;
+ };
+ // Lock-free fast path: the steady state is an unchanged view with
+ // nothing to write, and skipping the lock keeps every gated send off
+ // it, notably `send_prepare_ok`, which runs this per prepare. Safe
+ // because `view`/`log_view` advance only on this single-threaded
+ // executor and no `.await` sits between the `Cell` read and the
+ // return; a concurrent advance is caught by the re-check below.
+ if !self.consensus.needs_superblock_persist() {
+ return true;
+ }
+ // A write that keeps failing must not re-run a full `atomic_replace`
+ // on every 10 ms tick. Back off first, while still reporting `false`
+ // so the send stays withheld: fail-closed is the point of this gate,
+ // and the backoff only bounds what the retry costs.
+ if self.superblock_write_is_backed_off() {
+ return false;
+ }
+ // Re-check needs-persist AFTER acquiring the lock so check and write
+ // are atomic and a redundant caller coalesces, finding the state
+ // already made durable by the writer it queued behind.
+ let _superblock_guard = self.superblock_lock.acquire().await;
+ if !self.consensus.needs_superblock_persist() {
+ return true;
+ }
+ self.write_superblock(superblock.as_ref(), self.offset_frontier())
+ .await
+ }
+
+ /// Write the current VSR state under [`Self::superblock_lock`].
+ ///
+ /// The caller must hold that lock. The state is captured HERE rather than
+ /// passed in: with writes serialized and no await between the capture and
+ /// the write, the last writer carries the freshest view, so the durable
+ /// view cannot regress. `mark_superblock_durable` takes the WRITTEN
+ /// values, never a re-read, because the in-memory view can advance across
+ /// the write's `.await`.
+ ///
+ /// # Terminal policy
+ /// There is none beyond staying fenced: a replica that cannot record the
+ /// view it is in must not act in it, so it withholds every view-scoped
+ /// send for this group, goes quiet, and its peers elect around it. Only
+ /// THIS partition's group is fenced; the rest of the node keeps serving.
+ #[allow(clippy::future_not_send)]
+ async fn write_superblock(&self, superblock: &SB, offset_frontier: u64) -> bool {
+ // ADVANCE direction: never below what this replica has already minted,
+ // and never below what the record ALREADY holds. Both bounds are
+ // needed and neither implies the other -- a failed install leaves the
+ // counter behind the record it wrote before the swap, so maxing against
+ // the counter alone lets the fence that follows lower the durable
+ // frontier. The reset direction goes through `write_superblock_inner`.
+ let advanced = offset_frontier
+ .max(self.offset_frontier())
+ .max(self.durable_offset_frontier.get());
+ self.write_superblock_inner(superblock, advanced).await
+ }
+
+ /// The write itself; the advance and reset directions differ only in the
+ /// frontier they hand in.
+ #[allow(clippy::future_not_send)]
+ async fn write_superblock_inner(&self, superblock: &SB, offset_frontier: u64) -> bool {
+ // The pairing fields stay `(0, 0)` and `commit_max` is a dead write
+ // on this plane: nothing reads either back (`restore_partition_view`
+ // restores view/log_view only), because recovery re-derives the
+ // install floor from the installed segments at boot -- a crash after
+ // an install does not re-run the transfer. Written anyway so the
+ // record shape matches the metadata plane's.
+ //
+ // `offset_frontier` is NOT dead: it is the only durable carrier of the
+ // group's offset space once the segments that named it are gone. Every
+ // write stamps the current counter, so whichever write lands last (a
+ // view change, or the explicit persist an install issues) leaves a
+ // lower bound boot can re-seed from.
+ let mut state = self.consensus.vsr_state(0, 0);
+ state.offset_frontier = offset_frontier;
+ match superblock.write(&state.to_bytes()).await {
+ Ok(()) => {
+ self.consensus
+ .mark_superblock_durable(state.view, state.log_view);
+ self.durable_offset_frontier.set(state.offset_frontier);
+ self.superblock_write_failures.set(0);
+ self.superblock_retry_after_micros.set(0);
+ true
+ }
+ Err(error) => {
+ let failures = self.superblock_write_failures.get() + 1;
+ self.superblock_write_failures.set(failures);
+ let backoff = SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS
+ .saturating_mul(1 << failures.min(SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT))
+ .min(SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS);
+ self.superblock_retry_after_micros
+ .set(self.consensus.clock_realtime_micros() + backoff);
+ // Rate-limited to the backoff steps: the tick would otherwise
+ // emit this every 10 ms for as long as the disk stays broken.
+ if failures.is_power_of_two() {
+ tracing::error!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ replica_id = self.consensus.replica(),
+ namespace_raw = self.consensus.namespace(),
+ view = state.view,
+ log_view = state.log_view,
+ superblock_write_failures = failures,
+ retry_in_micros = backoff,
+ %error,
+ "partition superblock persist failed; withholding every view-scoped \
+ send for this group until it succeeds, so this replica stays \
+ quorum-invisible there"
+ );
+ }
+ false
+ }
+ }
+ }
+
+ /// Re-seed the offset counter from a recovered superblock record, taking
+ /// the MAX of what the record holds and what the recovered segments already
+ /// proved.
+ ///
+ /// The record is a lower bound, never a completeness claim: it exists
+ /// because three paths leave a replica whose counter would otherwise
+ /// restart at 0 while the group is at N (a transfer install of an all-GC'd
+ /// origin, a crash inside the install's swap window, and the
+ /// fence-and-rebuild path, which needs no crash at all). Restarting the
+ /// counter is not a lag -- replicas re-stamp `base_offset` from it and
+ /// recompute `batch_checksum` over the result, so the next replicated
+ /// prepare would persist different bytes here than on every peer, silently.
+ ///
+ /// Lives HERE rather than in the server crate so the boot paths and the
+ /// simulator share one implementation. A copy in the harness was a copy of
+ /// the max rule that had lost the max, in the one place built to catch
+ /// violations of it.
+ pub fn restore_offset_frontier(&mut self, recovered: Option<&consensus::VsrState>) {
+ let Some(frontier) = recovered
+ .map(|state| state.offset_frontier)
+ .filter(|&f| f > 0)
+ else {
+ return;
+ };
+ let recovered_end = frontier - 1;
+ if self.should_increment_offset && self.offset.load(Ordering::Acquire) >= recovered_end {
+ return;
+ }
+ tracing::info!(
+ namespace_raw = self.consensus().namespace(),
+ offset_frontier = frontier,
+ "restored partition offset frontier from its superblock"
+ );
+ self.offset.store(recovered_end, Ordering::Release);
+ self.dirty_offset.store(recovered_end, Ordering::Relaxed);
+ self.should_increment_offset = true;
+ self.stats.set_current_offset(recovered_end);
+ }
+
+ /// The next message offset this replica will mint, `0` while the offset
+ /// space is still empty. The value stamped into the durable record.
+ #[must_use]
+ pub fn offset_frontier(&self) -> u64 {
+ if self.should_increment_offset {
+ self.offset.load(Ordering::Acquire).saturating_add(1)
+ } else {
+ 0
+ }
+ }
+
+ /// Force the durable record to catch up with the current offset frontier,
+ /// outside the view-change gate.
+ ///
+ /// [`Self::persist_superblock_if_needed`] fires on `(view, log_view)`
+ /// changes only, which is the right trigger for the split-brain fence and
+ /// the wrong one for the frontier: an install can move the counter by
+ /// millions without touching the view. Called where the frontier changes
+ /// with nothing else durable naming it -- after a state-transfer install
+ /// and after the convergence that follows a failed one. Returns whether the
+ /// record now holds it; a failure is logged by the writer and left to the
+ /// ordinary retry, since the install itself already succeeded.
+ #[allow(clippy::future_not_send)]
+ #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+ pub async fn persist_offset_frontier(&self) -> bool {
+ self.persist_offset_frontier_at(self.offset_frontier())
+ .await
+ }
+
+ /// Record a frontier that may be LOWER than the one already on disk.
+ ///
+ /// The frontier is conditionally monotone: it advances everywhere except a
+ /// purge, which legitimately resets the offset space to 0. The advancing
+ /// form cannot express that -- it maxes against the live counter -- and the
+ /// distinction has to be explicit: a purge that leaves the old frontier
+ /// recorded makes the next boot re-seed the counter to the state the purge
+ /// just erased, and the following append stamps `base_offset` N where every
+ /// peer stamps 0.
+ #[allow(clippy::future_not_send)]
+ #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+ pub async fn reset_offset_frontier(&self) -> bool {
+ self.reset_offset_frontier_at(self.offset_frontier()).await
+ }
+
+ /// [`Self::reset_offset_frontier`] for a frontier the live counter does not
+ /// hold yet.
+ ///
+ /// Two callers need the value spelled out rather than read off the counter.
+ /// A purge records its reset BEFORE it unlinks anything, while the counter
+ /// still names the pre-purge space, so a crash mid-unlink cannot boot into
+ /// a re-seed of the space the purge was erasing. An install under an
+ /// advancing purge generation records the offer's frontier, which is
+ /// legitimately below the local counter: the advancing form would max it
+ /// straight back up and leave the pre-purge value on disk across the swap
+ /// window.
+ #[allow(clippy::future_not_send)]
+ #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+ pub async fn reset_offset_frontier_at(&self, frontier: u64) -> bool {
+ let Some(superblock) = self.superblock.as_ref().map(Rc::clone) else {
+ return true;
+ };
+ if self.superblock_write_is_backed_off() {
+ return false;
+ }
+ let _superblock_guard = self.superblock_lock.acquire().await;
+ self.write_superblock_inner(superblock.as_ref(), frontier)
+ .await
+ }
+
+ /// Record the frontier immediately ahead of an irreversible quarantine,
+ /// BYPASSING the retry backoff.
+ ///
+ /// The gate exists because the other writers' callers became retry loops,
+ /// and skipping a doomed write costs them nothing. This caller is the
+ /// opposite: it writes once and then moves the segments that are the
+ /// record's only corroborating witness into `.fenced.N`, so a skip here is
+ /// not deferred work, it is the last chance gone. A disk that recovered
+ /// inside the backoff window would otherwise leave the rebuild re-seeding
+ /// from a stale record with nothing left to take the max against.
+ ///
+ /// `intended` is the frontier the caller knows the group is at, written
+ /// verbatim; `None` means the live counter is authoritative and the
+ /// advancing form applies.
+ #[allow(clippy::future_not_send)]
+ #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+ pub async fn record_frontier_before_quarantine(&self, intended: Option<u64>) -> bool {
+ let Some(superblock) = self.superblock.as_ref().map(Rc::clone) else {
+ return true;
+ };
+ let _superblock_guard = self.superblock_lock.acquire().await;
+ match intended {
+ Some(frontier) => {
+ self.write_superblock_inner(superblock.as_ref(), frontier)
+ .await
+ }
+ None => {
+ self.write_superblock(superblock.as_ref(), self.offset_frontier())
+ .await
+ }
+ }
+ }
+
+ /// Whether a recent write failure's backoff window is still open.
+ ///
+ /// The same gate [`Self::persist_superblock_if_needed`] applies before its
+ /// own write, extended to the spelled-value writers because their callers
+ /// became retry loops: a deferred purge is re-issued by the reconciler, and
+ /// without this each pass re-runs a full `atomic_replace` against a disk
+ /// that just refused one, as fast as `ENOSPC` returns.
+ fn superblock_write_is_backed_off(&self) -> bool {
+ self.consensus.clock_realtime_micros() < self.superblock_retry_after_micros.get()
+ }
+
+ /// [`Self::persist_offset_frontier`] for a frontier this replica has not
+ /// reached yet.
+ ///
+ /// Used to record an INCOMING frontier before a destructive swap: the
+ /// install unlinks the old chain and fsyncs that before the first staged
+ /// rename lands, and boot sweeps `.log.staging` unconditionally, so a crash
+ /// in that window otherwise leaves no copy of the frontier anywhere. Writing
+ /// the claim first makes it a durable lower bound the whole way through, and
+ /// over-claiming is harmless: the convergence that follows a failed install
+ /// seeds the counter from the same artifact frontier.
+ #[allow(clippy::future_not_send)]
+ #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+ pub async fn persist_offset_frontier_at(&self, frontier: u64) -> bool {
+ let Some(superblock) = self.superblock.as_ref().map(Rc::clone) else {
+ return true;
+ };
+ if self.superblock_write_is_backed_off() {
+ return false;
+ }
+ let _superblock_guard = self.superblock_lock.acquire().await;
+ self.write_superblock(superblock.as_ref(), frontier).await
+ }
+
+ /// Burn one transfer stall round; `true` once the budget is exhausted.
+ /// Lives on the partition, not the session, so a re-minted session
+ /// cannot reset it (see [`Self::transfer_attempts`]).
+ #[must_use = "the bool is the abandon verdict; dropping it disables the stall budget"]
+ pub const fn burn_transfer_attempt(&mut self) -> bool {
+ self.transfer_attempts += 1;
+ self.transfer_attempts > consensus::STATE_TRANSFER_MAX_STALL_RETRIES
+ }
+
+ /// Real transfer progress: reset the stall budget. The budget bounds
+ /// CONSECUTIVE stalls, not lifetime ones; without this a handful of
+ /// stalls scattered across a large transfer would abandon one that was
+ /// nearly done, throwing away every byte already pulled.
+ pub const fn note_transfer_progress(&mut self) {
+ self.transfer_attempts = 0;
+ }
+
+ /// Charge one transfer failure (any class) and return the consecutive
+ /// count; the shard scales its re-arm backoff by it. Never resets on a
+ /// new generation or on received chunks -- a deterministic failure
+ /// re-pulls successfully every round and still must back off -- only
+ /// [`Self::note_transfer_installed`] clears it.
+ pub const fn record_transfer_failure(&mut self) -> u32 {
+ self.transfer_failures = self.transfer_failures.saturating_add(1);
+ self.transfer_failures
+ }
+
+ /// A completed install: the one signal that genuinely proves the
+ /// transfer pipeline works end to end, so it alone resets the
+ /// consecutive-failure count.
+ pub const fn note_transfer_installed(&mut self) {
+ self.transfer_failures = 0;
+ self.transfer_refusals = 0;
+ }
+
+ /// Charge one TRANSIENT refusal and return the consecutive count.
+ ///
+ /// Separate from [`Self::record_transfer_failure`] on purpose: a transient
+ /// refusal must not touch the exponential backoff (the flat retry interval
+ /// is the point), but a partition refused for hours still has to be
+ /// visible, so the count exists only to escalate logging and feed a metric.
+ /// Reset by [`Self::note_transfer_installed`] alongside the failure count.
+ pub const fn record_transfer_refusal(&mut self) -> u32 {
+ self.transfer_refusals = self.transfer_refusals.saturating_add(1);
+ self.transfer_refusals
+ }
+
+ /// A fresh re-arm is scheduled: the stall budget starts over for it.
+ ///
+ /// Carrying an exhausted budget into the next attempt left every later
+ /// session with a single retry-interval window to land its first response --
+ /// against a re-arm backoff climbing to 1024x, and a serving side that
+ /// hashes retained bytes before it can answer, so a slow first response is
+ /// ordinary rather than a stall. The budget bounds consecutive stalls within
+ /// one attempt; `transfer_failures` and its backoff are what bound livelock
+ /// across attempts.
+ pub const fn note_transfer_rearm_scheduled(&mut self) {
+ self.transfer_attempts = 0;
+ }
+
+ /// Read-only view of the stall budget, for diagnostics. The counters
+ /// themselves are private: they are the anti-livelock argument, and the
+ /// docs promise exactly one resetter each -- a `pub` field would let any
+ /// future call site break that silently.
+ #[must_use]
+ pub const fn transfer_attempts(&self) -> u32 {
+ self.transfer_attempts
+ }
+
pub fn configure_consumer_offset_storage(
&mut self,
consumer_offsets_path: String,
@@ -668,7 +1273,11 @@
}
}
- fn persisted_offset_path(&self, kind: ConsumerKind, consumer_id: u32) -> Option<String> {
+ pub(crate) fn persisted_offset_path(
+ &self,
+ kind: ConsumerKind,
+ consumer_id: u32,
+ ) -> Option<String> {
match kind {
ConsumerKind::Consumer => self
.consumer_offsets_path
@@ -920,9 +1529,10 @@
}
}
-impl<B> Partition for IggyPartition<B>
+impl<B, SB> Partition for IggyPartition<B, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
{
async fn append_messages(
&mut self,
@@ -1026,9 +1636,10 @@
}
}
-impl<B> IggyPartition<B>
+impl<B, SB> IggyPartition<B, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
{
#[must_use]
fn namespace(&self) -> IggyNamespace {
@@ -2960,9 +3571,18 @@
/// (see `rotate_segment`); falls back to the config-derived path for
/// in-memory partitions with no directory.
///
+ /// Both files are opened through `SegmentStorage::new` with
+ /// `file_exists = false`, which TRUNCATES them. That is load-bearing, not
+ /// incidental: this offset may already have an `.index` on disk (a crash
+ /// between the state-transfer install's index-rename and log-rename loops
+ /// leaves final-name indexes with no logs, and the boot sweep only reaches
+ /// the ones still orphaned at startup). The `partitions`-side writers with
+ /// the same names do NOT truncate, so a recreate path that opened them
+ /// directly would read index entries from a previous generation.
+ ///
/// # Errors
/// If the segment's log / index file cannot be created.
- async fn install_empty_segment(
+ pub(crate) async fn install_empty_segment(
&mut self,
config: &PartitionsConfig,
start_offset: u64,
@@ -3034,6 +3654,55 @@
Ok(())
}
+ /// Record the purge's frontier reset BEFORE the purge touches anything.
+ ///
+ /// The unlinks are made durable by their own directory fsync, so a crash
+ /// between them and a reset written afterwards boots a purged directory
+ /// whose record still names the pre-purge offset space:
+ /// `restore_offset_frontier` re-seeds the counter to it while every peer
+ /// restarted at 0, and the first append stamps a `base_offset` and
+ /// `batch_checksum` no peer shares. Writing 0 first inverts the window into
+ /// a harmless one -- the record under-claims while the segments still
+ /// exist, and boot takes the max of the record and what the segments prove.
+ ///
+ /// Spelled out rather than read off the counter, which still holds the
+ /// pre-purge frontier at this point.
+ ///
+ /// # Errors
+ /// [`PurgeError::FrontierNotRecorded`]. Refused rather than logged: nothing
+ /// has been mutated yet, and a purge that cannot record its reset must not
+ /// be the one that erases the data proving the old frontier. The caller
+ /// RETRIES; it must not fence, since the chain is still whole and the live
+ /// counter still names the pre-purge space.
+ #[allow(clippy::future_not_send)]
+ async fn record_purge_frontier_reset(&mut self, generation: u64) -> Result<(), PurgeError> {
+ if self.reset_offset_frontier_at(0).await {
+ self.purge_deferred = false;
+ return Ok(());
+ }
+ self.purge_deferred = true;
+ // The ONLY operator-visible signal for the withhold: `send_prepare_ok`
+ // returns silently, correctly, since it runs per prepare. So this line
+ // has to say that the replica is now out of quorum for this group, or
+ // the symptom reads as a network fault. The consecutive count
+ // correlates it with the superblock writer's own error log, which
+ // carries the `ENOSPC` / `EIO` cause but is rate-limited to
+ // power-of-two failures, while this deferral repeats per reconciler
+ // pass.
+ warn!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ namespace_raw = self.namespace().inner(),
+ generation,
+ superblock_write_failures = self.superblock_write_failures.get(),
+ "cannot record the purge's offset-frontier reset; deferring the purge so the \
+ durable frontier cannot outlive the data it describes. This replica now \
+ withholds PrepareOk for this partition until the purge lands, so it is \
+ quorum-invisible there; its other partitions are unaffected"
+ );
+ Err(PurgeError::FrontierNotRecorded)
+ }
+
/// Reset the partition to a single empty segment at offset 0 and clear all
/// consumer / consumer-group offsets (memory + disk). This is the local
/// effect of a committed `PurgeTopic`: it wipes message data and offsets but
@@ -3045,17 +3714,27 @@
/// `PurgeTopic` advances the committed generation and triggers a fresh pass).
///
/// # Errors
- /// If the replacement segment's log / index file cannot be created.
+ /// [`PurgeError::FrontierNotRecorded`] before anything is mutated, which
+ /// the caller RETRIES: the reconciler re-issues the purge while
+ /// `committed > applied`, and fencing a partition that still holds its whole
+ /// chain would quarantine live data behind a counter that still names the
+ /// pre-purge offset space. [`PurgeError::Unserviceable`] once the drain has
+ /// run, which the caller FENCES (quarantine + retire for the reconciler to
+ /// rebuild), exactly as the state-transfer install's `ConvergeFailed` arm
+ /// does, or the next append panics on `active_segment()`.
+ #[allow(clippy::too_many_lines)]
pub async fn purge(
&mut self,
config: &PartitionsConfig,
generation: u64,
- ) -> Result<(), IggyError> {
+ ) -> Result<(), PurgeError> {
let write_lock = self.write_lock.clone();
let _guard = write_lock.lock().await;
let namespace = self.namespace();
+ self.record_purge_frontier_reset(generation).await?;
+
// The purge recreates segment files at the paths it unlinks below, so
// an in-flight poll's cached read fd would keep serving the unlinked
// pre-purge inodes as live data. Wipe the shared read-state slots
@@ -3092,19 +3771,57 @@
}
}
- // Recreate a fresh empty segment at offset 0 with real writers.
- let start_offset = 0u64;
- self.install_empty_segment(config, start_offset).await?;
+ // An in-flight state transfer was pulling the PRE-purge state: its
+ // staged segments hold data this purge just deleted, and letting the
+ // session complete renames it back in -- durably, because the install
+ // takes `max(offer generation, applied)` and this purge already stamped
+ // the newer generation, so the reconciler's purge gate never re-fires
+ // and the resurrected data outlives the process. Drop the session,
+ // cancel the scheduled re-arm, release the transfer stage so the
+ // ordinary triggers can arm a fresh one, and sweep the staged bytes.
+ self.transfer = None;
+ self.transfer_rearm = None;
+ let consensus = self.consensus();
+ if consensus.state_transfer_stage() != consensus::StateTransferStage::Idle {
+ consensus.set_state_transfer_stage(consensus::StateTransferStage::Idle);
+ }
+ self.reuse_scan_memo.borrow_mut().take();
+ if let Some(partition_dir) = self.partition_dir.clone() {
+ crate::state_transfer::sweep_staging_except(&partition_dir, &HashSet::new()).await;
+ }
- // Reset the offset counters so new messages start at offset 0.
+ let start_offset = 0u64;
+ // Counters reset BEFORE the fallible plant, not after: `?` on
+ // `install_empty_segment` would otherwise leave the live counter at the
+ // pre-purge value, which is what the router's purge-failure fence then
+ // records and what a restart would re-seed. Safe to reorder --
+ // `install_empty_segment` takes `start_offset` as a parameter and never
+ // reads the counter, and the partition write lock is held across this
+ // whole body.
self.offset.store(start_offset, Ordering::Release);
self.dirty_offset.store(start_offset, Ordering::Relaxed);
self.should_increment_offset = false;
+
+ // Recreate a fresh empty segment at offset 0 with real writers. Every
+ // segment is drained by now, so a failure here is the fence case.
+ self.install_empty_segment(config, start_offset)
+ .await
+ .map_err(PurgeError::Unserviceable)?;
+ // Make the unlinks AND the replanted dirent durable together: without
+ // this a crash can resurrect pre-purge segments until the boot re-purge
+ // fires. Bounded and self-healing, but the fsync is one call.
+ if let Some(partition_dir) = self.partition_dir.clone() {
+ let _ = crate::state_transfer::fsync_dir(&partition_dir).await;
+ }
// The boot-time durable line marks recovered bytes that must not be
// re-persisted, but the purge just deleted those bytes and offsets
// restart at 0. Keeping it would make every post-purge batch at or
// below the old line evict silently without ever reaching a segment.
+ // The installed frontier goes with it: the offset space genuinely
+ // restarts, so nothing "stands in" below any floor anymore.
self.recovered_durable_offset = None;
+ self.installed_frontier = None;
+ self.segment_checksum_cache.borrow_mut().clear();
// Clear consumer + consumer-group offsets (memory + disk). Collect the
// file paths before deleting so the map guard is not held across an
@@ -3138,6 +3855,19 @@
for path in consumer_paths.into_iter().chain(group_paths) {
let _ = delete_persisted_offset(&path).await;
}
+ // Directory fsync so those unlinks stick, mirroring the install path: a
+ // crash right after the purge otherwise resurrects the offset files at
+ // boot, and while recovery clamps a resurrected offset down to the
+ // rebuilt head, "consumed through 0" is not the intended "no entry at
+ // all" -- that consumer skips the first post-purge message.
+ for dir in self
+ .consumer_offsets_path
+ .clone()
+ .into_iter()
+ .chain(self.consumer_group_offsets_path.clone())
+ {
+ let _ = crate::state_transfer::fsync_dir(&dir).await;
+ }
// The persisted-offset tracker mirrors the files unlinked above; a
// stale entry would make a post-purge auto-commit skip its write and
// lose the offset on restart.
@@ -3154,6 +3884,23 @@
self.stats.increment_segments_count(1);
self.applied_purge_generation = generation;
+ // Same commit frontier, different (now empty) bytes: a cached offer
+ // built pre-purge would advertise files the purge just unlinked.
+ self.transfer_offer_cache.borrow_mut().take();
+ // The reset itself already landed before the unlinks; this second write
+ // only re-stamps the record now that the view-scoped fields and the
+ // counter agree with it. A failure leaves the pre-unlink 0 on disk,
+ // which is the safe direction, so it is logged rather than refused.
+ if !self.reset_offset_frontier().await {
+ warn!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ namespace_raw = namespace.inner(),
+ generation,
+ "purge could not re-stamp the superblock after resetting the partition; \
+ the frontier reset written before the unlinks still stands"
+ );
+ }
Ok(())
}
@@ -3244,9 +3991,9 @@
/// peer's eviction point (everything below it is represented by this
/// replica's recovered segments + offset files) and walk the repaired
/// window through the normal commit path.
- pub async fn complete_repair(&mut self, config: &PartitionsConfig) {
+ pub async fn complete_repair(&mut self, config: &PartitionsConfig) -> RepairConclusion {
let Some(session) = self.repair else {
- return;
+ return RepairConclusion::Done;
};
if let Some(floor) = session.floor {
// A peer may have evicted past this replica's commit frontier;
@@ -3261,8 +4008,15 @@
// would silently serve a holed log. Refuse and stay gap-stopped:
// a visible stall beats invisible loss.
let durable_end = self.recovered_durable_offset;
- let connected = match (session.first_batch_offset, durable_end) {
- (Some(first), Some(durable)) => first <= durable.saturating_add(1),
+ // Recovered bytes and an installed frontier both "stand in"
+ // below the floor; a window connecting to either is whole. `None`
+ // orders below every `Some`, so the join covers all four
+ // combinations.
+ let stand_in = durable_end
+ .map(|durable| durable.saturating_add(1))
+ .max(self.installed_frontier);
+ let connected = match (session.first_batch_offset, stand_in) {
+ (Some(first), Some(bound)) => first <= bound,
(Some(first), None) => first == 0,
// No repaired batch arrived, so there is no offset anchor to
// verify the floor's continuum claim against. `None` is only
@@ -3286,7 +4040,25 @@
to recovered durable state (needs state transfer)"
);
self.commit_journal(config).await;
- return;
+ // A refusal is DEFINITIVE only once the window itself is
+ // fully present (or provably empty): until then more frames
+ // can still lower `first_batch_offset` into connection, so
+ // the session stays armed and the stall retry re-requests.
+ // A complete window that still cannot connect will never
+ // improve -- the peer retains nothing below the floor and
+ // this replica holds nothing either -- and an EMPTY window
+ // (everything evicted) re-raises identically every round.
+ // Both are the state-transfer trigger; the session is
+ // dropped here so the caller's arming funnel starts clean,
+ // and a transfer-unavailable fallback re-arms repair fresh.
+ if self.repaired_window_is_complete(floor, session.to_op) {
+ self.repair = None;
+ return RepairConclusion::FloorRefused {
+ floor,
+ to_op: session.to_op,
+ };
+ }
+ return RepairConclusion::InProgress;
}
let commit_min = self.consensus().commit_min();
if floor > commit_min {
@@ -3317,6 +4089,22 @@
done,
"repair window commit walk finished"
);
+ if done {
+ RepairConclusion::Done
+ } else {
+ RepairConclusion::InProgress
+ }
+ }
+
+ /// Whether every op in `(floor, to_op]` is journaled. An empty window
+ /// (`floor >= to_op`) counts as complete: there is nothing left that
+ /// could arrive and change the floor verdict.
+ fn repaired_window_is_complete(&self, floor: u64, to_op: u64) -> bool {
+ self.log
+ .journal()
+ .inner
+ .repaired_window_shape(floor, to_op)
+ .complete
}
/// Whether the served repair window `(floor, to_op]` arrived complete and
@@ -3329,13 +4117,8 @@
if floor >= to_op {
return false;
}
- ((floor + 1)..=to_op).all(|op| {
- self.log
- .journal()
- .inner
- .header_by_op(op)
- .is_some_and(|header| header.operation != Operation::SendMessages)
- })
+ let shape = self.log.journal().inner.repaired_window_shape(floor, to_op);
+ shape.complete && !shape.holds_messages
}
/// Journal a repaired `SendMessages` prepare, preserving its embedded
@@ -3398,6 +4181,24 @@
}
async fn send_prepare_ok(&self, header: &PrepareHeader) {
+ // Durable-before-send: a PrepareOk implies this replica's
+ // (view, log_view), so it must not leave until they are durable, or a
+ // crash could recover an older view than the one this ack helped
+ // commit in, losing a committed op. Mirrors the view-change dispatch
+ // gate; withhold on persist failure and let the primary's prepare
+ // retransmit re-drive the ack once a later persist succeeds.
+ if !self.persist_superblock_if_needed().await {
+ return;
+ }
+ // Same fail-closed shape for a purge this replica accepted but has not
+ // applied: its counter still names the pre-purge offset space, so an ack
+ // now helps commit an op it will stamp differently from every peer that
+ // did apply. The primary's retransmit re-drives the ack once the purge
+ // lands. Local commits still apply -- this fences the SEND, exactly as
+ // the durability gate above does.
+ if self.purge_deferred {
+ return;
+ }
// `VsrAction::RetransmitPrepares` reads from `self.log.journal`.
// Both `SendMessages` (via `append_send_messages_to_journal`) and
// consumer-offset ops (via `apply_replicated_operation`) append
@@ -3634,12 +4435,356 @@
)
}
+ /// Partition whose consensus already advanced to `(view, log_view)` with
+ /// nothing marked durable, as after a view change and before the persist
+ /// gate runs.
+ fn partition_at_view(
+ view: u32,
+ log_view: u32,
+ ) -> IggyPartition<IggyMessageBus, RecordingSuperblock> {
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let mut consensus = VsrConsensus::new(
+ TEST_CLUSTER,
+ 0,
+ 3,
+ namespace.inner(),
+ IggyMessageBus::new(0),
+ LocalPipeline::new(),
+ );
+ consensus.set_view(view);
+ consensus.set_log_view(log_view);
+ consensus.init_as_backup();
+ IggyPartition::with_in_memory_storage(
+ Arc::new(PartitionStats::default()),
+ consensus,
+ IggyByteSize::from(1024 * 1024),
+ false,
+ )
+ }
+
+ /// In-memory superblock double: records every payload, counts attempts,
+ /// and injects write failures.
+ #[derive(Default)]
+ struct RecordingSuperblock {
+ writes: RefCell<Vec<Vec<u8>>>,
+ attempts: Cell<u32>,
+ fail_writes: Cell<bool>,
+ }
+
+ impl journal::superblock::SuperblockStore for RecordingSuperblock {
+ async fn write(&self, payload: &[u8]) -> std::io::Result<()> {
+ self.attempts.set(self.attempts.get() + 1);
+ if self.fail_writes.get() {
+ return Err(std::io::Error::other("injected superblock write failure"));
+ }
+ self.writes.borrow_mut().push(payload.to_vec());
+ Ok(())
+ }
+
+ async fn read_latest(&self) -> std::io::Result<journal::superblock::SuperblockContents> {
+ Ok(self
+ .writes
+ .borrow()
+ .last()
+ .map_or(journal::superblock::SuperblockContents::Empty, |bytes| {
+ journal::superblock::SuperblockContents::Present(bytes.clone())
+ }))
+ }
+ }
+
+ #[compio::test]
+ async fn given_storeless_partition_when_persist_gate_runs_should_mark_current_view_durable() {
+ let partition = partition_at_view(2, 1);
+ assert!(partition.consensus().needs_superblock_persist());
+
+ assert!(partition.persist_superblock_if_needed().await);
+
+ assert!(
+ !partition.consensus().needs_superblock_persist(),
+ "a storeless partition must record durable = current, or the dispatch \
+ tripwire would fire on its first view-scoped send"
+ );
+ }
+
+ #[compio::test]
+ async fn given_advanced_view_when_persist_gate_runs_should_write_vsr_state_once() {
+ let mut partition = partition_at_view(3, 2);
+ let store = Rc::new(RecordingSuperblock::default());
+ partition.set_superblock(store.clone(), None);
+
+ assert!(partition.persist_superblock_if_needed().await);
+
+ let state = consensus::VsrState::try_from(store.writes.borrow()[0].as_slice())
+ .expect("recorded payload decodes as a VsrState");
+ assert_eq!(state.cluster, TEST_CLUSTER);
+ assert_eq!(state.view, 3);
+ assert_eq!(state.log_view, 2);
+ assert_eq!(
+ (state.checkpoint_op, state.checkpoint_checksum),
+ (0, 0),
+ "no partition checkpoint exists yet, so the pairing fields stay zero"
+ );
+ assert!(!partition.consensus().needs_superblock_persist());
+
+ assert!(partition.persist_superblock_if_needed().await);
+ assert_eq!(
+ store.attempts.get(),
+ 1,
+ "an unchanged view must take the lock-free fast path, not rewrite"
+ );
+ }
+
+ /// The `offset_frontier` of the most recent recorded write.
+ fn last_recorded_frontier(store: &RecordingSuperblock) -> u64 {
+ let writes = store.writes.borrow();
+ let bytes = writes.last().expect("a superblock write landed");
+ consensus::VsrState::try_from(bytes.as_slice())
+ .expect("recorded payload decodes as a VsrState")
+ .offset_frontier
+ }
+
+ /// The fence path persists the frontier while the live counter still sits
+ /// at its pre-install value, so an advance that maxes against the counter
+ /// alone erases the record and then quarantines the segments that were its
+ /// only other witness. Boot re-mints from 0 against a group at N after that.
+ #[compio::test]
+ async fn given_record_above_live_counter_when_advancing_should_keep_the_record() {
+ let mut partition = partition_at_view(1, 1);
+ let store = Rc::new(RecordingSuperblock::default());
+ partition.set_superblock(store.clone(), None);
+
+ assert!(partition.persist_offset_frontier_at(9_000).await);
+ assert_eq!(last_recorded_frontier(&store), 9_000);
+ assert_eq!(
+ partition.offset_frontier(),
+ 0,
+ "a partition that never minted reports a zero frontier, which is the \
+ value the fence would otherwise persist"
+ );
+
+ assert!(partition.persist_offset_frontier().await);
+
+ assert_eq!(
+ last_recorded_frontier(&store),
+ 9_000,
+ "the advance direction must not lower the durable frontier"
+ );
+ }
+
+ /// Attaching a store seeds the last-written frontier from the record
+ /// itself, so an advance maxes against what boot read off disk even before
+ /// this replica has written anything. The sibling test reaches that state by
+ /// WRITING first, which cannot catch an attach site that skips the seed.
+ #[compio::test]
+ async fn given_attached_record_when_advancing_should_keep_the_recorded_frontier() {
+ let mut partition = partition_at_view(1, 1);
+ let store = Rc::new(RecordingSuperblock::default());
+ let recovered = consensus::VsrState {
+ cluster: TEST_CLUSTER,
+ replica_id: 0,
+ replica_count: 3,
+ view: 1,
+ log_view: 1,
+ commit_max: 0,
+ checkpoint_op: 0,
+ checkpoint_checksum: 0,
+ offset_frontier: 4_200,
+ };
+ partition.set_superblock(store.clone(), Some(&recovered));
+ assert_eq!(partition.offset_frontier(), 0, "nothing minted locally");
+
+ assert!(partition.persist_offset_frontier().await);
+
+ assert_eq!(
+ last_recorded_frontier(&store),
+ 4_200,
+ "the first write after an attach must not lower the record it was attached to"
+ );
+ }
+
+ /// The reset direction is the only way down, and it must actually go there:
+ /// an install under an advancing purge generation records a frontier below
+ /// the live counter on purpose.
+ #[compio::test]
+ async fn given_reset_below_live_counter_when_written_should_lower_the_record() {
+ let mut partition = partition_at_view(1, 1);
+ let store = Rc::new(RecordingSuperblock::default());
+ partition.set_superblock(store.clone(), None);
+ partition.offset.store(9_000, Ordering::Release);
+ partition.should_increment_offset = true;
+
+ assert!(partition.persist_offset_frontier().await);
+ assert_eq!(last_recorded_frontier(&store), 9_001);
+
+ assert!(partition.reset_offset_frontier_at(12).await);
+
+ assert_eq!(
+ last_recorded_frontier(&store),
+ 12,
+ "the reset must record the incoming frontier, not max back up to the \
+ counter the install is about to replace"
+ );
+ }
+
+ /// A purge records its reset before it unlinks anything, so a write it
+ /// cannot make has to stop the purge while the data proving the old
+ /// frontier is still on disk.
+ #[compio::test]
+ async fn given_failing_store_when_purge_records_its_reset_should_refuse_before_mutating() {
+ let mut partition = partition_at_view(1, 1);
+ let store = Rc::new(RecordingSuperblock::default());
+ partition.set_superblock(store.clone(), None);
+ partition.offset.store(9_000, Ordering::Release);
+ partition.should_increment_offset = true;
+
+ store.fail_writes.set(true);
+ assert!(
+ matches!(
+ partition.record_purge_frontier_reset(7).await,
+ Err(PurgeError::FrontierNotRecorded)
+ ),
+ "the pre-mutation refusal must be distinguishable from a post-drain \
+ failure: the caller retries this one and fences the other"
+ );
+
+ assert!(
+ partition.purge_deferred,
+ "a deferred purge must fence the ack path: the counter still names the \
+ pre-purge offset space, and the view-change persist gate cannot see \
+ this because a stable view attempts no write at all"
+ );
+
+ store.fail_writes.set(false);
+ assert!(
+ matches!(
+ partition.record_purge_frontier_reset(7).await,
+ Err(PurgeError::FrontierNotRecorded)
+ ),
+ "the failed write armed a backoff, and the retry must respect it rather \
+ than re-running a full atomic_replace against a disk that just refused one"
+ );
+ assert_eq!(
+ store.attempts.get(),
+ 1,
+ "the backed-off retry must not reach the store at all"
+ );
+
+ // Backoff expiry, without a controllable clock in this fixture.
+ partition.superblock_retry_after_micros.set(0);
+ partition
+ .record_purge_frontier_reset(7)
+ .await
+ .expect("a working store records the reset once the backoff elapses");
+ assert!(
+ !partition.purge_deferred,
+ "recording the reset releases the fence"
+ );
+ assert_eq!(
+ last_recorded_frontier(&store),
+ 0,
+ "the reset is spelled out, not read off a counter still holding the \
+ pre-purge frontier"
+ );
+ }
+
+ #[compio::test]
+ async fn given_undurable_view_when_sending_prepare_ok_should_withhold_until_persisted() {
+ let bus = RecordingBus::default();
+ let replica_frames = bus.sent_to_replicas.clone();
+ let mut consensus = VsrConsensus::new(
+ TEST_CLUSTER,
+ 0,
+ 3,
+ IggyNamespace::new(1, 1, 0).inner(),
+ bus,
+ LocalPipeline::new(),
+ );
+ consensus.set_view(1);
+ consensus.set_log_view(1);
+ consensus.init_as_backup();
+ let mut partition: IggyPartition<RecordingBus, RecordingSuperblock> =
+ IggyPartition::with_in_memory_storage(
+ Arc::new(PartitionStats::default()),
+ consensus,
+ IggyByteSize::from(1024 * 1024),
+ false,
+ );
+ let store = Rc::new(RecordingSuperblock::default());
+ store.fail_writes.set(true);
+ partition.set_superblock(store.clone(), None);
+ // The ack path drops an op past the local head, so the head must cover it.
+ partition.consensus().sequencer().set_sequence(1);
+ let size = std::mem::size_of::<PrepareHeader>();
+ let prepare = Message::<PrepareHeader>::new(size).transmute_header(
+ |_, header: &mut PrepareHeader| {
+ header.command = Command2::Prepare;
+ header.op = 1;
+ // Current view: an older-view prepare is fenced as deposed-primary
+ // traffic and would never reach the ack send under test.
+ header.view = 1;
+ header.size = u32::try_from(size).expect("prepare header size fits in u32");
+ },
+ );
+ let header = *prepare.header();
+
+ partition.send_prepare_ok(&header).await;
+
+ assert!(
+ replica_frames.borrow().is_empty(),
+ "an ack must not leave while the advanced view is not durable"
+ );
+ assert_eq!(store.attempts.get(), 1);
+
+ // Outwait the write-failure backoff (base 10 ms doubled once by the
+ // first failure), then retry with the store healthy: the ack must
+ // persist first and then go out.
+ store.fail_writes.set(false);
+ compio::time::sleep(std::time::Duration::from_millis(50)).await;
+ partition.send_prepare_ok(&header).await;
+
+ assert_eq!(
+ replica_frames.borrow().len(),
+ 1,
+ "the retried ack must go out once the view persisted"
+ );
+ assert!(!partition.consensus().needs_superblock_persist());
+ }
+
+ #[compio::test]
+ async fn given_failing_superblock_when_persist_gate_runs_should_withhold_and_back_off() {
+ let mut partition = partition_at_view(1, 1);
+ let store = Rc::new(RecordingSuperblock::default());
+ store.fail_writes.set(true);
+ partition.set_superblock(store.clone(), None);
+
+ assert!(
+ !partition.persist_superblock_if_needed().await,
+ "a failed write must withhold the send"
+ );
+ assert_eq!(store.attempts.get(), 1);
+
+ assert!(
+ !partition.persist_superblock_if_needed().await,
+ "the backoff window must withhold without retrying the write"
+ );
+ assert_eq!(
+ store.attempts.get(),
+ 1,
+ "a call inside the backoff window must not touch the store"
+ );
+ assert!(
+ partition.consensus().needs_superblock_persist(),
+ "the view stays undurable until a write lands"
+ );
+ }
+
/// Client-facing bus that records every `send_to_client` frame so tests
/// can assert on reply bytes without a connection registry (whose slot
/// guard would borrow the partition across `on_request(&mut self)`).
#[derive(Debug, Default)]
struct RecordingBus {
sent_to_clients: Rc<RefCell<Vec<(u128, Frozen<MESSAGE_ALIGN>)>>>,
+ sent_to_replicas: Rc<RefCell<Vec<(u8, Frozen<MESSAGE_ALIGN>)>>>,
}
impl MessageBus for RecordingBus {
@@ -3656,9 +4801,10 @@
async fn send_to_replica(
&self,
- _replica: u8,
- _data: Frozen<MESSAGE_ALIGN>,
+ replica: u8,
+ data: Frozen<MESSAGE_ALIGN>,
) -> Result<(), SendError> {
+ self.sent_to_replicas.borrow_mut().push((replica, data));
Ok(())
}
@@ -4733,13 +5879,58 @@
}
#[compio::test]
+ async fn given_session_remint_when_attempts_burned_should_survive_on_partition() {
+ let mut partition = test_partition();
+ for round in 0..consensus::STATE_TRANSFER_MAX_STALL_RETRIES {
+ assert!(!partition.burn_transfer_attempt());
+ // A re-minted session must not reset the budget: it lives on the
+ // partition precisely because arming sites mint fresh sessions.
+ partition.transfer = Some(crate::state_transfer::PartitionTransferSession {
+ nonce: u128::from(round),
+ peer: 0,
+ commit_op: 0,
+ artifacts: Vec::new(),
+ target_accepted: false,
+ idle_ticks: 0,
+ });
+ }
+ assert!(partition.burn_transfer_attempt(), "budget exhausts");
+ partition.note_transfer_progress();
+ assert!(!partition.burn_transfer_attempt(), "progress resets it");
+ }
+
+ #[compio::test]
+ async fn given_repeated_failures_when_only_generation_advances_should_keep_counting() {
+ let mut partition = test_partition();
+ // A committing primary advances its generation every round; the
+ // consecutive count must keep growing regardless, or a
+ // deterministic local failure retries at network round-trip rate
+ // forever. Only a completed install resets it.
+ assert_eq!(partition.record_transfer_failure(), 1);
+ assert_eq!(partition.record_transfer_failure(), 2);
+ partition.note_transfer_progress();
+ assert_eq!(
+ partition.record_transfer_failure(),
+ 3,
+ "received chunks are not install progress"
+ );
+ partition.note_transfer_installed();
+ assert_eq!(partition.record_transfer_failure(), 1, "install resets");
+ }
+
+ #[compio::test]
async fn given_no_repaired_batch_when_window_never_arrived_should_refuse_commit_floor() {
let mut partition = test_partition();
partition.consensus().advance_commit_max(8);
partition.repair = Some(armed_session(8, 5, None));
- partition.complete_repair(&repair_config()).await;
+ let conclusion = partition.complete_repair(&repair_config()).await;
+ assert_eq!(
+ conclusion,
+ RepairConclusion::InProgress,
+ "an incomplete window is not a definitive refusal"
+ );
assert_eq!(partition.consensus().commit_min(), 0);
assert!(
partition.repair.is_some(),
@@ -4759,8 +5950,9 @@
}
partition.repair = Some(armed_session(8, 5, None));
- partition.complete_repair(&repair_config()).await;
+ let conclusion = partition.complete_repair(&repair_config()).await;
+ assert_eq!(conclusion, RepairConclusion::Done);
assert!(partition.consensus().commit_min() >= 5);
}
@@ -4774,9 +5966,18 @@
}
partition.repair = Some(armed_session(8, 5, None));
- partition.complete_repair(&repair_config()).await;
+ let conclusion = partition.complete_repair(&repair_config()).await;
+ assert_eq!(
+ conclusion,
+ RepairConclusion::FloorRefused { floor: 5, to_op: 8 },
+ "a complete window with an unanchored message op can never connect"
+ );
assert_eq!(partition.consensus().commit_min(), 0);
+ assert!(
+ partition.repair.is_none(),
+ "a definitive refusal hands recovery to state transfer"
+ );
}
#[compio::test]
@@ -4785,10 +5986,17 @@
partition.consensus().advance_commit_max(8);
partition.repair = Some(armed_session(8, 8, None));
- partition.complete_repair(&repair_config()).await;
+ let conclusion = partition.complete_repair(&repair_config()).await;
+ // Everything the peer retained was evicted: a retry re-raises the
+ // identical empty window every round (the wedge state transfer
+ // exists to break), so this refusal is definitive.
+ assert_eq!(
+ conclusion,
+ RepairConclusion::FloorRefused { floor: 8, to_op: 8 }
+ );
assert_eq!(partition.consensus().commit_min(), 0);
- assert!(partition.repair.is_some());
+ assert!(partition.repair.is_none());
}
#[compio::test]
@@ -4801,9 +6009,214 @@
// locally durable nor repaired.
partition.repair = Some(armed_session(8, 5, Some(3)));
- partition.complete_repair(&repair_config()).await;
+ let conclusion = partition.complete_repair(&repair_config()).await;
+ assert_eq!(
+ conclusion,
+ RepairConclusion::InProgress,
+ "with the window incomplete, later frames can still lower the \
+ first batch offset into connection"
+ );
assert_eq!(partition.consensus().commit_min(), 0);
+ assert!(partition.repair.is_some());
+ }
+ /// Temp partition directory for the state-transfer fence specs below.
+ async fn transfer_fence_dir(label: &str) -> String {
+ let dir = std::env::temp_dir().join(format!(
+ "iggy-transfer-fence-{label}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_nanos(),
+ ));
+ compio::fs::create_dir_all(&dir)
+ .await
+ .expect("create temp partition dir");
+ dir.to_string_lossy().into_owned()
+ }
+
+ fn armed_transfer(peer: u8) -> crate::state_transfer::PartitionTransferSession {
+ crate::state_transfer::PartitionTransferSession {
+ nonce: 7,
+ peer,
+ commit_op: 12,
+ artifacts: Vec::new(),
+ target_accepted: true,
+ idle_ticks: 0,
+ }
+ }
+
+ /// A purge must not leave a transfer running: its staged segments hold
+ /// PRE-purge data, and completing the install renames it back in durably
+ /// (the install takes `max(offer generation, applied)`, and this purge
+ /// already stamped the newer one, so the reconciler's purge gate never
+ /// re-fires).
+ #[compio::test]
+ async fn given_armed_transfer_when_purged_should_abandon_session_and_rearm() {
+ let partition_dir = transfer_fence_dir("purge-abandons").await;
+ let mut partition = test_partition();
+ partition.set_partition_dir(partition_dir.clone());
+ partition.transfer = Some(armed_transfer(1));
+ partition.transfer_rearm = Some(crate::state_transfer::PendingTransferRearm {
+ peer: 2,
+ after_ticks: 5,
+ });
+ partition.consensus().begin_state_transfer_await();
+
+ partition
+ .purge(&repair_config(), 3)
+ .await
+ .expect("purge partition");
+
+ assert!(
+ partition.transfer.is_none(),
+ "purge must drop the in-flight transfer session"
+ );
+ assert!(
+ partition.transfer_rearm.is_none(),
+ "purge must cancel the scheduled re-arm"
+ );
+ assert_eq!(
+ partition.consensus().state_transfer_stage(),
+ consensus::StateTransferStage::Idle,
+ "purge must release the transfer stage so a later trigger can arm"
+ );
+
+ let _ = std::fs::remove_dir_all(&partition_dir);
+ }
+
+ /// An offer whose frontier sits below this replica's own offset counter is
+ /// refused: installing it would rewind the counter, and the next replicated
+ /// prepare is re-stamped from it, so this replica would persist different
+ /// bytes (and a different `batch_checksum`) than the rest of the group.
+ #[compio::test]
+ async fn given_offer_below_local_counter_when_installed_should_refuse_rewind() {
+ let partition_dir = transfer_fence_dir("rewind-refused").await;
+ let mut partition = test_partition();
+ partition.set_partition_dir(partition_dir.clone());
+ partition.should_increment_offset = true;
+ partition.offset.store(99, Ordering::Release);
+
+ let behind = crate::state_transfer::ConsumerOffsetsWire {
+ purge_generation: 0,
+ next_offset: 50,
+ consumers: Vec::new(),
+ groups: Vec::new(),
+ };
+ let refused = partition
+ .install_state_transfer(&repair_config(), 12, Vec::new(), &behind.encode(), 0)
+ .await;
+ assert!(
+ matches!(
+ refused,
+ Err(
+ crate::state_transfer::PartitionInstallError::OfferRewindsDurableData {
+ offer_next_offset: 50,
+ local_next_offset: 100,
+ }
+ )
+ ),
+ "expected a rewind refusal, got {refused:?}"
+ );
+
+ // A purge at the origin is the one legitimate rewind, and the artifact
+ // carries the generation that proves it: the same offer passes the fence
+ // once its generation advances past the COMMITTED one the caller reads
+ // off the metadata plane (0 here), not past this replica's memory-only
+ // applied value.
+ let purged = crate::state_transfer::ConsumerOffsetsWire {
+ purge_generation: 1,
+ next_offset: 0,
+ consumers: Vec::new(),
+ groups: Vec::new(),
+ };
+ let accepted = partition
+ .install_state_transfer(&repair_config(), 12, Vec::new(), &purged.encode(), 0)
+ .await;
+ assert!(
+ !matches!(
+ accepted,
+ Err(crate::state_transfer::PartitionInstallError::OfferRewindsDurableData { .. })
+ ),
+ "a purge-advancing offer must pass the rewind fence, got {accepted:?}"
+ );
+
+ let _ = std::fs::remove_dir_all(&partition_dir);
+ }
+
+ /// The canonical post-restart rejoin: this replica applied a purge before
+ /// the restart, so the metadata plane's COMMITTED generation is 1 while its
+ /// own memory-only `applied_purge_generation` is back at 0. Gated on the
+ /// local field, `offered(1) > applied(0)` reads as an advancing purge and
+ /// disables the rewind refusal -- on the one path it exists to guard.
+ #[compio::test]
+ async fn given_restarted_replica_when_offer_matches_committed_purge_should_refuse_rewind() {
+ let partition_dir = transfer_fence_dir("restart-purge-rewind").await;
+ let mut partition = test_partition();
+ partition.set_partition_dir(partition_dir.clone());
+ partition.should_increment_offset = true;
+ partition.offset.store(99, Ordering::Release);
+ assert_eq!(
+ partition.applied_purge_generation(),
+ 0,
+ "the local generation is memory-only and starts over after a restart"
+ );
+
+ let offer = crate::state_transfer::ConsumerOffsetsWire {
+ purge_generation: 1,
+ next_offset: 50,
+ consumers: Vec::new(),
+ groups: Vec::new(),
+ };
+ let refused = partition
+ .install_state_transfer(&repair_config(), 12, Vec::new(), &offer.encode(), 1)
+ .await;
+
+ assert!(
+ matches!(
+ refused,
+ Err(
+ crate::state_transfer::PartitionInstallError::OfferRewindsDurableData {
+ offer_next_offset: 50,
+ local_next_offset: 100,
+ }
+ )
+ ),
+ "an offer that merely matches the committed generation is not a purge \
+ advancing past it, so the rewind fence must hold: got {refused:?}"
+ );
+
+ let _ = std::fs::remove_dir_all(&partition_dir);
+ }
+
+ /// Primary-by-index at view 0 with nothing committed refuses to serve: an
+ /// empty group is trivially "caught up", so this gate is the only thing
+ /// separating a real primary from a phantom whose directory vanished, whose
+ /// zero-segment offer at frontier 0 would make a data-holding receiver
+ /// unlink its chain.
+ #[compio::test]
+ async fn given_nothing_committed_when_offer_requested_should_refuse() {
+ let partition_dir = transfer_fence_dir("nothing-committed").await;
+ let mut partition = test_partition();
+ partition.set_partition_dir(partition_dir.clone());
+ assert_eq!(partition.consensus().commit_max(), 0);
+
+ let refused = partition.state_transfer_offer(&repair_config()).await;
+ assert!(
+ matches!(
+ refused,
+ Err(crate::state_transfer::PartitionTransferUnavailable::NothingCommitted)
+ ),
+ "expected a NothingCommitted refusal, got {refused:?}"
+ );
+ assert!(
+ refused.is_err_and(|reason| reason.transient()),
+ "the refusal must be transient: the requester rotates rather than \
+ charging its failure count"
+ );
+
+ let _ = std::fs::remove_dir_all(&partition_dir);
}
fn batch_stats(base_offset: u64, message_count: u32) -> CommittedBatchStats {
diff --git a/core/partitions/src/iggy_partitions.rs b/core/partitions/src/iggy_partitions.rs
index c73e8a9..a25aed6 100644
--- a/core/partitions/src/iggy_partitions.rs
+++ b/core/partitions/src/iggy_partitions.rs
@@ -25,6 +25,7 @@
use iggy_binary_protocol::{
Command2, ConsensusHeader, Operation, PrepareHeader, PrepareOkHeader, RequestHeader,
};
+use journal::superblock::{PingPongSuperblock, SuperblockStore};
use message_bus::MessageBus;
use server_common::send_messages2::{ChecksumMode, convert_request_message, encrypt_batch_request};
use server_common::sharding::{IggyNamespace, LocalIdx, ShardId};
@@ -65,7 +66,7 @@
/// For example, shard 0 might have `partition_ids` [0, 2, 4] while shard 1
/// has `partition_ids` [1, 3, 5]. The `LocalIdx` provides the actual index
/// into the `partitions` Vec.
-pub struct IggyPartitions<B>
+pub struct IggyPartitions<B, SB = PingPongSuperblock>
where
B: MessageBus,
{
@@ -79,7 +80,7 @@
/// only on the shard's pump task. Reconciler routes mutations
/// through `ReconcileOp` + `ReconcileApply`. Cross-task
/// access would be UB under cooperative `.await` interleaving.
- partitions: UnsafeCell<Vec<IggyPartition<B>>>,
+ partitions: UnsafeCell<Vec<IggyPartition<B, SB>>>,
/// Same single-pump invariant as `partitions`.
///
/// `BTreeMap`, not `HashMap`: iteration order via [`Self::namespaces`] must
@@ -112,9 +113,10 @@
borrow_active: Cell<u32>,
}
-impl<B> IggyPartitions<B>
+impl<B, SB> IggyPartitions<B, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
{
#[must_use]
pub fn new(shard_id: ShardId, config: PartitionsConfig) -> Self {
@@ -147,7 +149,7 @@
&self.config
}
- fn partitions(&self) -> &Vec<IggyPartition<B>> {
+ fn partitions(&self) -> &Vec<IggyPartition<B, SB>> {
// SAFETY: see the `partitions` field doc. The returned `&` is sound only
// while not held across an `.await` on a non-pump task (a sibling
// reconcile could realloc); single-threadedness alone is not enough.
@@ -179,13 +181,13 @@
}
/// Get partition by local index.
- pub fn get(&self, local_idx: LocalIdx) -> Option<&IggyPartition<B>> {
+ pub fn get(&self, local_idx: LocalIdx) -> Option<&IggyPartition<B, SB>> {
self.partitions().get(*local_idx)
}
/// Get mutable partition by local index.
#[allow(clippy::mut_from_ref)]
- fn get_mut(&self, local_idx: LocalIdx) -> Option<&mut IggyPartition<B>> {
+ fn get_mut(&self, local_idx: LocalIdx) -> Option<&mut IggyPartition<B, SB>> {
// SAFETY: `&mut` is sound on the pump task only (the sole mutator); see
// `namespace_map_mut`. Single-threadedness alone is not enough.
unsafe { (&mut *self.partitions.get()).get_mut(*local_idx) }
@@ -211,7 +213,7 @@
/// [`Self::with_partition`]; the `&mut` path above is uncounted (it is
/// pump-only, so it cannot alias this same-task mutation).
#[doc(hidden)]
- pub fn insert(&self, namespace: IggyNamespace, partition: IggyPartition<B>) -> LocalIdx {
+ pub fn insert(&self, namespace: IggyNamespace, partition: IggyPartition<B, SB>) -> LocalIdx {
#[cfg(debug_assertions)]
debug_assert_eq!(
self.borrow_active.get(),
@@ -247,7 +249,7 @@
/// the borrow to a synchronous closure, and must never hold the reference
/// across an `.await` (a sibling task's reconcile could reallocate the vec
/// mid-await).
- pub fn get_by_ns(&self, namespace: &IggyNamespace) -> Option<&IggyPartition<B>> {
+ pub fn get_by_ns(&self, namespace: &IggyNamespace) -> Option<&IggyPartition<B, SB>> {
if self.is_tombstoned(namespace) {
return None;
}
@@ -264,7 +266,7 @@
pub fn with_partition<R>(
&self,
namespace: &IggyNamespace,
- f: impl FnOnce(&IggyPartition<B>) -> R,
+ f: impl FnOnce(&IggyPartition<B, SB>) -> R,
) -> Option<R> {
let partition = self.get_by_ns(namespace)?;
#[cfg(debug_assertions)]
@@ -295,7 +297,7 @@
/// Get mutable partition by namespace directly. Tombstone-gated like
/// [`Self::get_by_ns`].
#[allow(clippy::mut_from_ref)]
- pub fn get_mut_by_ns(&self, namespace: &IggyNamespace) -> Option<&mut IggyPartition<B>> {
+ pub fn get_mut_by_ns(&self, namespace: &IggyNamespace) -> Option<&mut IggyPartition<B, SB>> {
if self.is_tombstoned(namespace) {
return None;
}
@@ -325,7 +327,7 @@
/// [`Self::with_partition`]; the `&mut` path above is uncounted (it is
/// pump-only, so it cannot alias this same-task mutation).
#[doc(hidden)]
- pub fn remove(&self, namespace: &IggyNamespace) -> Option<IggyPartition<B>> {
+ pub fn remove(&self, namespace: &IggyNamespace) -> Option<IggyPartition<B, SB>> {
#[cfg(debug_assertions)]
debug_assert_eq!(
self.borrow_active.get(),
@@ -364,7 +366,7 @@
///
/// Same pump-only safety discipline as [`Self::remove`].
#[doc(hidden)]
- pub fn remove_many(&self, namespaces: &[IggyNamespace]) -> Vec<IggyPartition<B>> {
+ pub fn remove_many(&self, namespaces: &[IggyNamespace]) -> Vec<IggyPartition<B, SB>> {
namespaces.iter().filter_map(|ns| self.remove(ns)).collect()
}
@@ -489,9 +491,10 @@
}
}
-impl<B> Plane<VsrConsensus<B>> for IggyPartitions<B>
+impl<B, SB> Plane<VsrConsensus<B>> for IggyPartitions<B, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
{
async fn on_request(&self, message: <VsrConsensus<B> as Consensus>::Message<RequestHeader>) {
let namespace = IggyNamespace::from_raw(message.header().namespace);
@@ -596,9 +599,10 @@
}
}
-impl<B> PlaneIdentity<VsrConsensus<B>> for IggyPartitions<B>
+impl<B, SB> PlaneIdentity<VsrConsensus<B>> for IggyPartitions<B, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
{
fn is_applicable<H>(&self, message: &<VsrConsensus<B> as Consensus>::Message<H>) -> bool
where
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index 0b2eaf8..c3a5b96 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -47,6 +47,16 @@
pub message_count: u32,
}
+/// What one pass over the journal headers found for a repair window.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RepairedWindowShape {
+ /// Every op in the window is resident. An EMPTY window is complete: nothing
+ /// left can arrive and change the floor verdict.
+ pub complete: bool,
+ /// At least one resident op in the window is a `SendMessages`.
+ pub holds_messages: bool,
+}
+
/// Lookup key for querying messages from the journal.
///
/// `ceiling` is the inclusive commit-frontier bound: the resident journal holds
@@ -283,9 +293,30 @@
}
impl PartitionJournal<PartitionJournalMemStorage> {
- /// Entry bytes for `op`, from the resident journal or the evicted ring.
- /// `None` when the op predates the ring (bulk-sync territory) or was
- /// never journaled here.
+ /// Drop EVERYTHING this journal holds: resident entries, the
+ /// op/offset/timestamp indexes, and the evicted repair ring.
+ ///
+ /// State-transfer install only. The installed segments supersede every
+ /// journaled op at or below the new commit floor, and the stale suffix
+ /// ABOVE it (prepared-but-uncommitted ops from a superseded view) would
+ /// collide with the new view's prepares at the same op numbers. The
+ /// journal is memory-only, so a full clear IS the partition plane's
+ /// suffix truncation; the receiver re-fetches the live tail through
+ /// normal journal repair afterwards. Ring caps and the retention flag
+ /// survive: they are configuration, not content.
+ pub fn clear_all(&self) {
+ {
+ let inner = unsafe { &*self.inner.get() };
+ let _ = inner.storage.drain();
+ }
+ unsafe { &mut *self.op_to_storage_offset.get() }.clear();
+ unsafe { &mut *self.offset_to_op.get() }.clear();
+ unsafe { &mut *self.timestamp_to_op.get() }.clear();
+ unsafe { &mut *self.headers.get() }.clear();
+ unsafe { &mut *self.evicted_ring.get() }.clear();
+ self.evicted_ring_bytes.set(0);
+ }
+
/// Disable repair retention (single-replica groups: nobody to repair).
pub fn set_repair_retention(&self, enabled: bool) {
self.repair_retention.set(enabled);
@@ -311,6 +342,9 @@
op_to_storage_offset.len()
}
+ /// Entry bytes for `op`, from the resident journal or the evicted ring.
+ /// `None` when the op predates the ring (bulk-sync territory) or was
+ /// never journaled here.
pub fn repair_entry(&self, op: u64) -> Option<JournalBuffer> {
{
let op_to_storage_offset = unsafe { &*self.op_to_storage_offset.get() };
@@ -631,6 +665,68 @@
headers.iter().find(|header| header.op == op).copied()
}
+ /// Presence and message-carrying shape of the repair window `(floor, to_op]`
+ /// in ONE pass over the header vec.
+ ///
+ /// [`Self::header_by_op`] is a linear scan with no index, so asking it
+ /// op-by-op over a window is O(window x headers): on the floor-refusal path
+ /// the replica is gap-stopped, so nothing evicts and the header vec grows
+ /// with the live tail, and the default 4096-op window over ~100k resident
+ /// headers is on the order of 4e8 comparisons -- synchronous, on the shard
+ /// pump, per repair round. Long enough to miss heartbeat and view-change
+ /// deadlines for every group on the core and turn one rejoin into an
+ /// election storm.
+ ///
+ /// The evicted ring is deliberately NOT consulted, matching the op-by-op
+ /// form: consulting it would change the floor-refusal verdict.
+ pub fn repaired_window_shape(&self, floor: u64, to_op: u64) -> RepairedWindowShape {
+ let headers = unsafe { &*self.headers.get() };
+ let expected = to_op.saturating_sub(floor);
+ // More in-window ops than resident headers can never be covered, and
+ // `expected` is unbounded here (`to_op` rides the local `commit_max`),
+ // so this is both the early answer and what keeps the bitset below from
+ // being sized off an arbitrary number.
+ if expected > headers.len() as u64 {
+ return RepairedWindowShape {
+ complete: false,
+ holds_messages: headers.iter().any(|header| {
+ header.op > floor
+ && header.op <= to_op
+ && header.operation == Operation::SendMessages
+ }),
+ };
+ }
+ // Dense window, so a flat presence vector beats a `HashSet`: no hashing
+ // per op and one contiguous allocation. One BYTE per op rather than one
+ // bit -- `expected` is bounded by `headers.len()`, so the 8x over a real
+ // bitset buys simpler indexing at a size the caller already holds in
+ // headers.
+ #[allow(clippy::cast_possible_truncation)]
+ let expected_len = expected as usize;
+ let mut present = vec![false; expected_len];
+ let mut covered = 0usize;
+ let mut holds_messages = false;
+ for header in headers
+ .iter()
+ .filter(|header| header.op > floor && header.op <= to_op)
+ {
+ if header.operation == Operation::SendMessages {
+ holds_messages = true;
+ }
+ #[allow(clippy::cast_possible_truncation)]
+ let slot = (header.op - floor - 1) as usize;
+ if !present[slot] {
+ present[slot] = true;
+ covered += 1;
+ }
+ }
+ RepairedWindowShape {
+ // In-window ops only, deduplicated, so a count match IS coverage.
+ complete: covered == expected_len,
+ holds_messages,
+ }
+ }
+
/// Headers for the contiguous op run `from_op ..= commit_max`, in op order,
/// stopping at the first missing op. A replication gap must not be skipped:
/// the caller advances `commit_min` strictly by one, so a hole would break
diff --git a/core/partitions/src/lib.rs b/core/partitions/src/lib.rs
index 33e33d0..75b4c70 100644
--- a/core/partitions/src/lib.rs
+++ b/core/partitions/src/lib.rs
@@ -28,6 +28,7 @@
mod offset_storage;
mod poll_plan;
mod segment;
+pub mod state_transfer;
mod types;
use iggy_binary_protocol::PrepareHeader;
@@ -35,7 +36,7 @@
pub use iggy_index::IggyIndex;
pub use iggy_index_reader::IggyIndexReader;
pub use iggy_index_writer::IggyIndexWriter;
-pub use iggy_partition::IggyPartition;
+pub use iggy_partition::{IggyPartition, PurgeError};
pub use iggy_partitions::IggyPartitions;
pub use journal::{EVICTED_RING_BYTES_MAX, EVICTED_RING_CAPACITY};
pub use messages_writer::MessagesWriter;
@@ -46,7 +47,8 @@
pub use server_common::send_messages2::{IggyMessage2, IggyMessage2Header, IggyMessages2};
pub use types::{
AppendResult, Fragment, PartitionOffsets, PartitionsConfig, PollFragments, PollQueryResult,
- PollingArgs, PollingConsumer, REPAIR_RETRY_TICKS, RepairSession, SendMessagesResult,
+ PollingArgs, PollingConsumer, REPAIR_RETRY_TICKS, RepairConclusion, RepairSession,
+ SendMessagesResult,
};
/// Partition-level data plane operations.
diff --git a/core/partitions/src/offset_storage.rs b/core/partitions/src/offset_storage.rs
index ebd4cf9..3938435 100644
--- a/core/partitions/src/offset_storage.rs
+++ b/core/partitions/src/offset_storage.rs
@@ -25,9 +25,11 @@
const OFFSET_SIZE: usize = core::mem::size_of::<u64>();
pub async fn persist_offset(path: &str, offset: u64, enforce_fsync: bool) -> Result<(), IggyError> {
- if let Some(parent) = Path::new(path).parent()
- && !parent.exists()
- {
+ // No `exists()` probe first: that is a BLOCKING `std::path` stat on the pump
+ // in front of every write, which serialises a batched fan-out on stats
+ // before it can submit any I/O. `create_dir_all` is already a no-op on an
+ // existing directory.
+ if let Some(parent) = Path::new(path).parent() {
create_dir_all(parent).await.map_err(|_| {
IggyError::CannotCreateConsumerOffsetsDirectory(parent.display().to_string())
})?;
@@ -114,13 +116,14 @@
/// # Errors
/// Returns [`IggyError::CannotDeleteConsumerOffsetFile`] if the unlink fails.
pub async fn delete_persisted_offset(path: &str) -> Result<(), IggyError> {
- if !Path::new(path).exists() {
- return Ok(());
+ // NotFound is tolerated on the result instead of probed for: the probe was
+ // a blocking stat on the pump before every unlink, and "already gone" is
+ // exactly the outcome this wants anyway.
+ match remove_file(path).await {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(_) => Err(IggyError::CannotDeleteConsumerOffsetFile(path.to_owned())),
}
-
- remove_file(path)
- .await
- .map_err(|_| IggyError::CannotDeleteConsumerOffsetFile(path.to_owned()))
}
#[cfg(test)]
diff --git a/core/partitions/src/state_transfer.rs b/core/partitions/src/state_transfer.rs
new file mode 100644
index 0000000..833f07a
--- /dev/null
+++ b/core/partitions/src/state_transfer.rs
@@ -0,0 +1,3002 @@
+// 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.
+
+//! Partition-plane state transfer: the offer a serving primary builds, the
+//! receiver session with its disk-spilled segment staging, and the wire
+//! codec for the consumer-offset artifact.
+//!
+//! A rejoining replica whose journal repair proved the gap below the commit
+//! floor is unrepairable (`RepairConclusion::FloorRefused`) pulls this
+//! partition's retained segments plus its consumer-offset table from the
+//! group's caught-up primary, installs them, and hands the live tail back to
+//! ordinary journal repair. Artifacts ride the plane-agnostic manifest/chunk
+//! protocol from `core/consensus`; everything in this module is the
+//! partition-specific payload handling on either end.
+
+use crate::messages_writer::MessagesWriter;
+use crate::offset_storage::{delete_persisted_offset, persist_offset};
+use crate::segment::Segment;
+use crate::types::PartitionsConfig;
+use crate::{IggyIndexWriter, IggyPartition};
+use compio::io::{AsyncReadAtExt, AsyncWriteAtExt};
+use consensus::le_cursor::{LeCursor, Truncated, split_verified_trailer};
+use consensus::state_manifest::artifact_kind;
+use consensus::{ArtifactProgress, Sequencer as _, StateArtifactHasher, state_artifact_checksum};
+use iggy_common::{ConsumerGroupId, ConsumerKind, ConsumerOffset, IggyByteSize};
+use journal::superblock::SuperblockStore;
+use message_bus::MessageBus;
+use server_common::SegmentStorage;
+use server_common::send_messages2::decode_batch_slice;
+use std::collections::HashSet;
+use std::fmt;
+use std::mem::size_of;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::sync::atomic::Ordering;
+
+/// Framing marker for the consumer-offsets wire artifact, "ICO1".
+pub(crate) const CONSUMER_OFFSETS_MAGIC: [u8; 4] = *b"ICO1";
+
+/// Version byte following the magic.
+///
+/// Any layout change bumps this, INCLUDING appended fields: the decoder
+/// deliberately fails closed on unknown versions and on trailing bytes,
+/// because a v2 field can change the meaning of fields v1 already read.
+pub(crate) const CONSUMER_OFFSETS_VERSION: u8 = 1;
+
+/// Per-section entry ceiling for the consumer-offsets artifact.
+///
+/// A corruption guard, not a target: it bounds the allocation `decode`
+/// makes from a length field a peer sent, exactly like the manifest's own
+/// entry ceiling.
+pub(crate) const CONSUMER_OFFSETS_ENTRIES_MAX: u32 = 1 << 20;
+
+/// One in-flight partition state transfer on the receiving replica.
+///
+/// Mirrors the metadata plane's session, plus `staged`: completed
+/// `SEGMENT_LOG` artifacts are validated and spilled to `.staging` files as
+/// they finish (bounding receiver memory to one in-flight artifact), and the
+/// walk metadata recorded here is what the install consumes. NO retry budget
+/// lives in here -- three of four metadata arming sites re-minted the
+/// session, so a per-session counter bounded nothing. The partition plane's
+/// budgets live on the partition: the stall budget
+/// (`transfer_attempts`, reset on received chunks) and the consecutive
+/// failure count driving the re-arm backoff (reset only by a completed
+/// install; deliberately NOT generation-keyed -- a committing origin
+/// advances its generation every round). A deterministically undecodable
+/// artifact therefore re-pulls once per backed-off round, capped at 1024x
+/// the base interval, rather than being refused outright.
+#[derive(Debug)]
+pub struct PartitionTransferSession {
+ pub nonce: u128,
+ /// Serving primary; also the stall re-request target.
+ pub peer: u8,
+ /// Serving peer's applied frontier from the accepted descriptor.
+ ///
+ /// Not a decode-budget generation: this plane keeps no such budget (see the
+ /// struct doc), it counts consecutive failures on the partition instead.
+ pub commit_op: u64,
+ /// One slot per offered artifact, in manifest order. A slot moves from
+ /// `Pending` to `Staged` when its segment payload is validated and
+ /// spilled (freeing the buffer); the single enum makes a
+ /// progress/spilled/staged desync unrepresentable.
+ pub artifacts: Vec<TransferArtifact>,
+ /// Whether a descriptor has been accepted (an accepted EMPTY manifest is
+ /// distinguishable from "still waiting").
+ pub target_accepted: bool,
+ /// Ticks with no frame progress; at the configured repair-retry
+ /// threshold the missing piece is re-requested.
+ pub idle_ticks: u32,
+}
+
+/// A scheduled transfer re-arm (see `IggyPartition::transfer_rearm`).
+///
+/// The shard tick sweep counts `after_ticks` down and arms a fresh session
+/// against `peer` when it reaches zero, provided nothing else armed one in
+/// the meantime.
+#[derive(Debug, Clone, Copy)]
+pub struct PendingTransferRearm {
+ pub peer: u8,
+ pub after_ticks: u32,
+}
+
+/// One artifact slot of an in-flight partition transfer.
+///
+/// `SEGMENT_LOG` artifacts pass through both states; the consumer-offsets
+/// artifact stays `Pending` until the install consumes its buffer.
+#[derive(Debug)]
+pub enum TransferArtifact {
+ /// Still pulling: manifest entry plus the bytes received so far.
+ Pending(ArtifactProgress),
+ /// Validated and spilled to `.staging` files; the buffer is freed and
+ /// the walk metadata is what the install consumes.
+ Staged(StagedSegmentMeta),
+}
+
+impl TransferArtifact {
+ #[must_use]
+ pub const fn pending(&self) -> Option<&ArtifactProgress> {
+ match self {
+ Self::Pending(progress) => Some(progress),
+ Self::Staged(_) => None,
+ }
+ }
+
+ pub const fn pending_mut(&mut self) -> Option<&mut ArtifactProgress> {
+ match self {
+ Self::Pending(progress) => Some(progress),
+ Self::Staged(_) => None,
+ }
+ }
+}
+
+impl consensus::ChunkProgress for TransferArtifact {
+ fn declared_len(&self) -> u64 {
+ match self {
+ Self::Pending(progress) => progress.entry.len,
+ Self::Staged(meta) => meta.size,
+ }
+ }
+
+ fn received_len(&self) -> u64 {
+ match self {
+ Self::Pending(progress) => progress.buf.len() as u64,
+ // Staged == validated == every declared byte arrived; the chunk
+ // cursor then skips it, subsuming the old `spilled` flags.
+ Self::Staged(meta) => meta.size,
+ }
+ }
+
+ fn extend_from_chunk(&mut self, payload: &[u8]) {
+ match self {
+ // Delegated, not re-implemented: the two must agree about how a
+ // buffer grows, and the reservation below only fires if this arm
+ // routes through the same impl.
+ Self::Pending(progress) => progress.extend_from_chunk(payload),
+ // Unreachable through `append_chunk`: a staged slot reports
+ // itself complete, so no in-window offset can address it.
+ Self::Staged(_) => debug_assert!(false, "chunk appended to a staged artifact"),
+ }
+ }
+
+ fn reserve_declared(&mut self) {
+ match self {
+ Self::Pending(progress) => progress.reserve_declared(),
+ Self::Staged(_) => {}
+ }
+ }
+}
+
+/// What the receiver learned walking one validated, staged segment artifact:
+/// everything the install needs to rebuild the in-memory `Segment` without
+/// re-reading the file.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StagedSegmentMeta {
+ pub start_offset: u64,
+ pub end_offset: u64,
+ /// Byte length of the locally rebuilt sparse index sidecar, recorded at
+ /// the walk so the install does not re-stat the renamed file.
+ pub index_size: u64,
+ /// Payload byte length == the manifest entry's `len` == the final `.log`
+ /// file size.
+ pub size: u64,
+ pub start_timestamp: u64,
+ pub end_timestamp: u64,
+ pub max_timestamp: u64,
+ /// `{start_offset:020}.log.staging` in the partition directory. The
+ /// `.staging` extension is invisible to boot recovery, which filters on
+ /// `extension == "log"`.
+ pub log_staging: PathBuf,
+ /// The locally rebuilt sparse index for the staged log, one entry per
+ /// batch (denser than the origin's per-flush-chunk index; recovery is
+ /// sparse-tolerant either way).
+ pub index_staging: PathBuf,
+}
+
+/// Memoized streaming checksum state for one segment file: the hasher fed
+/// exactly `hashed_len` of its bytes, plus the stamp at that length.
+///
+/// Keyed per segment rather than per `(base offset, size)` pair so the ACTIVE
+/// segment extends its own hasher as it grows, instead of missing the memo on
+/// every byte it gained and re-reading from byte zero. Every path that plants a
+/// new file at an existing base offset (purge, install, converge) clears the
+/// whole map, which is what makes "same base offset, longer file, same leading
+/// bytes" hold.
+pub(crate) struct SegmentChecksumMemo {
+ hashed_len: u64,
+ /// The stamp is NOT cached alongside: `StateArtifactHasher::finish` takes
+ /// `&self`, so it is a read of this hasher, and a second copy is just a
+ /// field that can drift.
+ hasher: StateArtifactHasher,
+}
+
+impl SegmentChecksumMemo {
+ fn new() -> Self {
+ Self {
+ hashed_len: 0,
+ hasher: StateArtifactHasher::new(),
+ }
+ }
+}
+
+/// The result of the last staged-segment reuse scan.
+///
+/// A scan reads every length-matching staged file whole, verifies it, and
+/// re-walks every batch -- sequentially, on the pump. Peer rotation and stall
+/// re-arms mint a fresh session against the same segment set, so without this
+/// the full cost is re-paid per arm. `digest` covers every `SEGMENT_LOG`
+/// manifest entry, so a hit means the new offer expects byte-identical staged
+/// files; any write to a staging file, or any unlink of one, drops the memo, so
+/// a hit can never describe bytes that were replaced meanwhile.
+pub(crate) struct ReuseScanMemo {
+ digest: u64,
+ adopted: Vec<(u32, StagedSegmentMeta)>,
+}
+
+impl StagedSegmentMeta {
+ /// Assemble the metadata a completed walk produced. Shared by the spill and
+ /// the reuse-adopt path, which differ only in whether they also wrote the
+ /// payload.
+ const fn from_walk(
+ entry: &consensus::StateArtifact,
+ stats: SegmentWalkStats,
+ index_size: u64,
+ log_staging: PathBuf,
+ index_staging: PathBuf,
+ ) -> Self {
+ Self {
+ start_offset: entry.frontier,
+ end_offset: stats.end_offset,
+ size: entry.len,
+ index_size,
+ start_timestamp: stats.start_timestamp,
+ end_timestamp: stats.end_timestamp,
+ max_timestamp: stats.max_timestamp,
+ log_staging,
+ index_staging,
+ }
+ }
+}
+
+/// The consumer-offset artifact: both offset maps plus the applied purge
+/// generation, at the offer's `commit_op`.
+///
+/// The purge generation rides here because a receiver that missed a
+/// `PurgeTopic` would otherwise install post-purge data at a stale local
+/// generation and the reconciler would immediately re-wipe it, costing a
+/// full extra transfer.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub(crate) struct ConsumerOffsetsWire {
+ pub purge_generation: u64,
+ /// The origin group's message-offset frontier: the offset the NEXT
+ /// append will mint, `0` for a partition that never appended. Segments
+ /// alone cannot carry this -- retention can GC every sealed segment
+ /// while the counter stands at N, and installing such an offer without
+ /// this field would restart the receiver's offset space at 0, forking
+ /// every future batch stamp from the rest of the group.
+ pub next_offset: u64,
+ /// `(consumer id, offset)`, ascending by id.
+ pub consumers: Vec<(u32, u64)>,
+ /// `(consumer group id, offset)`, ascending by id.
+ pub groups: Vec<(u32, u64)>,
+}
+
+impl ConsumerOffsetsWire {
+ /// Encode: `magic | version u8 | purge_generation u64 | next_offset u64 |
+ /// consumer_count u32 | group_count u32 | {id u32, offset u64}xN |
+ /// {id u32, offset u64}xM | XxHash3_64 trailer`. Little-endian
+ /// throughout.
+ #[must_use]
+ pub fn encode(&self) -> Vec<u8> {
+ // Size exactly rather than guess; the reservation assert keeps the
+ // arithmetic honest as fields are added.
+ let reserved = CONSUMER_OFFSETS_MAGIC.len()
+ + size_of::<u8>()
+ + 2 * size_of::<u64>()
+ + 2 * size_of::<u32>()
+ + (self.consumers.len() + self.groups.len()) * (size_of::<u32>() + size_of::<u64>())
+ + size_of::<u64>();
+ let mut out = Vec::with_capacity(reserved);
+ out.extend_from_slice(&CONSUMER_OFFSETS_MAGIC);
+ out.push(CONSUMER_OFFSETS_VERSION);
+ out.extend_from_slice(&self.purge_generation.to_le_bytes());
+ out.extend_from_slice(&self.next_offset.to_le_bytes());
+ #[allow(clippy::cast_possible_truncation)]
+ out.extend_from_slice(&(self.consumers.len() as u32).to_le_bytes());
+ #[allow(clippy::cast_possible_truncation)]
+ out.extend_from_slice(&(self.groups.len() as u32).to_le_bytes());
+ for (id, offset) in self.consumers.iter().chain(self.groups.iter()) {
+ out.extend_from_slice(&id.to_le_bytes());
+ out.extend_from_slice(&offset.to_le_bytes());
+ }
+ debug_assert_eq!(out.len() + size_of::<u64>(), reserved, "encode reservation");
+ let trailer = state_artifact_checksum(&out);
+ out.extend_from_slice(&trailer.to_le_bytes());
+ out
+ }
+
+ /// Decode and validate a peer's consumer-offset artifact.
+ ///
+ /// The artifact checksum already verified transit; these validations are
+ /// about the PEER's encoder (duplicate ids, count fields, trailing
+ /// bytes), which the transit checksum cannot vouch for. Offset-value
+ /// sanity is deliberately NOT here: it needs the installed end offset,
+ /// so the install clamps, mirroring boot recovery.
+ ///
+ /// # Errors
+ /// Any [`ConsumerOffsetsWireError`]; the input is never partially
+ /// trusted.
+ pub fn decode(bytes: &[u8]) -> Result<Self, ConsumerOffsetsWireError> {
+ let content = split_verified_trailer(bytes).map_err(|mismatch| match mismatch {
+ None => ConsumerOffsetsWireError::Truncated,
+ Some((expected, actual)) => {
+ ConsumerOffsetsWireError::ChecksumMismatch { expected, actual }
+ }
+ })?;
+ let mut cursor = LeCursor::new(content);
+ let magic = cursor.take(CONSUMER_OFFSETS_MAGIC.len())?;
+ if magic != CONSUMER_OFFSETS_MAGIC {
+ return Err(ConsumerOffsetsWireError::BadMagic);
+ }
+ let version = cursor.u8()?;
+ if version != CONSUMER_OFFSETS_VERSION {
+ return Err(ConsumerOffsetsWireError::UnsupportedVersion { version });
+ }
+ let purge_generation = cursor.u64()?;
+ let next_offset = cursor.u64()?;
+ let consumer_count = cursor.u32()?;
+ let group_count = cursor.u32()?;
+ let consumers = Self::decode_section(&mut cursor, "consumers", consumer_count)?;
+ let groups = Self::decode_section(&mut cursor, "groups", group_count)?;
+ if !cursor.remaining().is_empty() {
+ // Distinct from `Truncated`: extra bytes point at a NEWER
+ // encoder, and telling the operator the artifact is short would
+ // send them the wrong way.
+ return Err(ConsumerOffsetsWireError::TrailingBytes {
+ extra: cursor.remaining().len(),
+ });
+ }
+ Ok(Self {
+ purge_generation,
+ next_offset,
+ consumers,
+ groups,
+ })
+ }
+
+ fn decode_section(
+ cursor: &mut LeCursor<'_>,
+ section: &'static str,
+ count: u32,
+ ) -> Result<Vec<(u32, u64)>, ConsumerOffsetsWireError> {
+ // Ceiling BEFORE the reservation: `count` is peer input and this is
+ // the only check between it and an eager allocation.
+ if count > CONSUMER_OFFSETS_ENTRIES_MAX {
+ return Err(ConsumerOffsetsWireError::TooManyEntries {
+ section,
+ count,
+ max: CONSUMER_OFFSETS_ENTRIES_MAX,
+ });
+ }
+ // The count is peer input and the reservation is 12 bytes per element
+ // after alignment, so it is checked against the bytes actually present
+ // before allocating: a ~30 byte artifact could otherwise ask for tens of
+ // megabytes across the two sections. 12 is the wire stride below -- the
+ // groups call sees exactly `12 * count` bytes remaining, so a wider
+ // guard would reject every non-empty artifact.
+ if count as usize * (size_of::<u32>() + size_of::<u64>()) > cursor.remaining().len() {
+ return Err(ConsumerOffsetsWireError::Truncated);
+ }
+ let mut entries = Vec::with_capacity(count as usize);
+ let mut previous: Option<u32> = None;
+ for _ in 0..count {
+ let id = cursor.u32()?;
+ let offset = cursor.u64()?;
+ // Ascending-strict doubles as the duplicate reject and makes the
+ // encoding canonical: one table, one byte sequence.
+ if previous.is_some_and(|previous| id <= previous) {
+ return Err(ConsumerOffsetsWireError::NonAscendingId { section, id });
+ }
+ previous = Some(id);
+ entries.push((id, offset));
+ }
+ Ok(entries)
+ }
+}
+
+/// Failure decoding the consumer-offsets WIRE artifact (state transfer).
+/// Named for the format: the on-disk offset files are a different codec with
+/// different trust (this node's own bytes vs a peer's).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ConsumerOffsetsWireError {
+ Truncated,
+ BadMagic,
+ UnsupportedVersion {
+ version: u8,
+ },
+ /// Bytes remained after this version's field set: a newer encoder.
+ TrailingBytes {
+ extra: usize,
+ },
+ ChecksumMismatch {
+ expected: u64,
+ actual: u64,
+ },
+ TooManyEntries {
+ section: &'static str,
+ count: u32,
+ max: u32,
+ },
+ /// Ids in a section are not strictly ascending: a duplicate, or an
+ /// out-of-order entry. Both are the same encoder bug and both break the
+ /// canonical form the encoding promises.
+ NonAscendingId {
+ section: &'static str,
+ id: u32,
+ },
+}
+
+impl From<Truncated> for ConsumerOffsetsWireError {
+ fn from(_: Truncated) -> Self {
+ Self::Truncated
+ }
+}
+
+impl fmt::Display for ConsumerOffsetsWireError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Truncated => write!(f, "consumer-offsets artifact is truncated"),
+ Self::BadMagic => write!(f, "consumer-offsets artifact carries a foreign magic"),
+ Self::TrailingBytes { extra } => write!(
+ f,
+ "consumer-offsets artifact carries {extra} trailing bytes past this \
+ version's field set (a newer encoder?)"
+ ),
+ Self::UnsupportedVersion { version } => write!(
+ f,
+ "consumer-offsets artifact version {version} is not understood \
+ (this build speaks {CONSUMER_OFFSETS_VERSION})"
+ ),
+ Self::ChecksumMismatch { expected, actual } => write!(
+ f,
+ "consumer-offsets artifact checksum mismatch: expected {expected}, got {actual}"
+ ),
+ Self::TooManyEntries {
+ section,
+ count,
+ max,
+ } => write!(
+ f,
+ "consumer-offsets artifact {section} count {count} exceeds the {max} ceiling"
+ ),
+ Self::NonAscendingId { section, id } => write!(
+ f,
+ "consumer-offsets artifact {section} id {id} does not ascend \
+ (duplicate, or out of order)"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for ConsumerOffsetsWireError {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn table() -> ConsumerOffsetsWire {
+ ConsumerOffsetsWire {
+ purge_generation: 3,
+ next_offset: 43,
+ consumers: vec![(1, 10), (7, 42)],
+ groups: vec![(2, 5)],
+ }
+ }
+
+ #[test]
+ fn given_offset_table_when_encoded_should_round_trip() {
+ let encoded = table().encode();
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&encoded).expect("round trip"),
+ table()
+ );
+ }
+
+ #[test]
+ fn given_empty_table_when_encoded_should_round_trip() {
+ let empty = ConsumerOffsetsWire {
+ purge_generation: 0,
+ next_offset: 0,
+ consumers: Vec::new(),
+ groups: Vec::new(),
+ };
+ let encoded = empty.encode();
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&encoded).expect("round trip"),
+ empty
+ );
+ }
+
+ #[test]
+ fn given_flipped_bit_when_decoded_should_reject_checksum() {
+ let mut encoded = table().encode();
+ encoded[6] ^= 1;
+ assert!(matches!(
+ ConsumerOffsetsWire::decode(&encoded),
+ Err(ConsumerOffsetsWireError::ChecksumMismatch { .. })
+ ));
+ }
+
+ #[test]
+ fn given_truncated_bytes_when_decoded_should_reject() {
+ let encoded = table().encode();
+ for len in 0..encoded.len() {
+ assert!(
+ ConsumerOffsetsWire::decode(&encoded[..len]).is_err(),
+ "strict prefix of {len} bytes must fail closed"
+ );
+ }
+ }
+
+ #[test]
+ fn given_unknown_version_when_decoded_should_reject() {
+ let mut wrong = table().encode();
+ // Bump the version byte and re-seal so only the version check fires.
+ wrong[CONSUMER_OFFSETS_MAGIC.len()] = CONSUMER_OFFSETS_VERSION + 1;
+ let content_len = wrong.len() - size_of::<u64>();
+ let trailer = state_artifact_checksum(&wrong[..content_len]);
+ wrong[content_len..].copy_from_slice(&trailer.to_le_bytes());
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&wrong),
+ Err(ConsumerOffsetsWireError::UnsupportedVersion {
+ version: CONSUMER_OFFSETS_VERSION + 1,
+ })
+ );
+ }
+
+ #[test]
+ fn given_foreign_magic_when_decoded_should_reject() {
+ let mut wrong = table().encode();
+ // Rewrite the magic and re-seal so only the magic check can fire.
+ wrong[0] = b'X';
+ let content_len = wrong.len() - size_of::<u64>();
+ let trailer = state_artifact_checksum(&wrong[..content_len]);
+ wrong[content_len..].copy_from_slice(&trailer.to_le_bytes());
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&wrong),
+ Err(ConsumerOffsetsWireError::BadMagic)
+ );
+ }
+
+ #[test]
+ fn given_count_past_ceiling_when_decoded_should_reject_before_allocating() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(&CONSUMER_OFFSETS_MAGIC);
+ bytes.push(CONSUMER_OFFSETS_VERSION);
+ bytes.extend_from_slice(&0u64.to_le_bytes());
+ bytes.extend_from_slice(&0u64.to_le_bytes());
+ bytes.extend_from_slice(&(CONSUMER_OFFSETS_ENTRIES_MAX + 1).to_le_bytes());
+ bytes.extend_from_slice(&0u32.to_le_bytes());
+ let trailer = state_artifact_checksum(&bytes);
+ bytes.extend_from_slice(&trailer.to_le_bytes());
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&bytes),
+ Err(ConsumerOffsetsWireError::TooManyEntries {
+ section: "consumers",
+ count: CONSUMER_OFFSETS_ENTRIES_MAX + 1,
+ max: CONSUMER_OFFSETS_ENTRIES_MAX,
+ })
+ );
+ }
+
+ #[test]
+ fn given_duplicate_or_unordered_ids_when_decoded_should_reject() {
+ let duplicate = ConsumerOffsetsWire {
+ purge_generation: 0,
+ next_offset: 0,
+ consumers: vec![(5, 1), (5, 2)],
+ groups: Vec::new(),
+ };
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&duplicate.encode()),
+ Err(ConsumerOffsetsWireError::NonAscendingId {
+ section: "consumers",
+ id: 5,
+ })
+ );
+ let unordered = ConsumerOffsetsWire {
+ purge_generation: 0,
+ next_offset: 0,
+ consumers: Vec::new(),
+ groups: vec![(9, 1), (4, 2)],
+ };
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&unordered.encode()),
+ Err(ConsumerOffsetsWireError::NonAscendingId {
+ section: "groups",
+ id: 4,
+ })
+ );
+ }
+
+ #[test]
+ fn given_trailing_bytes_when_decoded_should_reject() {
+ let mut padded = table().encode();
+ let content_len = padded.len() - size_of::<u64>();
+ padded.truncate(content_len);
+ padded.push(0);
+ let trailer = state_artifact_checksum(&padded);
+ padded.extend_from_slice(&trailer.to_le_bytes());
+ assert_eq!(
+ ConsumerOffsetsWire::decode(&padded),
+ Err(ConsumerOffsetsWireError::TrailingBytes { extra: 1 }),
+ "bytes past the last section must fail closed"
+ );
+ }
+}
+
+/// What a full validation walk over one segment payload derived.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct SegmentWalkStats {
+ pub end_offset: u64,
+ pub start_timestamp: u64,
+ pub end_timestamp: u64,
+ pub max_timestamp: u64,
+}
+
+/// Failure validating a transferred segment payload.
+///
+/// The artifact checksum already proved transit; these are about the bytes
+/// themselves (the peer's disk, or its encoder), which transit integrity
+/// cannot vouch for.
+#[derive(Debug)]
+pub enum SegmentWalkError {
+ /// Batch header/checksum rejected at `position`.
+ Batch {
+ position: u64,
+ source: iggy_common::IggyError,
+ },
+ /// First batch does not start at the artifact's declared base offset.
+ BaseOffsetMismatch { expected: u64, actual: u64 },
+ /// A batch's base offset does not continue the previous batch.
+ NonContiguous { expected: u64, actual: u64 },
+ /// A batch's offset arithmetic overflows `u64`; the operands are
+ /// peer-controlled, so this is a rejection, not a clamp.
+ OffsetOverflow { position: u64 },
+ /// The payload holds no batches; empty segments are never offered.
+ Empty,
+}
+
+impl fmt::Display for SegmentWalkError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Batch { position, source } => {
+ write!(f, "segment batch at byte {position} rejected: {source}")
+ }
+ Self::BaseOffsetMismatch { expected, actual } => write!(
+ f,
+ "segment first batch starts at offset {actual}, manifest says {expected}"
+ ),
+ Self::NonContiguous { expected, actual } => write!(
+ f,
+ "segment batch starts at offset {actual}, expected {expected}"
+ ),
+ Self::OffsetOverflow { position } => {
+ write!(
+ f,
+ "segment batch at byte {position} overflows the offset space"
+ )
+ }
+ Self::Empty => write!(f, "segment payload holds no batches"),
+ }
+ }
+}
+
+impl std::error::Error for SegmentWalkError {}
+
+/// Walk every batch of a transferred `.log` payload.
+///
+/// Validates each header and `batch_checksum` (`decode_batch_slice`),
+/// proves offset continuity from the manifest's declared base, and derives
+/// the segment metadata plus a locally rebuilt sparse index (one 24-byte
+/// entry per batch -- denser than the origin's per-flush-chunk index, which
+/// recovery tolerates).
+///
+/// # Errors
+/// [`SegmentWalkError`] on the first invalid byte; nothing is partially
+/// trusted.
+pub(crate) async fn walk_segment_payload(
+ base_offset: u64,
+ bytes: &[u8],
+) -> Result<(SegmentWalkStats, Vec<u8>), SegmentWalkError> {
+ let mut position = 0usize;
+ let mut next_offset = base_offset;
+ let mut stats: Option<SegmentWalkStats> = None;
+ let mut index_bytes = Vec::new();
+ let mut indexed_position: Option<usize> = None;
+ let mut since_yield = 0usize;
+ while position < bytes.len() {
+ // The walk re-hashes every message (`decode_batch_slice` verifies
+ // `batch_checksum`), so a multi-GiB artifact is a long CPU pass on the
+ // pump task. What these yields buy is NOT tick liveness: the consensus
+ // tick is a sibling `select_biased!` arm of this same task and arms are
+ // not polled while another arm's body awaits, so every group's tick and
+ // heartbeat on this shard stay frozen for the duration either way (see
+ // the tick-starvation TODO in `shard::router`). They buy the reactor:
+ // detached tasks and io_uring completions make progress instead of
+ // waiting out the whole pass. Moving the verify + walk off the pump is
+ // what would fix the tick, and the nonce re-check after the spill is
+ // already shaped for that.
+ if since_yield >= OFFER_HASH_CHUNK_LEN {
+ since_yield = 0;
+ yield_to_reactor().await;
+ }
+ let batch =
+ decode_batch_slice(&bytes[position..]).map_err(|source| SegmentWalkError::Batch {
+ position: position as u64,
+ source,
+ })?;
+ let header = batch.header;
+ if stats.is_none() && header.base_offset != base_offset {
+ return Err(SegmentWalkError::BaseOffsetMismatch {
+ expected: base_offset,
+ actual: header.base_offset,
+ });
+ }
+ if header.base_offset != next_offset {
+ return Err(SegmentWalkError::NonContiguous {
+ expected: next_offset,
+ actual: header.base_offset,
+ });
+ }
+ if header.message_count == 0 {
+ return Err(SegmentWalkError::Batch {
+ position: position as u64,
+ source: iggy_common::IggyError::InvalidMessagesCount,
+ });
+ }
+ // Peer-controlled operands under a reject-on-first-invalid contract:
+ // checked, not saturating -- a clamp would misdirect the diagnostic.
+ let Some(batch_end) = header
+ .base_offset
+ .checked_add(u64::from(header.message_count) - 1)
+ else {
+ return Err(SegmentWalkError::OffsetOverflow {
+ position: position as u64,
+ });
+ };
+ // The append-time canonical stamp, exactly what the flush path writes
+ // into index entries and segment bounds; `origin_timestamp` is
+ // client-supplied and would give the installed replica a divergent
+ // timestamp column (polls and retention keyed differently per node).
+ let timestamp = header.base_timestamp;
+ // STRIDED, not one entry per batch: the origin writes one entry per
+ // flush chunk, and a per-batch index is dense enough that a transferred
+ // segment never fits the sealed-index residency cap
+ // (`poll_plan::SEALED_INDEX_RESIDENT_MAX_BYTES`), so every sealed poll
+ // would fall back to binary-searching the file with single-entry preads
+ // -- a slow path `poll_plan` reserves for a
+ // `messages_required_to_save = 1` misconfiguration, which a transfer
+ // would otherwise produce unconditionally. Both consumers do lower-bound
+ // lookups and recovery walks forward from the last entry by design, so
+ // sparser is correct; the first batch always gets one.
+ let stride_reached = indexed_position
+ .is_none_or(|indexed| position.saturating_sub(indexed) >= INDEX_STRIDE_BYTES);
+ if stride_reached {
+ indexed_position = Some(position);
+ index_bytes.extend_from_slice(&header.base_offset.to_le_bytes());
+ index_bytes.extend_from_slice(×tamp.to_le_bytes());
+ index_bytes.extend_from_slice(&(position as u64).to_le_bytes());
+ }
+ stats = Some(stats.map_or(
+ SegmentWalkStats {
+ end_offset: batch_end,
+ start_timestamp: timestamp,
+ end_timestamp: timestamp,
+ max_timestamp: timestamp,
+ },
+ |previous| SegmentWalkStats {
+ end_offset: batch_end,
+ start_timestamp: previous.start_timestamp,
+ end_timestamp: timestamp,
+ max_timestamp: previous.max_timestamp.max(timestamp),
+ },
+ ));
+ next_offset = batch_end
+ .checked_add(1)
+ .ok_or(SegmentWalkError::OffsetOverflow {
+ position: position as u64,
+ })?;
+ // No trailing-bytes guard: the header decode floors `batch_length`
+ // at the 256-byte command header (no zero-step loop is possible)
+ // and `decode_batch_slice` already rejects a body shorter than
+ // `total_size()`.
+ position += header.total_size();
+ since_yield += header.total_size();
+ }
+ stats.map_or(Err(SegmentWalkError::Empty), |stats| {
+ Ok((stats, index_bytes))
+ })
+}
+
+/// One offered segment: its manifest entry plus WHERE its bytes live.
+///
+/// The offer deliberately holds paths, not payloads -- the serving side
+/// loads one artifact at a time at chunk-serve time, bounding its memory to
+/// one segment per requester regardless of how much the partition retains.
+#[derive(Debug, Clone)]
+pub struct SegmentArtifactSource {
+ pub entry: consensus::StateArtifact,
+ pub log_path: String,
+}
+
+/// One artifact of an offer, addressed by manifest index: segment payloads
+/// live on disk (loaded at chunk-serve time), the offsets table is resident.
+#[derive(Debug)]
+pub enum PartitionArtifactSource<'a> {
+ Segment(&'a SegmentArtifactSource),
+ Offsets(&'a std::rc::Rc<Vec<u8>>),
+}
+
+/// A built partition state-transfer offer: everything at `commit_op`, with
+/// segment payloads addressed by path and only the (small) offsets artifact
+/// resident.
+#[derive(Debug)]
+pub struct PartitionStateTransferOffer {
+ /// `== commit_min == commit_max` at build (caught-up primary gate).
+ pub commit_op: u64,
+ /// Ascending base offset; one artifact per non-empty retained segment.
+ pub segments: Vec<SegmentArtifactSource>,
+ /// The consumer-offsets artifact, resident (a few KB at most).
+ pub offsets: (consensus::StateArtifact, std::rc::Rc<Vec<u8>>),
+}
+
+impl PartitionStateTransferOffer {
+ /// Manifest order: segments ascending, then the offsets artifact last,
+ /// so a receiver spills every segment before it holds the table.
+ #[must_use]
+ pub fn manifest(&self) -> Vec<consensus::StateArtifact> {
+ let mut entries: Vec<_> = self.segments.iter().map(|source| source.entry).collect();
+ entries.push(self.offsets.0);
+ entries
+ }
+
+ /// Never zero: the offsets artifact is always present.
+ #[must_use]
+ pub const fn artifact_count(&self) -> usize {
+ self.segments.len() + 1
+ }
+
+ /// The artifact at `index` in [`Self::manifest`] order (segments
+ /// ascending, offsets last) without materialising the manifest vec.
+ #[must_use]
+ pub fn artifact_at(&self, index: usize) -> Option<PartitionArtifactSource<'_>> {
+ match index.cmp(&self.segments.len()) {
+ std::cmp::Ordering::Less => {
+ Some(PartitionArtifactSource::Segment(&self.segments[index]))
+ }
+ std::cmp::Ordering::Equal => Some(PartitionArtifactSource::Offsets(&self.offsets.1)),
+ std::cmp::Ordering::Greater => None,
+ }
+ }
+
+ #[must_use]
+ pub fn total_len(&self) -> u64 {
+ self.segments
+ .iter()
+ .map(|source| source.entry.len)
+ .sum::<u64>()
+ + self.offsets.0.len
+ }
+}
+
+/// Why a partition cannot serve a state transfer right now.
+///
+/// Distinct variants because the operator responses differ: "not the
+/// caught-up primary" is routine (requester retries elsewhere), an
+/// unreadable segment is a local fault on THIS node.
+#[derive(Debug)]
+pub enum PartitionTransferUnavailable {
+ NotCaughtUpPrimary,
+ /// In-memory / simulated partition: nothing on disk to serve.
+ NoPartitionDir,
+ RepairInProgress,
+ /// Primary-by-index of a group that has committed nothing: an empty group
+ /// is trivially "caught up", so this is the only thing separating a real
+ /// primary from a view-0 phantom whose directory vanished.
+ NothingCommitted,
+ /// More retained segments than the manifest can carry entries for.
+ ManifestTooLarge {
+ entries: usize,
+ max: usize,
+ },
+ /// The segment chain changed while the offer's checksum passes ran, so the
+ /// stamps no longer describe the bytes the offer addresses.
+ SegmentSetChanged,
+ /// This round's share of the checksum pass ran out with retained bytes
+ /// still unhashed. Progress is memoized, so the next request resumes where
+ /// this one stopped rather than starting the pass again.
+ OfferBuildInProgress {
+ /// Bytes hashed so far over the chain AS THIS ROUND SEES IT. Carries
+ /// across rounds through the memo rather than resetting per round, but
+ /// retention GC dropping an already-hashed segment lowers it and
+ /// `remaining` together, so it tracks the live chain, not a monotone
+ /// total.
+ hashed: u64,
+ /// Bytes of it still unhashed. The pair is the only signal that
+ /// separates a converging multi-round build from a stalled one.
+ remaining: u64,
+ },
+ FlushFailed(iggy_common::IggyError),
+ SegmentUnreadable {
+ start_offset: u64,
+ source: std::io::Error,
+ },
+}
+
+impl PartitionTransferUnavailable {
+ /// Whether the refusal says "not right now" rather than "this node is
+ /// broken". A requester charges its consecutive-failure count (and the
+ /// exponential re-arm backoff behind it) only for the latter: a primary
+ /// that is momentarily behind its own frontier is the common case under
+ /// produce load, and charging it pins the backoff at its ceiling while
+ /// nothing else recovers the partition.
+ #[must_use]
+ pub const fn transient(&self) -> bool {
+ match self {
+ Self::NotCaughtUpPrimary
+ | Self::RepairInProgress
+ | Self::NothingCommitted
+ | Self::SegmentSetChanged
+ | Self::OfferBuildInProgress { .. } => true,
+ Self::NoPartitionDir
+ | Self::ManifestTooLarge { .. }
+ | Self::FlushFailed(_)
+ | Self::SegmentUnreadable { .. } => false,
+ }
+ }
+}
+
+impl fmt::Display for PartitionTransferUnavailable {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NotCaughtUpPrimary => write!(f, "not the caught-up primary of this group"),
+ Self::NoPartitionDir => write!(f, "partition has no on-disk directory"),
+ Self::RepairInProgress => write!(f, "partition is itself mid-repair"),
+ Self::NothingCommitted => write!(
+ f,
+ "primary by index at view 0 with nothing committed; refusing to serve an empty offer"
+ ),
+ Self::ManifestTooLarge { entries, max } => write!(
+ f,
+ "offer needs {entries} manifest entries, past the {max} ceiling"
+ ),
+ Self::SegmentSetChanged => {
+ write!(f, "segment chain changed while the offer was being built")
+ }
+ Self::OfferBuildInProgress { hashed, remaining } => write!(
+ f,
+ "offer checksum pass has hashed {hashed} bytes with {remaining} to go; \
+ resuming on the next request"
+ ),
+ Self::FlushFailed(source) => {
+ write!(f, "flushing the committed prefix failed: {source}")
+ }
+ Self::SegmentUnreadable {
+ start_offset,
+ source,
+ } => write!(f, "segment {start_offset:0>20}.log is unreadable: {source}"),
+ }
+ }
+}
+
+impl std::error::Error for PartitionTransferUnavailable {}
+
+/// Outcome of a completed install. A degraded install is a SUCCESS: the
+/// segments and floor landed; only some consumer-offset file writes failed,
+/// and the next offset commit blind-writes those files.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PartitionInstallOutcome {
+ /// The consensus op the install applied. Named for what it holds: every
+ /// other `frontier` in this module is a MESSAGE OFFSET
+ /// (`VsrState::offset_frontier`, `StateArtifact::frontier`,
+ /// `installed_frontier`), and op-vs-offset confusion is what produced this
+ /// PR's durability defects.
+ pub applied_commit_op: u64,
+ /// Every transferred offset file was WRITTEN (and the offset
+ /// directories fsynced, so the old files' unlinks stick). Not a
+ /// durability claim for the file contents: `persist_offset` fsyncs only
+ /// under `consumer_offset_enforce_fsync`, matching the normal
+ /// offset-commit path -- shipped default off.
+ pub offsets_written: bool,
+}
+
+/// Failure installing a transferred partition state. Every `check`-phase
+/// variant means NOTHING was mutated.
+#[derive(Debug)]
+pub enum PartitionInstallError {
+ NoPartitionDir,
+ /// `commit_op` fell below this replica's commit frontier; installing
+ /// would rewind `commit_min` (the anti-rewind assert, as a refusal).
+ StaleTransfer {
+ commit_op: u64,
+ commit_min: u64,
+ },
+ /// The incoming frontier could not be made durable before the swap, so the
+ /// install refuses rather than enter a window whose only durable witness
+ /// would be the segments the failure path quarantines away.
+ FrontierNotDurable {
+ frontier: u64,
+ },
+ /// The offer's offset frontier is below this replica's own offset
+ /// counter, so installing it would rewind the offset space: the next
+ /// replicated prepare would be re-stamped from the rewound counter and
+ /// persist different bytes (and a different `batch_checksum`) here than
+ /// on the rest of the group.
+ OfferRewindsDurableData {
+ offer_next_offset: u64,
+ local_next_offset: u64,
+ },
+ Offsets(ConsumerOffsetsWireError),
+ /// Duplicate base offset in the staged set.
+ DuplicateSegment {
+ start_offset: u64,
+ },
+ /// A hole between consecutive staged segments.
+ SegmentSetHole {
+ previous_end: u64,
+ next_start: u64,
+ },
+ /// Filesystem failure at/after the swap; disk holds a contiguous prefix
+ /// of the new state and a crash-restart recovers it (see the module
+ /// crash-window notes). The IN-MEMORY partition is converged to an
+ /// empty, honestly-lagging state before this returns, so the live
+ /// process stays serviceable and the normal triggers re-transfer.
+ SwapIo {
+ path: String,
+ source: std::io::Error,
+ },
+ /// Re-opening an installed segment failed; disk holds the full new
+ /// state, a restart boot-recovers it.
+ SegmentOpen {
+ path: String,
+ source: iggy_common::IggyError,
+ },
+ /// The post-failure convergence itself failed: the partition holds no
+ /// serviceable segment chain and every append or poll would panic. The
+ /// caller must fence this one partition (tear it down for the
+ /// reconciler to rebuild from disk) instead of leaving a live handle
+ /// whose first use kills the whole shard.
+ ConvergeFailed {
+ source: iggy_common::IggyError,
+ /// The offer's frontier, carried because the LIVE counter is not it on
+ /// this path: a mutate failure can leave the counter at its pre-install
+ /// value, and under an advancing purge generation that value is above
+ /// the group's. The fence records this instead, or it would stamp the
+ /// stale counter over the reset the install already made and then
+ /// quarantine the segments that would have contradicted it.
+ frontier: u64,
+ },
+}
+
+impl fmt::Display for PartitionInstallError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NoPartitionDir => write!(f, "partition has no on-disk directory"),
+ Self::StaleTransfer {
+ commit_op,
+ commit_min,
+ } => write!(
+ f,
+ "transfer frontier {commit_op} is below the local commit frontier {commit_min}"
+ ),
+ Self::FrontierNotDurable { frontier } => write!(
+ f,
+ "could not record the incoming offset frontier {frontier} before the swap"
+ ),
+ Self::OfferRewindsDurableData {
+ offer_next_offset,
+ local_next_offset,
+ } => write!(
+ f,
+ "offer frontier {offer_next_offset} is below this replica's own next offset \
+ {local_next_offset}; installing it would rewind the offset space"
+ ),
+ Self::Offsets(source) => write!(f, "consumer-offsets artifact rejected: {source}"),
+ Self::DuplicateSegment { start_offset } => {
+ write!(f, "duplicate staged segment at base offset {start_offset}")
+ }
+ Self::SegmentSetHole {
+ previous_end,
+ next_start,
+ } => write!(
+ f,
+ "staged segment set holds a hole: previous ends at {previous_end}, next starts at {next_start}"
+ ),
+ Self::SwapIo { path, source } => write!(f, "swap io failed at {path}: {source}"),
+ Self::SegmentOpen { path, source } => {
+ write!(f, "re-opening installed segment {path} failed: {source}")
+ }
+ Self::ConvergeFailed { source, frontier } => write!(
+ f,
+ "post-failure convergence failed at frontier {frontier}, \
+ the partition must be fenced: {source}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for PartitionInstallError {}
+
+impl From<ConsumerOffsetsWireError> for PartitionInstallError {
+ fn from(source: ConsumerOffsetsWireError) -> Self {
+ Self::Offsets(source)
+ }
+}
+
+/// Suffix marking a half-transferred file inside the partition directory.
+///
+/// Provably invisible to boot recovery, which filters on `extension == "log"`,
+/// and swept wholesale at boot by
+/// `segment_recovery::sweep_scratch_files_and_collect_offsets`.
+pub const STAGING_SUFFIX: &str = ".staging";
+
+/// Staging-file names inside the partition directory.
+fn staging_paths(partition_dir: &str, start_offset: u64) -> (PathBuf, PathBuf) {
+ (
+ PathBuf::from(format!(
+ "{partition_dir}/{start_offset:0>20}.log{STAGING_SUFFIX}"
+ )),
+ PathBuf::from(format!(
+ "{partition_dir}/{start_offset:0>20}.index{STAGING_SUFFIX}"
+ )),
+ )
+}
+
+/// Every entry of one partition directory, as paths.
+///
+/// BLOCKING `read_dir` on the pump: compio-fs 0.12 exposes no async directory
+/// walk, and `spawn_blocking` is not an escape either -- the shard executors run
+/// `thread_pool_limit(0)`. Bounded by the entry count of ONE partition directory,
+/// but it is a real stall (and under the write lock at the converge site), so it
+/// stays recorded rather than hidden.
+///
+/// Enumeration only: the three callers keep their own predicates and their own
+/// error policies (propagate / silent skip / log-and-fail), which is what
+/// `sweep_staging_except`'s do-not-widen warning depends on.
+fn segment_dir_entries(partition_dir: &str) -> std::io::Result<Vec<PathBuf>> {
+ Ok(std::fs::read_dir(partition_dir)?
+ .flatten()
+ .map(|entry| entry.path())
+ .collect())
+}
+
+/// Move every segment file in `partition_dir` aside into `<dir>.fenced.<n>/`,
+/// returning the directory used.
+///
+/// The partition directory itself STAYS, and so do its two superblock slots:
+/// they hold the group's only durable `(view, log_view)`, and moving them would
+/// make the rebuild read an empty directory -- no `restore_partition_view`,
+/// `consensus.init()` instead of `init_as_backup()`, no replica-identity guard --
+/// so the group would re-enter view 0 after acting in view N and could answer a
+/// retransmitted DVC with `(0, 0)`, letting a quorum adopt a log shorter than the
+/// committed prefix.
+///
+/// Nothing reclaims the fenced copies: they are evidence for an operator,
+/// bounded to 1000 per partition by the suffix search, and never read again
+/// (recovery keys on `.log` files inside the partition directory, and the fenced
+/// subdirectory is not one).
+///
+/// # Errors
+/// The underlying `std::io::Error`. A failure is NOT recoverable by rebuilding:
+/// the rebuild plants segment 0 with `file_exists = false` and truncates
+/// whatever the failed quarantine left, so callers tombstone the partition and
+/// leave the bytes for an operator.
+pub async fn quarantine_segment_files(partition_dir: &str) -> std::io::Result<String> {
+ // `create_dir`, not stat-then-create: one syscall per attempt instead of
+ // two, and race-free. Deliberately NOT `create_dir_all`, which succeeds on
+ // an existing directory and would silently merge this fence into an earlier
+ // copy.
+ let mut target = None;
+ for attempt in 0..1000 {
+ let candidate = format!("{partition_dir}.fenced.{attempt}");
+ match compio::fs::create_dir(&candidate).await {
+ Ok(()) => {
+ target = Some(candidate);
+ break;
+ }
+ // Lost the race for this suffix; the next iteration probes the
+ // next one.
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+ Err(error) => return Err(error),
+ }
+ }
+ let Some(target) = target else {
+ return Err(std::io::Error::other(
+ "a thousand fenced copies of this partition already exist",
+ ));
+ };
+ for path in segment_dir_entries(partition_dir)? {
+ let quarantined = path.to_str().is_some_and(|path| {
+ [".log", ".index", STAGING_SUFFIX]
+ .iter()
+ .any(|suffix| path.ends_with(suffix))
+ });
+ if !quarantined {
+ continue;
+ }
+ let Some(name) = path.file_name() else {
+ continue;
+ };
+ compio::fs::rename(&path, &PathBuf::from(&target).join(name)).await?;
+ }
+ // All three touched directories: the target (its new dirents), the source
+ // (the removals), and the source's parent (the target directory itself is a
+ // new dirent there). Without the target-side syncs a crash can leave the
+ // moved files linked in neither directory -- only forensics are at stake,
+ // but forensics are the whole point of the copies.
+ fsync_dir(&target).await?;
+ fsync_dir(partition_dir).await?;
+ if let Some(parent) = Path::new(partition_dir).parent().and_then(Path::to_str) {
+ fsync_dir(parent).await?;
+ }
+ Ok(target)
+}
+
+/// Unlink every staging file in `partition_dir` except `keep`.
+///
+/// Best-effort disk hygiene shared by the reuse scan and the install: a file
+/// that survives is swept at the next boot, so a failed unlink is not worth
+/// failing either caller for. The CONVERGE sweep is deliberately not this
+/// function -- it deletes the live chain as well and must propagate its
+/// errors.
+/// Do NOT widen this predicate to the quarantine's three-suffix list if the two
+/// are ever unified: the keep-lists callers pass hold staging paths only (purge
+/// passes none), so a wider filter would unlink every live `.log` and `.index`
+/// on the partition -- worst at the reuse scan, which runs at descriptor-accept
+/// on a serving partition.
+pub(crate) async fn sweep_staging_except(partition_dir: &str, keep: &HashSet<&Path>) {
+ let Ok(entries) = segment_dir_entries(partition_dir) else {
+ return;
+ };
+ for path in entries {
+ let is_staging = path
+ .to_str()
+ .is_some_and(|path| path.ends_with(STAGING_SUFFIX));
+ if is_staging && !keep.contains(path.as_path()) {
+ let _ = compio::fs::remove_file(&path).await;
+ }
+ }
+}
+
+fn final_paths(partition_dir: &str, start_offset: u64) -> (String, String) {
+ (
+ format!("{partition_dir}/{start_offset:0>20}.log"),
+ format!("{partition_dir}/{start_offset:0>20}.index"),
+ )
+}
+
+/// Consumer-offset files written concurrently while installing a transfer.
+/// Each is an open + write + optional fsync, so the width trades reactor queue
+/// depth against how long one partition monopolises it; matches the tick's
+/// superblock pre-pass.
+const OFFSET_PERSIST_CONCURRENCY: usize = 16;
+
+/// One consumer-offset file the install is about to write. Collected before any
+/// write is issued so the offset maps and the persisted-offset tracker are never
+/// borrowed across a batch's await.
+struct PlannedOffsetWrite {
+ kind: ConsumerKind,
+ id: u32,
+ path: String,
+ value: u64,
+}
+
+/// fsync the partition directory so a rename made durable stays durable.
+/// Async so the wait parks the task instead of the whole shard reactor;
+/// every other future on the pump keeps running through it.
+pub(crate) async fn fsync_dir(partition_dir: &str) -> std::io::Result<()> {
+ compio::fs::File::open(partition_dir)
+ .await?
+ .sync_all()
+ .await
+}
+
+impl<B, SB> IggyPartition<B, SB>
+where
+ B: MessageBus,
+ SB: SuperblockStore,
+{
+ /// Build (or serve from cache) this group's state-transfer offer.
+ ///
+ /// Force-flushes the committed prefix first so the segments cover every
+ /// committed `SendMessages` op and the offset table covers every
+ /// committed offset op; `commit_op = commit_min` then names the exact
+ /// state the artifacts represent. Segment bytes are NOT loaded here: the
+ /// offer records `(entry, path)` and the serving side loads one artifact
+ /// at a time, so building costs one streaming checksum pass per segment
+ /// and the resident footprint is just the offsets table.
+ ///
+ /// # Errors
+ /// [`PartitionTransferUnavailable`]; the requester falls back to journal
+ /// repair or retries after the next trigger.
+ #[allow(clippy::too_many_lines)]
+ pub async fn state_transfer_offer(
+ &mut self,
+ config: &PartitionsConfig,
+ ) -> Result<Rc<PartitionStateTransferOffer>, PartitionTransferUnavailable> {
+ if !consensus::is_caught_up_primary(self.consensus()) {
+ return Err(PartitionTransferUnavailable::NotCaughtUpPrimary);
+ }
+ if self.partition_dir.is_none() {
+ // Also defuses the in-memory trap where `segment.size` grows with
+ // no bytes on disk ("simulated in-memory batch persistence").
+ return Err(PartitionTransferUnavailable::NoPartitionDir);
+ }
+ if self.repair.is_some() {
+ return Err(PartitionTransferUnavailable::RepairInProgress);
+ }
+ // Primary-by-index at view 0 over an empty log passes every gate above
+ // yet knows nothing: a group whose directory is absent boots through
+ // `consensus.init()`, comes up Normal at view 0, and an empty group is
+ // trivially "caught up". Its offer would be zero segments at frontier
+ // 0, which makes a receiver holding real data unlink its own chain.
+ //
+ // A RESTARTED replica holding a full chain matches this shape too
+ // (`commit_max == 0` because the partition journal is memory-only,
+ // `installed_frontier == None` for a recovered non-empty chain). That
+ // is the load-bearing reason this refusal is safe rather than a
+ // wedge: every transfer-arm site presupposes a peer that already
+ // reported commit > 0 (repair floor refusals, StartView adoption), so
+ // nobody ever asks a cluster where everything still reports 0.
+ // Extending the gate with `recovered_durable_offset.is_some()` would
+ // be WRONG: such an offer carries `commit_op = 0`, so the receiver's
+ // floor becomes a no-op while its counter jumps to the frontier.
+ if self.consensus().commit_max() == 0 && self.installed_frontier.is_none() {
+ return Err(PartitionTransferUnavailable::NothingCommitted);
+ }
+ self.flush_committed_messages(config)
+ .await
+ .map_err(PartitionTransferUnavailable::FlushFailed)?;
+ let commit_op = self.consensus().commit_min();
+ if let Some(cached) = self.transfer_offer_cache.borrow().as_ref()
+ && cached.commit_op == commit_op
+ {
+ // Returns BEFORE the chain re-validation below, deliberately:
+ // re-validating on every hit is the walk the cache exists to skip.
+ // Retention GC on an idle partition therefore costs one wasted
+ // round -- the chunk serve fails `Stale` and the eviction path
+ // re-enumerates -- which is the cheaper side of the trade.
+ return Ok(Rc::clone(cached));
+ }
+
+ // Enumerate under the write lock so GC (`remove_sealed_segments_up_to`,
+ // also write-locked) cannot unlink a file between enumeration and read.
+ // The checksum passes below run with the lock RELEASED: they are the
+ // expensive part, and the same mutex serializes
+ // `append_send_messages_to_journal` and `commit_messages_inner`, so
+ // holding it across a multi-GiB first pass stalls this partition's
+ // produce and commit for the whole pass. The chain is re-validated
+ // under the lock afterwards.
+ let write_lock = self.write_lock.clone();
+ // Sampled with the chain: a purge inside the hash window below both
+ // truncates every file and restarts the offset space, so a size that
+ // grew back past its planned length would pass the size re-check while
+ // the stamps describe post-purge bytes at pre-purge offsets.
+ let planned_purge_generation = self.applied_purge_generation;
+ let planned: Vec<(u64, u64, String)> = {
+ let _guard = write_lock.lock().await;
+ let mut planned = Vec::with_capacity(self.log.segments().len());
+ for (segment, storage) in self.log.segments().iter().zip(self.log.storages()) {
+ let size = segment.size.as_bytes_u64();
+ if size == 0 {
+ continue;
+ }
+ let (log_path, _) = storage.segment_and_index_paths();
+ let Some(log_path) = log_path else {
+ return Err(PartitionTransferUnavailable::SegmentUnreadable {
+ start_offset: segment.start_offset,
+ source: std::io::Error::other("segment holds bytes but no backing file"),
+ });
+ };
+ planned.push((segment.start_offset, size, log_path));
+ }
+ planned
+ };
+ // One manifest entry per planned segment plus the offsets table. The
+ // manifest encoder ASSERTS its entry ceiling and that assert survives
+ // release builds, so a partition retaining more segments than the
+ // ceiling would panic this shard the moment a peer asked it to serve.
+ // A small configured `segment_size` makes a chain that long ordinary,
+ // so refuse the request instead of tripping the assert.
+ let manifest_entries = planned.len() + 1;
+ let manifest_entries_max = consensus::state_manifest::STATE_MANIFEST_ENTRIES_MAX as usize;
+ if manifest_entries > manifest_entries_max {
+ return Err(PartitionTransferUnavailable::ManifestTooLarge {
+ entries: manifest_entries,
+ max: manifest_entries_max,
+ });
+ }
+
+ // The checksum pass is the expensive part and it runs inside ONE frame
+ // body: the router's tick arm is not polled while another arm's body
+ // awaits, and the yields inside `hash_segment_range` move the reactor,
+ // not this shard's consensus ticks. A cold pass over multi-GiB
+ // retention therefore silences every group on this core for its whole
+ // duration, past `heartbeat_timeout`, on the node that by construction
+ // is the caught-up primary of those groups.
+ //
+ // Bounded per round instead. The memo carries partial progress, so a
+ // refusal here is not lost work: the requester re-asks on its flat
+ // transient interval and each round advances the pass by the budget
+ // until the offer completes.
+ let mut budget = OFFER_HASH_BUDGET_PER_ROUND_BYTES;
+ let mut segments = Vec::with_capacity(planned.len());
+ for (start_offset, size, log_path) in &planned {
+ let Some(checksum) = self
+ .segment_checksum(*start_offset, *size, log_path, &mut budget)
+ .await?
+ else {
+ // CUMULATIVE across rounds, read back off the memo: per-round
+ // figures are constant by construction (a partial round always
+ // spends exactly the budget and always stops inside one
+ // segment), so they render identically on round 1 and round 30
+ // and an operator cannot tell a converging pass from a wedged
+ // one. This is the only window onto a multi-round build.
+ let hashed = self.hashed_prefix_len(&planned);
+ let total = planned.iter().map(|(_, size, _)| *size).sum::<u64>();
+ // The completing round's sweep is skipped on this path, so
+ // prune here too: retention GC can unlink segments across a
+ // long build, and their memos would otherwise accumulate until
+ // some round finally runs the loop to the end.
+ self.retain_segment_checksum_memos(&planned);
+ return Err(PartitionTransferUnavailable::OfferBuildInProgress {
+ hashed,
+ remaining: total.saturating_sub(hashed),
+ });
+ };
+ segments.push(SegmentArtifactSource {
+ entry: consensus::StateArtifact {
+ kind: artifact_kind::SEGMENT_LOG,
+ frontier: *start_offset,
+ len: *size,
+ checksum,
+ },
+ log_path: log_path.clone(),
+ });
+ }
+
+ // Re-validate the chain under the lock: the passes above yielded, so GC
+ // could have unlinked a sealed segment or a purge could have planted a
+ // fresh file at a planned path. Every stamp would then describe bytes
+ // the offer no longer addresses, so refuse and let the requester ask
+ // again against the chain that exists now.
+ {
+ let _guard = write_lock.lock().await;
+ let live: std::collections::HashMap<u64, u64> = self
+ .log
+ .segments()
+ .iter()
+ .map(|segment| (segment.start_offset, segment.size.as_bytes_u64()))
+ .collect();
+ // Append-only within a segment instance, so a live size BELOW the
+ // planned one means the file was replaced rather than extended.
+ let changed = planned_purge_generation != self.applied_purge_generation
+ || planned.iter().any(|(start_offset, size, _)| {
+ live.get(start_offset)
+ .is_none_or(|live_size| live_size < size)
+ });
+ if changed {
+ return Err(PartitionTransferUnavailable::SegmentSetChanged);
+ }
+ // Sweep memo entries whose segment left the chain (GC), so the map
+ // tracks the live chain rather than growing with history.
+ self.segment_checksum_cache
+ .borrow_mut()
+ .retain(|start_offset, _| live.contains_key(start_offset));
+ }
+
+ // Second phantom gate, on the BUILT offer rather than on `commit_max`.
+ // The gate above keys on `commit_max() == 0`, which a replica that lifted
+ // its commit floor through an offsets-only repair window clears while
+ // still holding zero bytes; such a replica passes `is_caught_up_primary`
+ // and would hand a data-holding peer an empty chain at frontier 0,
+ // making it unlink its own. An offer with no segments AND no offset
+ // space is indistinguishable from that phantom, and a group genuinely
+ // in that state has nothing worth transferring anyway.
+ let offsets_wire = self.offsets_wire_snapshot();
+ if segments.is_empty() && offsets_wire.next_offset == 0 {
+ return Err(PartitionTransferUnavailable::NothingCommitted);
+ }
+ let offsets_bytes = Rc::new(offsets_wire.encode());
+ let offsets_entry = consensus::StateArtifact::for_bytes(
+ artifact_kind::CONSUMER_OFFSETS,
+ commit_op,
+ &offsets_bytes,
+ );
+ let offer = Rc::new(PartitionStateTransferOffer {
+ commit_op,
+ segments,
+ offsets: (offsets_entry, offsets_bytes),
+ });
+ *self.transfer_offer_cache.borrow_mut() = Some(Rc::clone(&offer));
+ Ok(offer)
+ }
+
+ /// Bytes of `planned` the memo already covers, clamped per segment to the
+ /// planned length so a memo carrying an active segment's later growth
+ /// cannot report more than this offer will hash.
+ fn hashed_prefix_len(&self, planned: &[(u64, u64, String)]) -> u64 {
+ let memos = self.segment_checksum_cache.borrow();
+ planned
+ .iter()
+ .map(|(start_offset, size, _)| {
+ memos
+ .get(start_offset)
+ .map_or(0, |memo| memo.hashed_len.min(*size))
+ })
+ .sum()
+ }
+
+ /// Drop memo entries whose segment is no longer in `planned`, which is the
+ /// live chain as of this round.
+ ///
+ /// Set-based rather than a scan per entry: this runs on every
+ /// budget-exhausted round, inside the frame body the budget exists to
+ /// bound, and `planned` is capped by `STATE_MANIFEST_ENTRIES_MAX` rather
+ /// than by anything an operator sized, so the quadratic form dominates the
+ /// hashing it was meant to make room for.
+ fn retain_segment_checksum_memos(&self, planned: &[(u64, u64, String)]) {
+ let live: HashSet<u64> = planned
+ .iter()
+ .map(|(start_offset, _, _)| *start_offset)
+ .collect();
+ self.segment_checksum_cache
+ .borrow_mut()
+ .retain(|start_offset, _| live.contains(start_offset));
+ }
+
+ /// The artifact stamp over the first `size` bytes of a segment file,
+ /// extending the memoized hasher rather than re-reading what it already
+ /// covered.
+ ///
+ /// Sealed segments hit the memo outright. The active one pays for its delta
+ /// only, which is what keeps a committing primary off a full re-read of the
+ /// retained history every round: `commit_op` advances per round, so the
+ /// offer cache misses even when nothing else changed.
+ ///
+ /// `budget` caps the bytes this call may read, charged as it goes. `None`
+ /// means the budget ran out first: the memo holds everything hashed so far
+ /// and the next call resumes from it.
+ ///
+ /// # Errors
+ /// [`PartitionTransferUnavailable::SegmentUnreadable`] when the file is
+ /// unreadable or shorter than `size`.
+ async fn segment_checksum(
+ &self,
+ start_offset: u64,
+ size: u64,
+ log_path: &str,
+ budget: &mut u64,
+ ) -> Result<Option<u64>, PartitionTransferUnavailable> {
+ // Taken OUT of the map for the read: the hash awaits, and a half-fed
+ // hasher left visible could be extended twice by a second build.
+ let memo = self
+ .segment_checksum_cache
+ .borrow_mut()
+ .remove(&start_offset);
+ let mut memo = match memo {
+ // `<=`, so the already-hashed case falls through to the shared tail:
+ // `hash_segment_range` returns before opening the file when
+ // `from == to`, and the finish + reinsert below is the same work the
+ // separate arm did.
+ Some(memo) if memo.hashed_len <= size => memo,
+ // Segment bytes are append-only within one segment instance (the
+ // failed-index-save path rewinds the writer cursor and returns
+ // BEFORE the size increment), and every path that plants a fresh
+ // file at an existing base offset clears the whole map, so a
+ // shrunk size means the two have drifted.
+ shrunk => {
+ debug_assert!(
+ shrunk.is_none(),
+ "segment {start_offset} shrank to {size} bytes below its memo"
+ );
+ SegmentChecksumMemo::new()
+ }
+ };
+ // Clamped to the round's remaining budget, so a single multi-GiB
+ // segment is split across rounds rather than being the granularity
+ // floor. `finish` does not consume the hasher, so a partial pass is
+ // simply a memo nobody stamps yet.
+ let target = size.min(memo.hashed_len.saturating_add(*budget));
+ let hashed = target.saturating_sub(memo.hashed_len);
+ // Dropped on failure, not reinserted: the hasher is fed chunk by chunk
+ // and a mid-range error leaves it holding bytes `hashed_len` does not
+ // account for, so resuming from it would stamp a checksum over a
+ // doubly-fed prefix. Losing the partial pass is the cheap side.
+ hash_segment_range(log_path, memo.hashed_len, target, &mut memo.hasher, None)
+ .await
+ .map_err(|source| PartitionTransferUnavailable::SegmentUnreadable {
+ start_offset,
+ source,
+ })?;
+ memo.hashed_len = target;
+ let checksum = (target == size).then(|| memo.hasher.finish());
+ self.segment_checksum_cache
+ .borrow_mut()
+ .insert(start_offset, memo);
+ *budget = budget.saturating_sub(hashed);
+ Ok(checksum)
+ }
+
+ /// [`quarantine_segment_files`] over this partition's directory, for the
+ /// shard's `ConvergeFailed` fence -- the safety argument (segment files
+ /// move, superblock slots STAY, copies are unreclaimed operator evidence)
+ /// lives on the free function. `None` for an in-memory partition.
+ ///
+ /// # Errors
+ /// The underlying `std::io::Error`; see [`quarantine_segment_files`] for why
+ /// a failure is not something the rebuild can absorb.
+ pub async fn quarantine_partition_dir(&self) -> std::io::Result<Option<String>> {
+ let Some(dir) = self.partition_dir.clone() else {
+ return Ok(None);
+ };
+ quarantine_segment_files(&dir).await.map(Some)
+ }
+
+ /// Release the cached offer once no requester holds one (the shard's
+ /// offer-expiry sweep).
+ pub fn clear_state_transfer_offer_cache(&self) {
+ self.transfer_offer_cache.borrow_mut().take();
+ }
+
+ /// Snapshot the live offset maps + purge generation into the wire shape.
+ /// Eagerly auto-committed offsets can run slightly ahead of committed
+ /// state; that is safe because their covering ops sit in
+ /// `(commit_op, commit_max]`, which the receiver's tail repair replays,
+ /// and offset applies converge (monotone auto-commit, verbatim stores).
+ fn offsets_wire_snapshot(&self) -> ConsumerOffsetsWire {
+ // Every key is minted from a u32 wire id, so the narrowing filter is
+ // an invariant, not a policy: say so out loud instead of silently
+ // shrinking the snapshot when it ever breaks.
+ let mut consumers: Vec<(u32, u64)> = self
+ .consumer_offsets
+ .pin()
+ .iter()
+ .filter_map(|(id, offset)| {
+ let narrowed = u32::try_from(*id).ok();
+ debug_assert!(narrowed.is_some(), "consumer offset key {id} exceeds u32");
+ narrowed.map(|id| (id, offset.offset.load(Ordering::Acquire)))
+ })
+ .collect();
+ consumers.sort_unstable_by_key(|(id, _)| *id);
+ let mut groups: Vec<(u32, u64)> = self
+ .consumer_group_offsets
+ .pin()
+ .iter()
+ .filter_map(|(id, offset)| {
+ let narrowed = u32::try_from(id.0).ok();
+ debug_assert!(
+ narrowed.is_some(),
+ "consumer group offset key {} exceeds u32",
+ id.0
+ );
+ narrowed.map(|id| (id, offset.offset.load(Ordering::Acquire)))
+ })
+ .collect();
+ groups.sort_unstable_by_key(|(id, _)| *id);
+ // The append counter, not the segment end: retention can GC every
+ // sealed segment while the counter stands at N, and the receiver
+ // must resume minting at N either way.
+ let next_offset = self.offset_frontier();
+ ConsumerOffsetsWire {
+ purge_generation: self.applied_purge_generation,
+ next_offset,
+ consumers,
+ groups,
+ }
+ }
+
+ /// Validate one completed `SEGMENT_LOG` artifact and spill it to staging
+ /// files, returning the walk metadata. Frees receiver memory as it goes:
+ /// after this the session drops the artifact's buffer.
+ ///
+ /// # Errors
+ /// `Err(walk error description)` when the payload fails validation (the
+ /// caller charges the decode budget), or a staging-write failure
+ /// description.
+ pub async fn spill_transfer_segment(
+ &self,
+ entry: &consensus::StateArtifact,
+ bytes: Vec<u8>,
+ ) -> Result<StagedSegmentMeta, SpillError> {
+ let Some(partition_dir) = self.partition_dir.clone() else {
+ return Err(SpillError::NoPartitionDir);
+ };
+ // This write replaces whatever the reuse memo recorded for this path,
+ // so the memo cannot outlive it: a later scan against an older offer
+ // must re-read the file rather than trust a walk of the old bytes.
+ self.reuse_scan_memo.borrow_mut().take();
+ // Artifact-level integrity FIRST, exactly as the metadata plane
+ // verifies every artifact before decoding: the walk's per-batch
+ // checksums prove batch bodies, not that these are the bytes the
+ // manifest promised (length alone is implied by completion).
+ if !verify_state_artifact_yielding(entry, &bytes).await {
+ return Err(SpillError::ManifestChecksum {
+ frontier: entry.frontier,
+ });
+ }
+ let (stats, index_bytes) = walk_segment_payload(entry.frontier, &bytes)
+ .await
+ .map_err(SpillError::Walk)?;
+ let (log_staging, index_staging) = staging_paths(&partition_dir, entry.frontier);
+ let index_size = index_bytes.len() as u64;
+ // Two writes, not a loop: each moves its buffer into compio's
+ // owned-buffer API, so a segment-sized payload is never copied.
+ write_staging_file(&log_staging, bytes)
+ .await
+ .map_err(|source| SpillError::StagingIo {
+ path: log_staging.clone(),
+ source,
+ })?;
+ write_staging_file(&index_staging, index_bytes)
+ .await
+ .map_err(|source| SpillError::StagingIo {
+ path: index_staging.clone(),
+ source,
+ })?;
+ fsync_dir(&partition_dir)
+ .await
+ .map_err(|source| SpillError::StagingIo {
+ path: PathBuf::from(&partition_dir),
+ source,
+ })?;
+ Ok(StagedSegmentMeta::from_walk(
+ entry,
+ stats,
+ index_size,
+ log_staging,
+ index_staging,
+ ))
+ }
+
+ /// Adopt an already-verified staged log without rewriting it: walk the
+ /// payload once (validation + a rebuilt sparse index), write only the
+ /// index sidecar, and return the walk metadata. The reuse scan calls
+ /// this after `verify_state_artifact` proved the bytes match the
+ /// manifest; rewriting the byte-identical log (and re-verifying a third
+ /// time) is exactly the work reuse exists to skip. The index write
+ /// stays: the scan never checks `.index.staging`, and a missing sidecar
+ /// would hand the install a missing rename source.
+ ///
+ /// No directory fsync here: every sidecar lands in the same directory, so
+ /// the caller fsyncs ONCE after its loop instead of once per adoption.
+ async fn adopt_staged_segment(
+ &self,
+ entry: &consensus::StateArtifact,
+ bytes: &[u8],
+ ) -> Result<StagedSegmentMeta, SpillError> {
+ let Some(partition_dir) = self.partition_dir.clone() else {
+ return Err(SpillError::NoPartitionDir);
+ };
+ let (stats, index_bytes) = walk_segment_payload(entry.frontier, bytes)
+ .await
+ .map_err(SpillError::Walk)?;
+ let (log_staging, index_staging) = staging_paths(&partition_dir, entry.frontier);
+ let index_size = index_bytes.len() as u64;
+ write_staging_file(&index_staging, index_bytes)
+ .await
+ .map_err(|source| SpillError::StagingIo {
+ path: index_staging.clone(),
+ source,
+ })?;
+ Ok(StagedSegmentMeta::from_walk(
+ entry,
+ stats,
+ index_size,
+ log_staging,
+ index_staging,
+ ))
+ }
+
+ /// Scan the partition directory for staging files left by an earlier
+ /// session and adopt every one that matches a manifest entry byte-for-
+ /// byte (length + artifact checksum + full re-walk). Sealed segments are
+ /// immutable, so on a retry or peer re-target typically only the active
+ /// segment and the offsets artifact re-pull. Staging strays matching no
+ /// entry are swept.
+ ///
+ /// A scan against a segment set this partition already scanned short-
+ /// circuits through [`ReuseScanMemo`]: rotating to another peer would
+ /// otherwise re-read and re-walk every staged file, up to 2 GiB each,
+ /// sequentially, on the pump.
+ pub async fn reuse_staged_segments(
+ &self,
+ manifest: &[consensus::StateArtifact],
+ ) -> Vec<(u32, StagedSegmentMeta)> {
+ let Some(partition_dir) = self.partition_dir.clone() else {
+ return Vec::new();
+ };
+ let digest = segment_manifest_digest(manifest);
+ // Cloned out of the borrow: the file re-checks below await.
+ let memoized = self
+ .reuse_scan_memo
+ .borrow()
+ .as_ref()
+ .filter(|memo| memo.digest == digest)
+ .map(|memo| memo.adopted.clone());
+ if let Some(adopted) = memoized {
+ // The memo proves the bytes were validated; only their continued
+ // presence needs re-checking (an install or a converge between the
+ // two scans unlinks them).
+ let mut intact = true;
+ for (_, meta) in &adopted {
+ let log_matches = matches!(
+ compio::fs::metadata(&meta.log_staging).await,
+ Ok(metadata) if metadata.len() == meta.size
+ );
+ if !log_matches || compio::fs::metadata(&meta.index_staging).await.is_err() {
+ intact = false;
+ break;
+ }
+ }
+ if intact {
+ return adopted;
+ }
+ self.reuse_scan_memo.borrow_mut().take();
+ }
+ let mut adopted = Vec::new();
+ let mut matched_paths = Vec::new();
+ for (index, entry) in manifest.iter().enumerate() {
+ if entry.kind != artifact_kind::SEGMENT_LOG {
+ continue;
+ }
+ let (log_staging, _) = staging_paths(&partition_dir, entry.frontier);
+ // Length short-circuit BEFORE reading: length is the first
+ // conjunct of the artifact check anyway, and the common retry
+ // case is precisely an active-segment length mismatch -- no
+ // point reading up to 2 GiB just to discard it.
+ match compio::fs::metadata(&log_staging).await {
+ Ok(metadata) if metadata.len() == entry.len => {}
+ _ => continue,
+ }
+ let Ok(bytes) = compio::fs::read(&log_staging).await else {
+ continue;
+ };
+ if !verify_state_artifact_yielding(entry, &bytes).await {
+ continue;
+ }
+ if let Ok(meta) = self.adopt_staged_segment(entry, &bytes).await {
+ matched_paths.push(meta.log_staging.clone());
+ matched_paths.push(meta.index_staging.clone());
+ #[allow(clippy::cast_possible_truncation)]
+ adopted.push((index as u32, meta));
+ }
+ }
+ // Every rebuilt sidecar landed in the same directory, so one fsync
+ // covers them all. Its failure discards EVERY adoption: the per-adopt
+ // filter above no longer sees a durability failure, and an undurable
+ // sidecar handed to the install is a missing rename source after a
+ // crash.
+ if !adopted.is_empty() && fsync_dir(&partition_dir).await.is_err() {
+ adopted.clear();
+ matched_paths.clear();
+ }
+ // Sweep strays: anything staged that no adopted meta claims.
+ let keep: HashSet<&Path> = matched_paths.iter().map(PathBuf::as_path).collect();
+ sweep_staging_except(&partition_dir, &keep).await;
+ *self.reuse_scan_memo.borrow_mut() = Some(ReuseScanMemo {
+ digest,
+ adopted: adopted.clone(),
+ });
+ adopted
+ }
+
+ /// Install a fully transferred partition state: swap the staged segment
+ /// files in, rebuild the in-memory log over them, replace the consumer
+ /// offset tables, clear the journal, and lift the commit floor to
+ /// `commit_op`. The live tail `(commit_op, commit_max]` is left to
+ /// ordinary journal repair.
+ ///
+ /// Two-phase: every validation runs before any mutation. The mutate
+ /// phase's crash windows all recover as an honestly-shorter partition
+ /// (see the swap ordering comments); no durable completeness claim
+ /// exists anywhere, so boot re-derives from whatever files survive.
+ ///
+ /// # Errors
+ /// [`PartitionInstallError`]; check-phase variants mutate nothing.
+ #[allow(clippy::too_many_lines)]
+ pub async fn install_state_transfer(
+ &mut self,
+ config: &PartitionsConfig,
+ commit_op: u64,
+ mut staged: Vec<StagedSegmentMeta>,
+ offsets_bytes: &[u8],
+ committed_purge_generation: u64,
+ ) -> Result<PartitionInstallOutcome, PartitionInstallError> {
+ // ---- check phase: nothing below may mutate ----
+ let Some(partition_dir) = self.partition_dir.clone() else {
+ return Err(PartitionInstallError::NoPartitionDir);
+ };
+ let commit_min = self.consensus().commit_min();
+ if commit_op < commit_min {
+ // The receiver's commit walk is frozen while transferring (the
+ // `is_transferring` dispatch gates), and install runs on the
+ // single pump task, so this is a refusal of a genuinely stale
+ // offer, not a race.
+ return Err(PartitionInstallError::StaleTransfer {
+ commit_op,
+ commit_min,
+ });
+ }
+ // The install rewinds the sequencer to `commit_op`, which erases ops
+ // this replica may already have journaled and acked. Bounding it below
+ // by what this replica knows to be COMMITTED keeps the erased window to
+ // ops it does not know are committed -- the checkable form of an
+ // argument the rewind's own comment only asserts. Free on an honest
+ // offer: only a caught-up primary can serve, so its `commit_min`
+ // equals its `commit_max`, and the receiver's descriptor gate already
+ // refused any peer whose `commit_max` was below this one's.
+ let commit_max = self.consensus().commit_max();
+ if commit_op < commit_max {
+ return Err(PartitionInstallError::StaleTransfer {
+ commit_op,
+ commit_min: commit_max,
+ });
+ }
+ let offsets_wire = ConsumerOffsetsWire::decode(offsets_bytes)?;
+ // Anti-rewind against the LOCAL OFFSET COUNTER, not the commit
+ // frontier: the partition journal is memory-only and
+ // `restore_partition_view` restores view/log_view alone, so `commit_min`
+ // is 0 after every restart however much data sits on disk -- the
+ // `StaleTransfer` refusal above is inert on exactly the canonical
+ // rejoin. The counter is the one signal that is `Some`-equivalent in
+ // EVERY state the offset space has advanced through (recovered bytes,
+ // an installed frontier, a converge after a failed install --
+ // `recovered_durable_offset` is `None` in the last two), and it is
+ // precisely what a rewind corrupts: received prepares are pre-stamp,
+ // `stamp_prepare_for_persistence` overwrites `base_offset` from this
+ // counter and recomputes `batch_checksum` over it, so a rewound
+ // counter persists different bytes and a different checksum on this
+ // replica than on the rest of the group. A purge is the one
+ // legitimate rewind, and the artifact carries the generation that
+ // proves one happened.
+ // Against the METADATA plane's committed generation, which the caller
+ // reads off durable state, NOT against `self.applied_purge_generation`:
+ // that one is memory-only and reads 0 after every restart, so a
+ // post-restart rejoin of any ever-purged topic would see
+ // `offered > 0 == applied` and call it an advancing purge. That is the
+ // canonical rejoin, and treating it as a purge disables the
+ // `OfferRewindsDurableData` refusal below -- the one guard standing
+ // between an offer that rewinds this replica's offset space and its
+ // durable data.
+ let purge_advances = offsets_wire.purge_generation > committed_purge_generation;
+ let local_next_offset = self.offset_frontier();
+ if !purge_advances && local_next_offset > 0 && offsets_wire.next_offset < local_next_offset
+ {
+ return Err(PartitionInstallError::OfferRewindsDurableData {
+ offer_next_offset: offsets_wire.next_offset,
+ local_next_offset,
+ });
+ }
+ staged.sort_unstable_by_key(|meta| meta.start_offset);
+ for pair in staged.windows(2) {
+ if pair[1].start_offset == pair[0].start_offset {
+ return Err(PartitionInstallError::DuplicateSegment {
+ start_offset: pair[1].start_offset,
+ });
+ }
+ if pair[1].start_offset != pair[0].end_offset + 1 {
+ return Err(PartitionInstallError::SegmentSetHole {
+ previous_end: pair[0].end_offset,
+ next_start: pair[1].start_offset,
+ });
+ }
+ }
+
+ // ---- mutate phase ----
+ // Record the INCOMING frontier before anything destructive: the swap
+ // below unlinks the old chain and makes that durable before the first
+ // staged rename lands, and boot sweeps `.log.staging`, so a crash in
+ // that window would otherwise leave the frontier named by nothing at
+ // all and the replica would re-mint from 0 against a group at N.
+ //
+ // REFUSED, not logged and continued: this write is the sole durable
+ // carrier of the frontier on the path that matters, and if the converge
+ // that follows a failed install also fails, the fence quarantines away
+ // the very segments that would otherwise witness the counter. A
+ // storeless partition returns true early, so refusing here cannot
+ // wedge the in-memory case. Nothing has been mutated yet.
+ //
+ // Under `purge_advances` the offer's frontier is legitimately BELOW the
+ // live counter and must be written as a RESET. The advancing form would
+ // max it back up to the pre-purge value, and the reset it defers to
+ // belongs to a `purge` this replica provably never ran -- `purge_advances`
+ // is true precisely because it missed one. A crash between the old
+ // chain's unlink fsync and the last staged rename would then boot with
+ // zero `.log` files and re-seed the counter from the pre-purge frontier,
+ // above a group that restarted at the offer's, and the next prepare
+ // would stamp a `base_offset` and `batch_checksum` no peer shares.
+ let frontier_durable = if purge_advances {
+ self.reset_offset_frontier_at(offsets_wire.next_offset)
+ .await
+ } else {
+ self.persist_offset_frontier_at(offsets_wire.next_offset)
+ .await
+ };
+ if !frontier_durable {
+ return Err(PartitionInstallError::FrontierNotDurable {
+ frontier: offsets_wire.next_offset,
+ });
+ }
+ // The write lock spans the convergence too: a mutate failure leaves
+ // the segment vectors drained, and a concurrent replicated append
+ // indexing `segments().len() - 1` on the emptied vec is exactly the
+ // race every other segment-vec mutator takes this lock against.
+ let write_lock = self.write_lock.clone();
+ let _guard = write_lock.lock().await;
+ // Captured before `staged` moves: the convergence may only claim an
+ // offset frontier when the offer itself proved nothing is retained
+ // below it.
+ let staged_was_empty = staged.is_empty();
+ let outcome = self
+ .apply_checked_install(config, commit_op, staged, &offsets_wire, &partition_dir)
+ .await;
+ if outcome.is_err() {
+ // A mutate-phase failure can leave the log drained or half
+ // rebuilt while the disk already holds any prefix of the new
+ // chain. Converge BOTH the live state and the disk to an empty,
+ // honestly-lagging partition, so the next flush or poll cannot
+ // hit an empty segment vec and no stray chain can resurrect at
+ // boot; the normal triggers re-transfer the rest. The offset
+ // counter still seeds from the artifact frontier: a replica
+ // that resumed minting at 0 would fork its batch stamps from
+ // the group. A convergence failure outranks the install error:
+ // the partition cannot serve and the caller must fence it.
+ self.converge_to_empty_after_failed_install(
+ config,
+ offsets_wire.next_offset,
+ staged_was_empty,
+ )
+ .await
+ .map_err(|source| PartitionInstallError::ConvergeFailed {
+ source,
+ frontier: offsets_wire.next_offset,
+ })?;
+ }
+ // The frontier just moved with nothing durable naming it (an all-GC'd
+ // origin leaves no segment carrying it, and the crash windows inside
+ // the swap leave none either), so record it before returning. Runs for
+ // the converge path too: it seeds the counter from the same artifact.
+ //
+ // Logged rather than refused: the install already mutated, and the
+ // pre-swap write above left a valid lower bound on disk either way. The
+ // failure still matters -- the ordinary retry is the view-change gate,
+ // which an idle group may not reach for a long time -- so it must not
+ // pass silently.
+ if !self.persist_offset_frontier().await {
+ tracing::error!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ namespace_raw = self.consensus().namespace(),
+ frontier = self.offset_frontier(),
+ "state-transfer install could not record the installed offset frontier; \
+ the durable record stays at the pre-swap claim until the next view change"
+ );
+ }
+ outcome
+ }
+
+ /// The install's mutate phase; every early return is a failure the
+ /// caller converges from. Split out so the convergence handling cannot
+ /// be forgotten on a new error path. Runs under the caller's write-lock
+ /// guard.
+ #[allow(clippy::too_many_lines)]
+ async fn apply_checked_install(
+ &mut self,
+ config: &PartitionsConfig,
+ commit_op: u64,
+ staged: Vec<StagedSegmentMeta>,
+ offsets_wire: &ConsumerOffsetsWire,
+ partition_dir: &str,
+ ) -> Result<PartitionInstallOutcome, PartitionInstallError> {
+ // Sweep staging strays a dead earlier attempt left behind, keeping
+ // only what THIS install is about to rename. Bounded disk hygiene;
+ // the reuse-scan sweeps too, and boot sweeps ALL of `.staging`
+ // (`sweep_scratch_files_and_collect_offsets`), so a transfer abandoned
+ // for good leaks at most until the next restart.
+ // A SET, not a list: the sweep tests every staging dirent against this,
+ // and `staged` is peer-supplied up to `STATE_MANIFEST_ENTRIES_MAX`, so a
+ // linear membership test makes the whole sweep quadratic in a number the
+ // requester chooses -- under the partition write lock, with no yields.
+ let keep: HashSet<&Path> = staged
+ .iter()
+ .flat_map(|meta| [meta.log_staging.as_path(), meta.index_staging.as_path()])
+ .collect();
+ sweep_staging_except(partition_dir, &keep).await;
+
+ // The install recreates segment files at paths it unlinks (the staged
+ // chain can reuse the same base offsets), so an in-flight poll's
+ // cached read fd would keep serving the unlinked pre-install inodes
+ // as live data -- worst case, purged messages returning checksum-clean
+ // on a receiver that missed the purge. Same hazard and same fix as
+ // `purge`: wipe the shared read-state slots first, so suspended walks
+ // re-resolve by path and see the fresh files.
+ self.log.invalidate_sealed_read_state();
+ self.segment_checksum_cache.borrow_mut().clear();
+ // Every staging file this install does not rename away is gone by the
+ // time it returns, and the ones it does rename stop being staging
+ // files, so no memo entry can survive it.
+ self.reuse_scan_memo.borrow_mut().take();
+
+ // Unlink the old segment chain oldest-first (a crash mid-loop leaves
+ // the NEWEST suffix, which is contiguous) and drop the in-memory
+ // vectors in lockstep, exactly as `purge` does.
+ let namespace_raw = self.consensus().namespace();
+ while let Some((_, mut storage)) = self.log.retire_front() {
+ let (messages_path, index_path) = storage.segment_and_index_paths();
+ let _ = storage.shutdown();
+ drop(storage);
+ for path in messages_path.into_iter().chain(index_path) {
+ match compio::fs::remove_file(&path).await {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => {
+ // Propagated, not shrugged off: a surviving old
+ // `.log` outside the staged set resurrects at boot
+ // (recovery takes every `.log` stem), and can push
+ // the recovered chain past the installed one. The
+ // converge path re-sweeps the directory.
+ warn_unlink(namespace_raw, &path, &error);
+ return Err(PartitionInstallError::SwapIo {
+ path,
+ source: error,
+ });
+ }
+ }
+ }
+ }
+ fsync_dir(partition_dir)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ })?;
+
+ // Rename staged -> final: ALL indexes first, one directory fsync,
+ // then each log rename with its own fsync (N+1 total, down from 2N).
+ // Every index is durable before any log rename is issued -- strictly
+ // stronger than index_i-before-log_i -- because boot recovery
+ // derives segment bounds from the index and treats a `.log` without
+ // its `.index` as fatal, while an orphaned `.index` is invisible
+ // (recovery keys on `.log` stems). Each log rename remains its
+ // segment's commit point, and a crash mid-loop leaves a strict
+ // PREFIX of the new chain visible, which boots as a shorter
+ // contiguous partition and re-triggers transfer for the rest. Not
+ // fewer fsyncs than this: one-per-pair would lean on intra-directory
+ // rename ordering POSIX does not grant.
+ //
+ // KNOWN WINDOW, above and here: the old chain's unlinks are already
+ // durable and no staged log has landed yet. The frontier IS named
+ // durably across it -- the install records it in the superblock before
+ // the first unlink and refuses outright if that write fails -- so a
+ // crash here boots to zero segments with the counter re-seeded from the
+ // record, and the replica takes a clean full re-transfer. The residual
+ // is narrow: a record that predates this install (a fresh joiner's
+ // view-adoption write leaves frontier 0, which reads as no record at
+ // all), where `repaired_window_is_offsets_only` can then accept a
+ // complete offsets-only window with the counter still at 0.
+ for meta in &staged {
+ let (_, index_final) = final_paths(partition_dir, meta.start_offset);
+ compio::fs::rename(&meta.index_staging, &index_final)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: index_final.clone(),
+ source,
+ })?;
+ }
+ fsync_dir(partition_dir)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ })?;
+ // One directory handle for the whole loop. The per-rename fsync STAYS --
+ // each log rename is that segment's commit point and the ordering is the
+ // crash-safety argument -- but re-opening the directory to make each one
+ // is an `open`+`close` per segment for no durability gain.
+ let dir_handle = compio::fs::File::open(partition_dir)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ })?;
+ for meta in &staged {
+ let (log_final, _) = final_paths(partition_dir, meta.start_offset);
+ compio::fs::rename(&meta.log_staging, &log_final)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: log_final.clone(),
+ source,
+ })?;
+ dir_handle
+ .sync_all()
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ })?;
+ }
+
+ // Rebuild the in-memory log over the installed files: sealed
+ // segments with metadata from the validation walk, real storage over
+ // the final paths, and writers on the LAST segment only (the
+ // hydrate pattern; earlier segments are sealed and never written).
+ for meta in &staged {
+ let (log_final, index_final) = final_paths(partition_dir, meta.start_offset);
+ // Retried once: by this point every rename already landed, so a
+ // failed open converges away a chain that is COMPLETE AND DURABLE
+ // on disk and the re-pull transfers the whole thing again. The
+ // sweep itself is right (a chain the live state does not know
+ // about would resurrect at boot), so one retry against a
+ // transient open failure is the only cheap save available.
+ let open = || {
+ SegmentStorage::new(
+ &log_final,
+ &index_final,
+ meta.size,
+ meta.index_size,
+ config.enforce_fsync,
+ config.enforce_fsync,
+ true,
+ )
+ };
+ let storage = match open().await {
+ Ok(storage) => storage,
+ Err(_) => open()
+ .await
+ .map_err(|source| PartitionInstallError::SegmentOpen {
+ path: log_final.clone(),
+ source,
+ })?,
+ };
+ let mut segment = Segment::new(meta.start_offset, config.segment_size);
+ segment.sealed = true;
+ segment.start_timestamp = meta.start_timestamp;
+ segment.end_timestamp = meta.end_timestamp;
+ segment.max_timestamp = meta.max_timestamp;
+ segment.end_offset = meta.end_offset;
+ segment.size = IggyByteSize::from(meta.size);
+ segment.current_position = meta.size;
+ self.log.add_persisted_segment(segment, storage, None, None);
+ }
+ if staged.is_empty() {
+ // An empty offered set (everything GC'd behind the consumer
+ // barrier on the sender) installs a fresh segment at the
+ // artifact's frontier, exactly where rotation would have put
+ // it, so post-install traffic lands in a segment named for the
+ // offsets it holds.
+ self.install_empty_segment(config, offsets_wire.next_offset)
+ .await
+ .map_err(|source| PartitionInstallError::SegmentOpen {
+ path: partition_dir.to_owned(),
+ source,
+ })?;
+ // The per-log fsync below lives inside the staged loop, which is
+ // empty on this path, and `install_empty_segment` opens with
+ // `file_exists = false` (no writer-creation fsyncs), so without
+ // this the frontier-bearing dirent is page-cache only: a crash
+ // right after the install boots an empty directory and re-derives
+ // the counter at 0.
+ fsync_dir(partition_dir)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ })?;
+ } else {
+ let last = self.log.segments().len() - 1;
+ let storage = self.log.storages()[last].clone();
+ if let (Some(messages_reader), Some(index_reader), Some(messages_w), Some(index_w)) = (
+ storage.messages_reader.as_ref(),
+ storage.index_reader.as_ref(),
+ storage.messages_writer.as_ref(),
+ storage.index_writer.as_ref(),
+ ) {
+ let messages_writer = MessagesWriter::new(
+ &messages_reader.path(),
+ messages_w.size_counter(),
+ config.enforce_fsync,
+ true,
+ )
+ .await
+ .map_err(|source| PartitionInstallError::SegmentOpen {
+ path: messages_reader.path(),
+ source,
+ })?;
+ let index_writer = IggyIndexWriter::new(
+ &index_reader.path(),
+ index_w.size_counter(),
+ config.enforce_fsync,
+ true,
+ )
+ .await
+ .map_err(|source| PartitionInstallError::SegmentOpen {
+ path: index_reader.path(),
+ source,
+ })?;
+ self.log.messages_writers_mut()[last] = Some(Rc::new(messages_writer));
+ self.log.index_writers_mut()[last] = Some(Rc::new(index_writer));
+ }
+ self.log.segments_mut()[last].sealed = false;
+ }
+
+ // The installed segments supersede every journaled op; stale
+ // residents (below OR above the floor) would collide with the new
+ // view's prepares. Memory-only journal, so a full clear IS the
+ // suffix truncation.
+ self.log.journal().inner.clear_all();
+ // The wrapper's flush accounting too, or thresholds and tail-repair
+ // appends fold onto a pre-install base until the first real evict.
+ self.log.journal_mut().info = crate::log::JournalInfo::default();
+
+ // Consumer offsets: replace both maps through the SAME Arcs (the
+ // data plane holds clones), unlink the old files, install the
+ // transferred entries with locally minted paths, clamped like boot
+ // recovery clamps. The clamp anchors on the GROUP FRONTIER, not the
+ // staged end: an empty staged set (everything GC'd at the origin)
+ // must not rewind every transferred offset to 0 -- a durable,
+ // client-visible rewind the replicas would then disagree on.
+ let installed_end = staged.last().map(|meta| meta.end_offset);
+ let next_offset = offsets_wire
+ .next_offset
+ .max(installed_end.map_or(0, |end| end + 1));
+ let mut offsets_written = true;
+ // A key that fails the u32 narrowing would strand its old offset
+ // file's delete, which boot can then resurrect: unreachable while
+ // keys are minted from u32 wire ids, so assert it.
+ let old_consumer_paths: Vec<String> = {
+ let guard = self.consumer_offsets.pin();
+ let paths = guard
+ .iter()
+ .filter_map(|(key, _)| {
+ let narrowed = u32::try_from(*key).ok();
+ debug_assert!(narrowed.is_some(), "consumer offset key {key} exceeds u32");
+ narrowed.and_then(|id| self.persisted_offset_path(ConsumerKind::Consumer, id))
+ })
+ .collect();
+ guard.clear();
+ paths
+ };
+ let old_group_paths: Vec<String> = {
+ let guard = self.consumer_group_offsets.pin();
+ let paths = guard
+ .iter()
+ .filter_map(|(key, _)| {
+ let narrowed = u32::try_from(key.0).ok();
+ debug_assert!(
+ narrowed.is_some(),
+ "consumer group offset key {} exceeds u32",
+ key.0
+ );
+ narrowed
+ .and_then(|id| self.persisted_offset_path(ConsumerKind::ConsumerGroup, id))
+ })
+ .collect();
+ guard.clear();
+ paths
+ };
+ for path in old_consumer_paths.into_iter().chain(old_group_paths) {
+ if let Err(error) = delete_persisted_offset(&path).await {
+ // Not fatal, but not silent either: a stranded file is an id
+ // absent from the NEW table (matching ids get overwritten at
+ // the same path), and boot resurrects it. Sharpest after a
+ // purged origin ships `next_offset = 0`, where the clamp drops
+ // every incoming entry and the whole old table survives while
+ // the install still reports success.
+ tracing::warn!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ namespace_raw = self.consensus().namespace(),
+ path = %path,
+ %error,
+ "failed to unlink a superseded consumer-offset file during install"
+ );
+ }
+ }
+ self.persisted_offsets.borrow_mut().clear();
+ self.pending_consumer_offset_commits.clear();
+ self.last_polled_offsets.pin().clear();
+
+ // `None` when the group's offset space is empty (`next_offset == 0`,
+ // a purged origin): clamping every transferred offset to 0 would tell
+ // each consumer it consumed offset 0 on a partition that never minted
+ // one, so a `Next` poll skips the first message. Dropping the entries
+ // is what "no offsets yet" means.
+ let clamp = |offset: u64| next_offset.checked_sub(1).map(|last| offset.min(last));
+ if self.consumer_offsets_path.is_none() || self.consumer_group_offsets_path.is_none() {
+ // Nothing to write the transferred table into: unreachable via
+ // the server boot paths (they always configure storage), but if
+ // it ever fires the table was dropped and the flag must say so.
+ offsets_written = false;
+ }
+ // Both maps are populated first (no await, so nothing borrows across
+ // one), then the files are written in capped batches. One await per
+ // file put a rejoin carrying thousands of consumers on the pump for
+ // thousands of sequential open + write + optional fsync round trips;
+ // the tick's superblock pre-pass sets the precedent for the width.
+ let mut planned: Vec<PlannedOffsetWrite> =
+ Vec::with_capacity(offsets_wire.consumers.len() + offsets_wire.groups.len());
+ if let Some(dir) = self.consumer_offsets_path.clone() {
+ for (id, offset) in &offsets_wire.consumers {
+ let Some(value) = clamp(*offset) else {
+ continue;
+ };
+ let entry = ConsumerOffset::default_for_consumer(*id, &dir);
+ entry.offset.store(value, Ordering::Release);
+ let path = entry.path.clone();
+ self.consumer_offsets.pin().insert(*id as usize, entry);
+ planned.push(PlannedOffsetWrite {
+ kind: ConsumerKind::Consumer,
+ id: *id,
+ path,
+ value,
+ });
+ }
+ }
+ if let Some(dir) = self.consumer_group_offsets_path.clone() {
+ for (id, offset) in &offsets_wire.groups {
+ let Some(value) = clamp(*offset) else {
+ continue;
+ };
+ let group_id = ConsumerGroupId(*id as usize);
+ let entry = ConsumerOffset::default_for_consumer_group(group_id, &dir);
+ entry.offset.store(value, Ordering::Release);
+ let path = entry.path.clone();
+ self.consumer_group_offsets.pin().insert(group_id, entry);
+ planned.push(PlannedOffsetWrite {
+ kind: ConsumerKind::ConsumerGroup,
+ id: *id,
+ path,
+ value,
+ });
+ }
+ }
+ let enforce_fsync = self.consumer_offset_enforce_fsync;
+ for batch in planned.chunks(OFFSET_PERSIST_CONCURRENCY) {
+ let writes = batch.iter().map(|write| async move {
+ let written = persist_offset(&write.path, write.value, enforce_fsync)
+ .await
+ .is_ok();
+ (written, write.kind, write.id, write.value)
+ });
+ for (written, kind, id, value) in futures::future::join_all(writes).await {
+ if written {
+ self.persisted_offsets
+ .borrow_mut()
+ .insert((kind, id), value);
+ } else {
+ offsets_written = false;
+ }
+ }
+ }
+ // Directory fsync so the OLD files' unlinks stick: without it a
+ // crash right after install resurrects the pre-transfer offset
+ // files at boot. The per-file content durability stays governed by
+ // `consumer_offset_enforce_fsync` like every other offset commit.
+ for dir in self
+ .consumer_offsets_path
+ .clone()
+ .into_iter()
+ .chain(self.consumer_group_offsets_path.clone())
+ {
+ if fsync_dir(&dir).await.is_err() {
+ offsets_written = false;
+ }
+ }
+
+ // Counters and stats. The offset counter seeds from the ARTIFACT's
+ // frontier, not the installed segments: base offsets are minted
+ // locally per replica, so a counter behind the group (all sealed
+ // segments GC'd at the origin, empty active one skipped) would stamp
+ // the next replicated batch differently from the primary -- same op,
+ // different persisted bytes, different checksum. Segments can only
+ // trail the artifact (both were built at `commit_op`), so take the
+ // max defensively. The stats mutate through the EXISTING Arc:
+ // partition counters are never snapshotted, and the data plane's
+ // registered handle must keep reading the same cells.
+ let end = next_offset.saturating_sub(1);
+ self.offset.store(end, Ordering::Release);
+ self.dirty_offset.store(end, Ordering::Relaxed);
+ self.should_increment_offset = next_offset > 0;
+ self.recovered_durable_offset = installed_end;
+ // Where the group's offset space starts on this replica: everything
+ // below is represented by this install, so the repair floor check
+ // can connect windows that begin at (or below) it even with no
+ // durable bytes on disk. Deliberately NOT `recovered_durable_offset`:
+ // that field also gates repaired-batch persistence, and overstating
+ // it would silently drop the `(commit_op, commit_max]` replay window.
+ // `Some(0)` is filtered out: it reads as a real claim in the
+ // `NothingCommitted` serve gate (`installed_frontier.is_none()`), so a
+ // replica holding zero bytes at frontier 0 would start serving empty
+ // offers -- the phantom shape that gate exists to stop. Behavior is
+ // otherwise unchanged: the repair floor stand-in already treats
+ // `Some(0)` and `None` identically.
+ self.installed_frontier = (next_offset > 0).then_some(next_offset);
+ self.stats.zero_out_all();
+ #[allow(clippy::cast_possible_truncation)]
+ self.stats
+ .increment_segments_count(self.log.segments().len() as u32);
+ self.stats
+ .increment_size_bytes(staged.iter().map(|meta| meta.size).sum());
+ self.stats.increment_messages_count(
+ staged
+ .iter()
+ .map(|meta| meta.end_offset - meta.start_offset + 1)
+ .sum(),
+ );
+ self.stats.set_current_offset(end);
+
+ // A receiver that missed a purge must not be re-wiped by the
+ // reconciler right after installing post-purge data.
+ self.applied_purge_generation = self
+ .applied_purge_generation
+ .max(offsets_wire.purge_generation);
+ // Releasing the deferred-purge fence with it. Satisfying the generation
+ // here is what stops the reconciler re-issuing the purge that armed the
+ // fence, so leaving the flag set strands the replica quorum-invisible on
+ // this group for good. The fence's premise is discharged either way:
+ // it exists because the counter still named the pre-purge offset space,
+ // and this install just re-seeded that counter and recorded it durably
+ // before the swap.
+ self.purge_deferred = false;
+
+ let consensus = self.consensus();
+ if commit_op > consensus.commit_min() {
+ consensus.set_commit_floor(commit_op);
+ }
+ // The sequencer is SET, not raised: the install cleared the whole
+ // journal, so ops in `(commit_op, old_sequencer]` -- journaled and
+ // PrepareOk'd before the transfer armed, since a transferring replica
+ // withholds acks -- are ops consensus still claims and the journal can
+ // no longer serve. The primary's retransmit dies in the backup gap
+ // check, so a DVC from here would advertise an op this replica cannot
+ // walk -- tail repair targets the `(commit_op, commit_max]` gap, not
+ // this one. The pipeline is cleared in the same breath:
+ // its entries are backed by the same erased journal, and
+ // `LocalPipeline::push` asserts op sequentiality in release, so a bare
+ // rewind would turn the silent desync into a shard panic on a replica
+ // promoted mid-transfer. (`last_prepare_checksum` needs nothing: it is
+ // only read as a `parent:` stamp when building a prepare.)
+ consensus.sequencer().set_sequence(commit_op);
+ consensus.pipeline().borrow_mut().clear();
+ consensus.advance_commit_max(commit_op);
+ self.observed_view = self.consensus().view();
+ self.repair = None;
+ self.transfer_offer_cache.borrow_mut().take();
+
+ Ok(PartitionInstallOutcome {
+ applied_commit_op: commit_op,
+ offsets_written,
+ })
+ }
+
+ /// Converge the live partition AND its directory to an empty,
+ /// honestly-lagging shape after a failed install: no segment files at
+ /// all (the failure can land anywhere from "old chain unlinked" to
+ /// "new chain fully renamed in", and any survivor would either be
+ /// truncated in place by the empty plant or resurrect at boot in front
+ /// of / behind a later install), a fresh empty segment at the group's
+ /// offset frontier, an empty journal, and boot-equivalent counters.
+ /// Consensus state (commit floor, view) is left alone; the replica is
+ /// simply behind, and the normal triggers re-transfer.
+ ///
+ /// # Errors
+ /// [`iggy_common::IggyError`] when the sweep or the empty plant fails;
+ /// the partition then has no serviceable chain and the caller must
+ /// fence it (see [`PartitionInstallError::ConvergeFailed`]).
+ async fn converge_to_empty_after_failed_install(
+ &mut self,
+ config: &PartitionsConfig,
+ minted_next_offset: u64,
+ staged_was_empty: bool,
+ ) -> Result<(), iggy_common::IggyError> {
+ while let Some((_, mut storage)) = self.log.retire_front() {
+ let _ = storage.shutdown();
+ }
+ self.log.journal().inner.clear_all();
+ self.log.journal_mut().info = crate::log::JournalInfo::default();
+ // Every segment and staging file this partition had is about to be
+ // unlinked, so neither memo can describe anything real afterwards. The
+ // checksum map's own doc promises the clear happens here; without it the
+ // promise rested on the caller clearing it first.
+ self.segment_checksum_cache.borrow_mut().clear();
+ self.reuse_scan_memo.borrow_mut().take();
+
+ // Sweep EVERY segment file, not the in-memory count's worth: after
+ // a late failure the renamed-in new chain is on disk while the
+ // in-memory vectors were already drained, so only the directory
+ // itself knows what needs unlinking.
+ if let Some(partition_dir) = self.partition_dir.clone() {
+ let swept: Vec<PathBuf> = match segment_dir_entries(&partition_dir) {
+ Ok(entries) => entries
+ .into_iter()
+ .filter(|path| {
+ path.to_str().is_some_and(|path| {
+ [".log", ".index", STAGING_SUFFIX]
+ .iter()
+ .any(|extension| path.ends_with(extension))
+ })
+ })
+ .collect(),
+ Err(error) => {
+ tracing::error!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ namespace_raw = self.consensus().namespace(),
+ partition_dir,
+ %error,
+ "converge sweep cannot list the partition directory"
+ );
+ return Err(iggy_common::IggyError::CannotReadPartitions);
+ }
+ };
+ for path in swept {
+ match compio::fs::remove_file(&path).await {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => {
+ warn_unlink(
+ self.consensus().namespace(),
+ &path.display().to_string(),
+ &error,
+ );
+ return Err(iggy_common::IggyError::CannotDeleteFile);
+ }
+ }
+ }
+ fsync_dir(&partition_dir)
+ .await
+ .map_err(|_| iggy_common::IggyError::CannotSyncFile)?;
+ }
+
+ self.install_empty_segment(config, minted_next_offset)
+ .await?;
+ // Empty data, but NOT offset zero: the artifact already proved the
+ // group's frontier, and a counter reset would stamp the next
+ // replicated batch differently from the primary. Only the durable
+ // claim goes back to None; the partition is honestly lagging.
+ let end = minted_next_offset.saturating_sub(1);
+ self.offset.store(end, Ordering::Release);
+ self.dirty_offset.store(end, Ordering::Relaxed);
+ self.should_increment_offset = minted_next_offset > 0;
+ self.recovered_durable_offset = None;
+ // The frontier claims "everything below me is represented here", and the
+ // repair floor check accepts any floor at or below it. Nothing was
+ // installed, so the claim holds ONLY when the offer itself proved the
+ // origin retains nothing below its frontier -- an empty staged set. With
+ // segments staged and the install failed, this replica holds zero bytes
+ // of a range it would otherwise declare whole, and `set_commit_floor`
+ // would lift `commit_min` over ops it cannot serve.
+ self.installed_frontier =
+ (staged_was_empty && minted_next_offset > 0).then_some(minted_next_offset);
+ self.stats.zero_out_all();
+ self.stats.increment_segments_count(1);
+ // `zero_out_all` clears the reported offset too, and the counter above
+ // sits at `minted_next_offset - 1`: the success path keeps the two in
+ // step, so this one does as well.
+ self.stats.set_current_offset(end);
+ self.repair = None;
+ self.transfer_offer_cache.borrow_mut().take();
+ Ok(())
+ }
+}
+
+/// Failure validating or staging one transferred segment artifact.
+#[derive(Debug)]
+pub enum SpillError {
+ NoPartitionDir,
+ /// The received bytes are not what the manifest promised.
+ ManifestChecksum {
+ frontier: u64,
+ },
+ /// The payload failed its format validation walk.
+ Walk(SegmentWalkError),
+ /// Writing or syncing a staging file failed.
+ StagingIo {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+}
+
+impl fmt::Display for SpillError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NoPartitionDir => write!(f, "partition has no on-disk directory"),
+ Self::ManifestChecksum { frontier } => write!(
+ f,
+ "segment artifact at base offset {frontier} fails its manifest checksum"
+ ),
+ Self::Walk(source) => write!(f, "{source}"),
+ Self::StagingIo { path, source } => {
+ write!(f, "staging io failed at {}: {source}", path.display())
+ }
+ }
+ }
+}
+
+impl std::error::Error for SpillError {}
+
+/// Payload bytes between rebuilt sparse-index entries.
+///
+/// Targets the ORIGIN's density (one entry per flush chunk), not maximum
+/// sparseness: at this stride a maximum-size 1 GiB segment rebuilds ~16k
+/// entries (~384 KiB), inside `poll_plan::SEALED_INDEX_RESIDENT_MAX_BYTES`, so
+/// a transferred segment caches its index like any other instead of taking the
+/// per-poll binary-search fallback.
+const INDEX_STRIDE_BYTES: usize = 64 * 1024;
+
+/// Hand the core back to the reactor mid-CPU-pass.
+///
+/// Reactor only: the consensus tick shares this task as a sibling
+/// `select_biased!` arm, and arms are not polled while one arm's body awaits, so
+/// yielding here does not unfreeze ticks or heartbeats.
+///
+/// A zero-duration timer, NOT a bare self-waking yield: this runtime does not
+/// reliably re-poll a task that woke itself from inside its own poll, and a
+/// pump that suspends that way stops driving consensus entirely (the frame
+/// handler never resumes, ticks stop, the node goes quiet until something else
+/// wakes it). Registering with the reactor is what every other yield on these
+/// paths does -- the serving side yields through real file reads.
+async fn yield_to_reactor() {
+ compio::time::sleep(std::time::Duration::ZERO).await;
+}
+
+/// Chunk size for the offer build's streaming checksum pass. Large enough
+/// that per-chunk overhead is noise, small enough that the pump yields to
+/// the reactor many times per segment.
+const OFFER_HASH_CHUNK_LEN: usize = 1 << 20;
+
+/// Bytes one offer-build round may read and hash before it refuses and resumes
+/// on the next request.
+///
+/// The pass holds a frame body, and this shard's consensus ticks are a sibling
+/// select arm that stays unpolled for its duration, so the budget is really a
+/// bound on how long every OTHER group on this core goes without a heartbeat.
+///
+/// A BYTE budget standing in for a time bound, so the margin is storage-class
+/// specific: 256 MiB is roughly a quarter second on commodity `NVMe` against
+/// the shipped 5 s `heartbeat_timeout`, but about 2 s on a throttled cloud
+/// volume at 125 MB/s baseline, which is most of that window. Sized for the
+/// slower case still leaving room, and large enough that ordinary retention
+/// finishes in one round. An elapsed-time clamp would bound it properly on
+/// every storage class.
+const OFFER_HASH_BUDGET_PER_ROUND_BYTES: u64 = 256 * 1024 * 1024;
+
+/// Feed bytes `[from, to)` of `path` into `hasher`, read in
+/// [`OFFER_HASH_CHUNK_LEN`] chunks with one reactor yield per chunk, appending
+/// each chunk to `sink` when one is given.
+///
+/// The single chunked reader for both passes over a segment file: the offer
+/// build's checksum extension (no sink) and the serving side's load + re-verify
+/// (sink collects the artifact). Errors on a file shorter than `to`: the segment
+/// accounts bytes the disk does not hold.
+async fn hash_segment_range(
+ path: &str,
+ from: u64,
+ to: u64,
+ hasher: &mut StateArtifactHasher,
+ mut sink: Option<&mut Vec<u8>>,
+) -> std::io::Result<()> {
+ if from >= to {
+ return Ok(());
+ }
+ let file = compio::fs::File::open(path).await?;
+ let mut position = from;
+ // One buffer for the whole pass; `BufResult` hands it back per read
+ // precisely so the alloc + memset are not paid per chunk. Re-allocated
+ // (not resized) when the tail chunk is shorter: compio reads into a
+ // Vec's CAPACITY, and a shrunken length over the old 1 MiB capacity made
+ // `read_exact_at` demand a full megabyte at EOF.
+ #[allow(clippy::cast_possible_truncation)]
+ let mut buf = vec![0u8; OFFER_HASH_CHUNK_LEN.min((to - from) as usize)];
+ while position < to {
+ #[allow(clippy::cast_possible_truncation)]
+ let want = OFFER_HASH_CHUNK_LEN.min((to - position) as usize);
+ if buf.len() != want {
+ buf = vec![0u8; want];
+ }
+ let compio::BufResult(read, returned) = file.read_exact_at(buf, position).await;
+ buf = returned;
+ read.map_err(|source| {
+ std::io::Error::other(format!(
+ "reading segment bytes at {position} of {to} failed: {source}"
+ ))
+ })?;
+ hasher.update(&buf);
+ if let Some(sink) = sink.as_deref_mut() {
+ sink.extend_from_slice(&buf);
+ }
+ position += want as u64;
+ }
+ Ok(())
+}
+
+/// Read the first `entry.len` bytes of a served segment file and re-verify them
+/// against the manifest entry, chunked through [`hash_segment_range`] with one
+/// reactor yield per chunk.
+///
+/// The serving side runs this on the pump to answer a single chunk request, so
+/// it reads and hashes in chunks rather than in one pass. The yields keep the
+/// REACTOR moving (detached tasks, `io_uring` completions); they do not keep this
+/// shard's consensus ticks alive, which are a sibling select arm of the same
+/// task and stay frozen for the duration.
+///
+/// The file may legitimately be LONGER than the entry (an active segment that
+/// kept appending after the offer was built); the artifact is the prefix.
+///
+/// # Errors
+/// [`SegmentLoadError`], which the caller maps onto the refusal it sends: a
+/// collapsed `Option` here told a requester that a dying disk was a momentary
+/// blip forever, because the refusal it drives is classified by cause.
+pub async fn load_verified_segment_artifact(
+ log_path: &str,
+ entry: &consensus::StateArtifact,
+) -> Result<Vec<u8>, SegmentLoadError> {
+ let mut hasher = StateArtifactHasher::new();
+ #[allow(clippy::cast_possible_truncation)]
+ let mut bytes = Vec::with_capacity(entry.len as usize);
+ hash_segment_range(log_path, 0, entry.len, &mut hasher, Some(&mut bytes))
+ .await
+ .map_err(SegmentLoadError::classify)?;
+ if hasher.finish() != entry.checksum {
+ return Err(SegmentLoadError::ChecksumMismatch);
+ }
+ Ok(bytes)
+}
+
+/// Why a served segment could not be handed to a requester.
+///
+/// The split is the whole point: a short read is what a concurrent GC
+/// unlink-and-recreate legitimately produces and a checksum mismatch means the
+/// offer is simply stale, but `EIO` / `EACCES` / a failed open is a fault on
+/// THIS node, and telling the requester it was transient hides a dying disk
+/// behind an endless peer rotation.
+#[derive(Debug)]
+pub enum SegmentLoadError {
+ /// The file is gone, shorter than the entry, or otherwise out of step with
+ /// an offer built earlier. Retryable from the requester's side.
+ Stale(std::io::Error),
+ /// The bytes are present but no longer hash to the manifest entry.
+ ChecksumMismatch,
+ /// A local fault: unreadable device, permissions, an open that failed.
+ LocalFault(std::io::Error),
+}
+
+impl SegmentLoadError {
+ fn classify(source: std::io::Error) -> Self {
+ // `raw_os_error`, not just `kind()`: std maps EIO to
+ // `ErrorKind::Uncategorized`, so a dying disk is invisible to a
+ // kind-only match -- the exact case this split exists to catch.
+ // Everything unrecognised stays STALE: a short read past EOF is what a
+ // racing GC unlink-and-recreate legitimately produces.
+ const EIO: i32 = 5;
+ if source.raw_os_error() == Some(EIO) {
+ return Self::LocalFault(source);
+ }
+ match source.kind() {
+ std::io::ErrorKind::PermissionDenied => Self::LocalFault(source),
+ _ => Self::Stale(source),
+ }
+ }
+
+ /// Whether the requester should retry without charging a failure.
+ #[must_use]
+ pub const fn transient(&self) -> bool {
+ matches!(self, Self::Stale(_) | Self::ChecksumMismatch)
+ }
+}
+
+impl fmt::Display for SegmentLoadError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Stale(source) => {
+ write!(f, "served segment no longer matches the offer: {source}")
+ }
+ Self::ChecksumMismatch => {
+ write!(
+ f,
+ "served segment bytes no longer hash to the manifest entry"
+ )
+ }
+ Self::LocalFault(source) => {
+ write!(f, "served segment is unreadable on this node: {source}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for SegmentLoadError {}
+
+/// [`consensus::verify_state_artifact`] with reactor yields.
+///
+/// The receiver runs this on the pump for a whole artifact (up to a segment),
+/// and a non-yielding hash of that size makes the node quorum-invisible for its
+/// duration and starves the same-core segment cleaner.
+async fn verify_state_artifact_yielding(entry: &consensus::StateArtifact, bytes: &[u8]) -> bool {
+ if bytes.len() as u64 != entry.len {
+ return false;
+ }
+ let mut hasher = StateArtifactHasher::new();
+ for chunk in bytes.chunks(OFFER_HASH_CHUNK_LEN) {
+ hasher.update(chunk);
+ yield_to_reactor().await;
+ }
+ hasher.finish() == entry.checksum
+}
+
+/// The purge generation an encoded consumer-offsets artifact carries, or `0`
+/// when it cannot be decoded.
+///
+/// Lets the shard refuse an offer built BEFORE a committed purge without
+/// duplicating the wire codec: the install's own generation handling only ever
+/// widens permission, so a stale offer would resurrect purged data with the
+/// local applied generation left at the newer value, which the reconciler's
+/// re-wipe gate then reads as "already applied".
+#[must_use]
+pub fn offered_purge_generation(offsets_bytes: &[u8]) -> u64 {
+ ConsumerOffsetsWire::decode(offsets_bytes)
+ .map(|wire| wire.purge_generation)
+ .unwrap_or_default()
+}
+
+/// Stamp over every `SEGMENT_LOG` entry of a manifest, keying
+/// [`ReuseScanMemo`]. Equal digests mean the two offers expect byte-identical
+/// staged files, so a scan already done for one answers the other; the offsets
+/// artifact is excluded because the scan never looks at it (and it re-encodes
+/// per build, so including it would defeat the memo on every rotation).
+fn segment_manifest_digest(manifest: &[consensus::StateArtifact]) -> u64 {
+ let mut hasher = StateArtifactHasher::new();
+ for entry in manifest
+ .iter()
+ .filter(|entry| entry.kind == artifact_kind::SEGMENT_LOG)
+ {
+ hasher.update(&entry.frontier.to_le_bytes());
+ hasher.update(&entry.len.to_le_bytes());
+ hasher.update(&entry.checksum.to_le_bytes());
+ }
+ hasher.finish()
+}
+
+async fn write_staging_file(path: &Path, payload: Vec<u8>) -> std::io::Result<()> {
+ let mut file = compio::fs::File::create(path).await?;
+ let (result, _) = file.write_all_at(payload, 0).await.into();
+ result?;
+ file.sync_data().await?;
+ Ok(())
+}
+
+fn warn_unlink(namespace_raw: u64, path: &str, error: &std::io::Error) {
+ tracing::warn!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ namespace_raw,
+ path = %path,
+ %error,
+ "failed to unlink segment file during state-transfer install"
+ );
+}
diff --git a/core/partitions/src/types.rs b/core/partitions/src/types.rs
index 11c7796..f052ff7 100644
--- a/core/partitions/src/types.rs
+++ b/core/partitions/src/types.rs
@@ -236,6 +236,27 @@
pub idle_ticks: u32,
}
+/// How a repair-window commit walk concluded, decided by
+/// `IggyPartition::complete_repair`.
+///
+/// `#[must_use]` because `FloorRefused` is the partition plane's
+/// state-transfer trigger: repair proved the gap below the floor is neither
+/// locally durable nor repairable, so ignoring it wedges the replica
+/// gap-stopped forever.
+#[must_use]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RepairConclusion {
+ /// The walk fell short of `to_op`; the session stays armed and the stall
+ /// retry re-requests the remains.
+ InProgress,
+ /// The walk reached the requested frontier; the session was dropped.
+ Done,
+ /// The floor's continuity check failed: ops below it are neither locally
+ /// durable nor repaired. The session was dropped here -- state transfer
+ /// supersedes repair -- and the caller arms the transfer.
+ FloorRefused { floor: u64, to_op: u64 },
+}
+
/// Configuration for partition operations.
///
/// Mirrors the relevant fields from the server's `PartitionConfig` and
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index cbcd6e9..10a5299 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -955,12 +955,42 @@
# large batches can pin. Must be > 0 and <= "256 MiB".
evicted_ring_bytes_max = "16 MiB"
+# Byte budget for segment payloads a SERVING shard keeps resident to answer
+# state-transfer chunk requests. PER SHARD, and shard count defaults to core
+# count, so the process-wide high-water is this times the core count on top of
+# page cache -- keep that product in mind before raising it. The default is a
+# FIXED 2176 MiB: two sealed segments at the SHIPPED system.segment.size of
+# 1 GiB, each of which can close one whole message_bus.max_message_size past
+# its target, which is why it is not 2 GiB. It does not track your segment
+# size. How many groups this shard serves at once IS derived from yours:
+# floor(this / max(partition.transfer_artifact_bytes_max,
+# system.segment.size + 64 MiB)), minimum one. So raising either that knob or
+# system.segment.size without raising this lowers concurrency and can take it
+# to one, serialising rejoins, and nothing at boot warns about it.
+# Below one segment a single rejoining node thrashes the cache by itself and
+# every miss re-reads and re-hashes a whole segment to serve one 256 KiB chunk.
+# Running under the budget costs re-reads, not failures.
+# Must be > 0 and <= "64 GiB".
+transfer_served_cache_bytes_max = "2176 MiB"
+
+# Alloc ceiling for ONE received state-transfer artifact, per shard. The
+# receiver holds it resident through verify, walk and staging write, and up to
+# four transfers run at once. MUST cover system.segment.size plus
+# message_bus.max_message_size (a segment may close one whole batch past its
+# cap): under that, a legal segment is refused, the whole manifest with it, and
+# the partition livelocks re-requesting it from every peer. Boot validates the
+# floor. Raising this above the floor for headroom also DIVIDES the serving
+# concurrency derived from transfer_served_cache_bytes_max above, so raise that
+# in step. Must be > 0 and <= "64 GiB".
+transfer_artifact_bytes_max = "1088 MiB"
+
# Message bus configuration.
# Tunables for the inter-shard / inter-replica internal bus that ships
# consensus traffic between replicas and SDK-client traffic between
# shards. These knobs are consensus-liveness-critical (max_batch gates
# throughput under backpressure). Defaults match
# core::message_bus::config::MessageBusConfig::default().
+
[message_bus]
# Maximum number of BusMessage entries coalesced into a single writev(2)
# call. Hard upper bound: IOV_MAX/2 = 512 on Linux.
diff --git a/core/server-ng/src/auth.rs b/core/server-ng/src/auth.rs
index 65dc83a..74a3844 100644
--- a/core/server-ng/src/auth.rs
+++ b/core/server-ng/src/auth.rs
@@ -34,6 +34,7 @@
MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
};
use iggy_common::{IggyError, IggyTimestamp, PersonalAccessToken, UserStatus};
+use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use metadata::impls::metadata::StreamsFrontend;
use server_common::Message;
@@ -60,8 +61,8 @@
LazyLock::force(&DUMMY_PASSWORD_HASH);
}
-pub(crate) fn verify_login_credentials<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn verify_login_credentials<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
username: &str,
password: &str,
) -> Result<u32, LoginRegisterError>
@@ -70,6 +71,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
// Same bounds the legacy server enforces before any lookup or hashing;
// also keeps arbitrary-length input out of the password hash. Collapsed
@@ -106,8 +108,8 @@
})
}
-pub(crate) fn verify_pat_credentials<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn verify_pat_credentials<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
token: &str,
) -> Result<u32, LoginRegisterError>
where
@@ -115,6 +117,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
verify_pat_credentials_with_expiry(shard, token).map(|(user_id, _)| user_id)
}
@@ -123,8 +126,8 @@
/// seconds, `u64::MAX` when the PAT never expires). The HTTP extractor keys a
/// per-token VSR session table on this expiry for lazy eviction; the wire and
/// login paths only need the user id and go through [`verify_pat_credentials`].
-pub(crate) fn verify_pat_credentials_with_expiry<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn verify_pat_credentials_with_expiry<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
token: &str,
) -> Result<(u32, u64), LoginRegisterError>
where
@@ -132,6 +135,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let token_hash = PersonalAccessToken::hash_token(token);
// PAT expiry gates the login accept/reject, and that outcome folds into
@@ -174,8 +178,8 @@
}
#[allow(clippy::future_not_send)]
-pub(crate) async fn complete_login_register<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn complete_login_register<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
vsr_client_id: u128,
@@ -188,6 +192,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let sdk_info = ClientSdkInfo {
sdk_name: client_version.sdk_name.as_str().to_owned(),
@@ -283,8 +288,8 @@
/// SDK surfaces the real reason (every frame transport decodes
/// `Command2::Eviction`) instead of a decode error or a timeout.
#[allow(clippy::future_not_send)]
-pub(crate) async fn surface_login_failure<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn surface_login_failure<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RequestHeader,
error: &LoginRegisterError,
@@ -293,6 +298,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
if error.is_terminal() {
send_login_eviction(
@@ -317,8 +323,8 @@
/// same login on the same connection. Only call for transient errors -- see
/// [`surface_login_failure`].
#[allow(clippy::future_not_send)]
-async fn send_login_transient_reply<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn send_login_transient_reply<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RequestHeader,
) where
@@ -326,6 +332,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
// `TransientNotAccepted`: a login/register replay is safe under any
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index 9fddb38..57994d9 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -25,7 +25,8 @@
};
use crate::http;
use crate::partition_helpers::{
- configure_consumer_offsets, ensure_initial_segment, validate_namespace_bounds,
+ build_partition_fresh, configure_consumer_offsets, ensure_initial_segment,
+ open_partition_superblock, restore_partition_view, validate_namespace_bounds,
};
use crate::segment_recovery::{RecoveredSegment, load_persisted_segments};
use crate::server_error::{ServerNgError, ShardJoinFailure, ShardJoinFailureKind};
@@ -50,7 +51,7 @@
};
use iggy_common::{Aes256GcmEncryptor, EncryptorKind, IggyByteSize, PartitionStats, variadic};
use journal::prepare_journal::PrepareJournal;
-use journal::superblock::{DynSuperblockStore, PingPongSuperblock};
+use journal::superblock::{PingPongSuperblock, SuperblockStore};
use journal::{Journal, JournalHandle};
use message_bus::client_listener::{self, RequestHandler};
use message_bus::installer;
@@ -71,8 +72,9 @@
};
use metadata::IggyMetadata;
use metadata::MuxStateMachine;
+use metadata::ReplicaIdentity;
use metadata::impls::metadata::{IggySnapshot, StreamsFrontend};
-use metadata::impls::recovery::{ReplicaIdentity, recover};
+use metadata::impls::recovery::recover;
use metadata::stm::mux::WithFactory;
use metadata::stm::snapshot::Snapshot;
use metadata::stm::stream::{Partition, Streams};
@@ -130,14 +132,17 @@
/// The shard type the dispatch layer is generic over.
///
-/// `B`/`MJ`/`S` are free; the metadata state machine (`M`) and shards table
-/// (`T`) are pinned, being identical in production and the simulator.
-/// Production instantiates it as [`ServerNgShard`]; the simulator supplies its
-/// own `B`/`MJ`/`S`.
-pub type ShellShard<B, MJ, S> = IggyShard<B, MJ, S, ServerNgMuxStateMachine, PapayaShardsTable>;
+/// `B`/`MJ`/`S`/`SB` are free; the metadata state machine (`M`) and shards
+/// table (`T`) are pinned, being identical in production and the simulator.
+/// Production instantiates it as [`ServerNgShard`], defaulting `SB` to the
+/// on-disk [`PingPongSuperblock`]; the simulator supplies its own
+/// `B`/`MJ`/`S`/`SB`.
+pub type ShellShard<B, MJ, S, SB = PingPongSuperblock> =
+ IggyShard<B, MJ, S, ServerNgMuxStateMachine, PapayaShardsTable, SB>;
/// Late-bound self-reference the deferred dispatch handlers upgrade per frame.
-pub type ShellShardHandle<B, MJ, S> = Rc<RefCell<Option<Weak<ShellShard<B, MJ, S>>>>>;
+pub type ShellShardHandle<B, MJ, S, SB = PingPongSuperblock> =
+ Rc<RefCell<Option<Weak<ShellShard<B, MJ, S, SB>>>>>;
/// Bus bounds the dispatch/pump path needs (matches `run_message_pump`).
/// Blanket-impl'd, so it is only shorthand for the four underlying bounds.
@@ -185,9 +190,9 @@
/// They share one fresh [`SessionManager`]. The caller must set the weak
/// self-reference in `shard_handle` once the shard is built, so the
/// handlers can upgrade it per frame.
-pub fn wire_shell_handlers<B, MJ, S>(
+pub fn wire_shell_handlers<B, MJ, S, SB>(
bus: &B,
- shard_handle: &ShellShardHandle<B, MJ, S>,
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
system_config: Arc<NgSystemConfig>,
max_tokens_per_user: u32,
) -> ShellHandlers
@@ -196,6 +201,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let sessions = Rc::new(RefCell::new(SessionManager::new()));
ShellHandlers {
@@ -1025,7 +1031,7 @@
// ping-pong sequence counter. Consensus recovers its true (view, log_view)
// from `recovered_state` instead of inferring a stale view from the WAL.
let consensus = restore_metadata_consensus(&owner, &topology, config, Rc::clone(&bus));
- let superblock: Rc<dyn DynSuperblockStore> = Rc::new(owner.superblock);
+ let superblock = Rc::new(owner.superblock);
(
Some(consensus),
Some(owner.journal),
@@ -1723,17 +1729,114 @@
for (stream_id, topic_id, partition_stats, partition_metadata) in owned {
validate_namespace_bounds(config, stream_id, topic_id, partition_metadata.id)?;
let namespace = IggyNamespace::new(stream_id, topic_id, partition_metadata.id);
- let partition = load_partition(
+ let partition = match load_partition(
config,
namespace,
- partition_stats,
+ Arc::clone(&partition_stats),
&partition_metadata,
topology.cluster_id,
topology.self_replica_id,
topology.replica_count,
Rc::clone(&bus),
)
- .await?;
+ .await
+ {
+ Ok(partition) => partition,
+ // ONE damaged local chain must not take the node down. The shapes
+ // this refuses are exactly what a failed state-transfer quarantine
+ // leaves behind, so fence that group the same way the runtime path
+ // does -- move its segment files aside, keeping the superblock so it
+ // cannot re-enter view 0 -- and materialise it fresh. The ordinary
+ // rejoin path (repair, then state transfer on a refused floor)
+ // recovers its data from a peer.
+ Err(ServerNgError::PartitionChainRefused { dir, reason, .. }) => {
+ let partition_dir = dir.to_string_lossy().into_owned();
+ error!(
+ stream_id,
+ topic_id,
+ partition_id = partition_metadata.id,
+ partition_dir,
+ %reason,
+ "refusing the recovered segment chain; fencing this partition and \
+ rebuilding it empty for the rejoin path"
+ );
+ match partitions::state_transfer::quarantine_segment_files(&partition_dir).await {
+ Ok(fenced_dir) => error!(
+ stream_id,
+ topic_id,
+ partition_id = partition_metadata.id,
+ fenced_dir,
+ "quarantined the refused segment files; they are kept for inspection"
+ ),
+ Err(error) => {
+ // NOT rebuilt: `build_partition_fresh` reaches
+ // `ensure_initial_segment`, which opens segment 0 with
+ // `file_exists = false` and TRUNCATES whatever the
+ // failed quarantine left behind. The likeliest failures
+ // (suffix cap exhausted, `create_dir_all`) move zero
+ // files, so rebuilding would destroy the oldest segment
+ // on the first attempt while the higher-offset survivors
+ // keep refusing every boot -- a loop that never
+ // terminates and eats the chain one segment at a time.
+ // Tombstone instead: the namespace stays unmaterialised
+ // and unrouted, the reconciler backs off, and an
+ // operator still has every byte.
+ error!(
+ stream_id,
+ topic_id,
+ partition_id = partition_metadata.id,
+ partition_dir,
+ %error,
+ "failed to quarantine the refused segment files; leaving this \
+ partition tombstoned rather than rebuilding over them"
+ );
+ partition_stats.zero_out_all();
+ partitions.tombstone(namespace);
+ continue;
+ }
+ }
+ // The refused load already folded its segment counts in.
+ partition_stats.zero_out_all();
+ build_partition_fresh(
+ config,
+ namespace,
+ partition_stats,
+ topology.cluster_id,
+ topology.self_replica_id,
+ topology.replica_count,
+ Rc::clone(&bus),
+ )
+ .await?
+ }
+ // An untrustworthy superblock fences ONE group, not the node. The
+ // segment files stay exactly where they are -- unlike a refused
+ // chain, the data on disk is not the thing in doubt -- so there is
+ // nothing to quarantine and nothing to rebuild: rebuilding fresh
+ // would hand this replica a view-0 identity while a record it
+ // cannot read says otherwise. Tombstoned, the namespace stays
+ // unmaterialised and unrouted, the reconciler backs off, and an
+ // operator has every byte plus a message naming the directory.
+ Err(
+ error @ (ServerNgError::PartitionSuperblockIo { .. }
+ | ServerNgError::PartitionSuperblockVersionUnknown { .. }
+ | ServerNgError::PartitionSuperblockUnverifiable { .. }
+ | ServerNgError::PartitionSuperblockUndecodable { .. }
+ | ServerNgError::PartitionSuperblockIdentityMismatch { .. }),
+ ) => {
+ error!(
+ stream_id,
+ topic_id,
+ partition_id = partition_metadata.id,
+ %error,
+ "cannot trust this partition's durable consensus state; tombstoning the \
+ partition and continuing to boot the rest of the shard"
+ );
+ partition_stats.zero_out_all();
+ partitions.tombstone(namespace);
+ continue;
+ }
+ Err(error) => return Err(error),
+ };
partitions.insert(namespace, partition);
shards_table.insert(
namespace,
@@ -1795,6 +1898,15 @@
// Repair pacing is shared by both planes' repair loops, so it is a
// per-shard tunable set once here rather than per consensus group.
shard.set_repair_retry_ticks(repair_retry_ticks(config));
+ shard.set_served_segment_cache_bytes_max(
+ config
+ .partition
+ .transfer_served_cache_bytes_max
+ .as_bytes_u64(),
+ );
+ shard.set_partition_artifact_len_max(
+ config.partition.transfer_artifact_bytes_max.as_bytes_u64(),
+ );
shard.set_repair_chunk_max(config.cluster.repair_chunk_max as u64);
// Bounds a served state-transfer chunk. A frame above the bus ceiling is
// rejected by the RECEIVING transport, which tears the replica connection
@@ -1833,6 +1945,14 @@
const _: () = assert!(
configs::ng_partition::DEFAULT_EVICTED_RING_BYTES_MAX == partitions::EVICTED_RING_BYTES_MAX
);
+const _: () = assert!(
+ configs::ng_partition::DEFAULT_TRANSFER_ARTIFACT_BYTES_MAX
+ == shard::PARTITION_ARTIFACT_LEN_DEFAULT
+);
+const _: () = assert!(
+ configs::ng_partition::DEFAULT_TRANSFER_SERVED_CACHE_BYTES_MAX
+ == shard::SERVED_SEGMENT_CACHE_BYTES_DEFAULT
+);
const _: () =
assert!(configs::ng_cluster::DEFAULT_REPAIR_CHUNK_MAX as u64 == shard::REPAIR_CHUNK_MAX);
const _: () = assert!(
@@ -2153,7 +2273,7 @@
// Request queue holds 2x the prepare depth (buffered requests drain as
// prepares commit); depth is the per-partition `[partition]` knob.
let prepare_queue_depth = config.partition.prepare_queue_depth;
- let consensus = VsrConsensus::new(
+ let mut consensus = VsrConsensus::new(
cluster_id,
self_replica_id,
replica_count,
@@ -2168,10 +2288,33 @@
consensus.set_view_change_status_ticks(view_change_status_ticks(config));
consensus.set_request_start_view_ticks(request_start_view_ticks(config));
consensus.set_probe_attempts_max(config.cluster.view_probe_attempts_max);
- // A recovered partition lost its consensus state with the process: the
+
+ // (view, log_view) come from the group's durable superblock when present;
+ // a present but unverifiable record already refused boot inside
+ // `open_partition_superblock`. Restored BEFORE choosing how to join, so
+ // the backup probe below never advertises a view older than the recorded
+ // one.
+ let partition_dir = config
+ .system
+ .get_partition_path(stream_id, topic_id, partition_id);
+ let (superblock, recovered_state) = open_partition_superblock(
+ &partition_dir,
+ ReplicaIdentity {
+ cluster: cluster_id,
+ replica_id: self_replica_id,
+ replica_count,
+ },
+ )
+ .await?;
+ if let Some(state) = recovered_state.as_ref() {
+ restore_partition_view(&mut consensus, state);
+ }
+
+ // A recovered partition lost its journal state with the process: the
// partition journal is in-memory and segments carry no op numbers, so
- // this replica cannot know the group's (op, commit). In a cluster it
- // boots as a quorum-invisible backup and probes for the current view
+ // this replica cannot know the group's (op, commit) even when the
+ // superblock restored its view. In a cluster it boots as a
+ // quorum-invisible backup and probes for the current view
// (`RequestStartView`): the view's primary answers with a `StartView`,
// journal repair fills the rejoin window, and the commit floor settles
// at the serving peer's retention point. The probe re-broadcasts on its
@@ -2207,6 +2350,7 @@
})?;
let mut partition = IggyPartition::new(stats.clone(), consensus);
+ partition.set_superblock(superblock, recovered_state.as_ref());
// Recovered partitions honor the same config-surfaced ring ceilings as the
// fresh-create path (build_partition_fresh). Retention is already off for
// single-replica groups, so this only sizes the multi-replica ring.
@@ -2214,11 +2358,7 @@
config.partition.evicted_ring_capacity,
config.partition.evicted_ring_bytes_max.as_bytes_u64(),
);
- partition.set_partition_dir(config.system.get_partition_path(
- stream_id,
- topic_id,
- partition_id,
- ));
+ partition.set_partition_dir(partition_dir);
hydrate_partition_log(
&mut partition,
config,
@@ -2229,33 +2369,52 @@
)
.await?;
- let current_offset = partition
+ let sized_end = partition
.log
.segments()
.iter()
.filter(|segment| segment.size > IggyByteSize::default())
.map(|segment| segment.end_offset)
+ .max();
+ // An empty chain whose segment is named for a nonzero offset is the
+ // shape a state-transfer install (or its converge) plants at the group
+ // frontier after the origin GC'd everything: the file name carries the
+ // frontier, and re-minting offsets from 0 here would fork this
+ // replica's batch stamps from the rest of the group after a restart.
+ let empty_frontier = partition
+ .log
+ .segments()
+ .iter()
+ .map(|segment| segment.start_offset)
.max()
- .unwrap_or(0);
+ .filter(|&start| sized_end.is_none() && start > 0);
+ let current_offset = sized_end.or_else(|| empty_frontier.map(|start| start - 1));
partition.created_at = partition_metadata.created_at;
- if partition
- .log
- .segments()
- .iter()
- .any(|segment| segment.size > IggyByteSize::default())
- {
- partition.recovered_durable_offset = Some(current_offset);
- }
- partition.offset.store(current_offset, Ordering::Release);
- partition
- .dirty_offset
- .store(current_offset, Ordering::Relaxed);
- partition.should_increment_offset = partition
- .log
- .segments()
- .iter()
- .any(|segment| segment.size > IggyByteSize::default());
- partition.stats.set_current_offset(current_offset);
+ partition.recovered_durable_offset = sized_end;
+ // The OFFSET COUNTER is restored from that file name (above), but the
+ // `installed_frontier` CLAIM deliberately is not: the claim says "everything
+ // below me is represented here", and `converge_to_empty_after_failed_install`
+ // refuses to make it when staged segments were dropped -- yet a converge
+ // plants exactly the same empty `{frontier:020}.log` a legitimate empty
+ // install does, so boot provably cannot tell them apart. Re-deriving it here
+ // would hand the refused claim back: the repair floor stand-in would accept a
+ // commit floor over ops this replica holds zero bytes for, and the replica
+ // would pass the serve gate and offer that emptiness onward, making a peer
+ // unlink its own chain. Leaving it `None` costs one spurious full
+ // re-transfer on the legitimate empty-install restart; a false caught-up
+ // claim is not recoverable. A durable home for the frontier (the partition
+ // superblock already reserves a field) is what would settle it properly.
+ let counter = current_offset.unwrap_or(0);
+ partition.offset.store(counter, Ordering::Release);
+ partition.dirty_offset.store(counter, Ordering::Relaxed);
+ partition.should_increment_offset = current_offset.is_some();
+ partition.stats.set_current_offset(counter);
+ // The durable frontier is a LOWER BOUND on top of what the segments proved:
+ // it is the only carrier left when the segments that named the frontier are
+ // gone (an all-GC'd origin's install, a crash inside the swap window), and
+ // taking the max means real recovered data always wins.
+ partition.restore_offset_frontier(recovered_state.as_ref());
+ let current_offset = partition.offset.load(Ordering::Acquire);
configure_consumer_offsets(&mut partition, config, namespace, current_offset)?;
ensure_initial_segment(&mut partition, config, stream_id, topic_id, partition_id).await?;
diff --git a/core/server-ng/src/consumer_group.rs b/core/server-ng/src/consumer_group.rs
index 23244d2..0347136 100644
--- a/core/server-ng/src/consumer_group.rs
+++ b/core/server-ng/src/consumer_group.rs
@@ -42,6 +42,7 @@
};
use iggy_binary_protocol::{KIND_CONSUMER_GROUP, Operation, RequestHeader, WireIdentifier};
use iggy_common::IggyError;
+use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use metadata::impls::metadata::StreamsFrontend;
use metadata::stm::consumer_group::{
@@ -60,8 +61,8 @@
/// (via the partition-read mesh), so the cooperative rebalance pending-revokes
/// only those and hands off never-polled/drained partitions synchronously at
/// join. Every other operation passes through.
-pub(crate) async fn maybe_rewrite_consumer_group_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn maybe_rewrite_consumer_group_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RequestHeader>,
) -> Result<Message<RequestHeader>, IggyError>
where
@@ -69,6 +70,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let operation = request.header().operation;
let client_id = request.header().client;
@@ -112,8 +114,8 @@
/// no `PendingRevocation` record, so the reconciler never revisits it -- a
/// misclassification here just redelivers the uncommitted range to the new
/// owner, which is correct under at-least-once.
-async fn gather_in_flight<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn gather_in_flight<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
group_id: &WireIdentifier,
@@ -123,6 +125,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let streams = shard.plane.metadata().mux_stm.streams();
let Some(monotonic_group_id) = streams.resolve_consumer_group_id(stream_id, topic_id, group_id)
@@ -203,8 +206,8 @@
/// purge agree, and a re-created group (new id) never inherits a stale offset.
/// Individual-consumer ops and every other operation pass through untouched.
#[allow(clippy::cast_possible_truncation)]
-pub(crate) fn maybe_rewrite_consumer_offset_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn maybe_rewrite_consumer_offset_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RequestHeader>,
) -> Result<Message<RequestHeader>, IggyError>
where
@@ -212,6 +215,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let operation = request.header().operation;
if !matches!(
@@ -260,8 +264,8 @@
/// Resolve the monotonic group id for a group consumer-offset op, or `None` for
/// an individual consumer (kind != 2) / unresolved group (leave the body as-is;
/// the apply / read path handle the miss).
-fn resolve_group_offset_id<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn resolve_group_offset_id<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
consumer: &WireConsumer,
namespace: (&WireIdentifier, &WireIdentifier),
) -> Option<u64>
@@ -270,6 +274,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
if consumer.kind != KIND_CONSUMER_GROUP {
return None;
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 72c238f..cbae3f2 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -84,6 +84,7 @@
WireIdentifier, is_protocol_compatible,
};
use iggy_common::{IggyError, PollingStrategy, SnapshotCompression, SystemSnapshotType};
+use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use message_bus::AUTO_COMMIT_CLIENT_ID;
use message_bus::client_listener::RequestHandler;
@@ -113,8 +114,8 @@
pub(crate) type ClientRequestQueues = Rc<RefCell<HashMap<u128, VecDeque<Message<GenericHeader>>>>>;
pub(crate) type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
-pub(crate) fn make_client_request_handler<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn make_client_request_handler<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
system_config: Arc<NgSystemConfig>,
max_tokens_per_user: u32,
@@ -124,6 +125,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let shard = Rc::clone(shard);
let sessions = Rc::clone(sessions);
@@ -177,14 +179,15 @@
/// against the local partitions plane and push the result back over the
/// carried reply sender. The requesting shard bounds the wait with a
/// timeout, so a dropped reply degrades to a client-visible read failure.
-pub(crate) fn make_partition_read_handler<B, MJ, S>(
- shard_handle: &ShellShardHandle<B, MJ, S>,
+pub(crate) fn make_partition_read_handler<B, MJ, S, SB>(
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
) -> PartitionReadHandler
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let shard_handle = Rc::clone(shard_handle);
// Runs synchronously on the shard pump (see `process_lifecycle` ->
@@ -266,8 +269,8 @@
/// map), then replicate the auto-committed offset and send the reply. Holds no
/// partition reference across the IO, so it is sound concurrently with the
/// pump's `&mut` writes; the auto-commit submit re-borrows synchronously after.
-fn spawn_poll_io<B, MJ, S>(
- shard: Rc<ShellShard<B, MJ, S>>,
+fn spawn_poll_io<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
namespace: IggyNamespace,
plan: PollPlan,
reply: shard::Sender<PartitionReadReply>,
@@ -276,6 +279,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let bus = shard.bus.clone();
bus.spawn(async move {
@@ -322,8 +326,8 @@
/// data, hence no log). The gate reads committed state only, so an offset that
/// merely sits in flight keeps resubmitting until its covering op commits -- a
/// dropped op self-heals on the next poll instead of being suppressed forever.
-fn submit_auto_commit<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn submit_auto_commit<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
namespace: IggyNamespace,
applied: &AutoCommitApplied,
) where
@@ -331,6 +335,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
enum AutoCommitGate {
Submit,
@@ -425,14 +430,15 @@
}))
}
-pub(crate) fn make_deferred_replica_message_handler<B, MJ, S>(
- shard_handle: &ShellShardHandle<B, MJ, S>,
+pub(crate) fn make_deferred_replica_message_handler<B, MJ, S, SB>(
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
) -> MessageHandler
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let shard_handle = Rc::clone(shard_handle);
Rc::new(move |_replica_id, message| {
@@ -442,9 +448,9 @@
})
}
-pub(crate) fn make_deferred_client_request_handler<B, MJ, S>(
+pub(crate) fn make_deferred_client_request_handler<B, MJ, S, SB>(
bus: &B,
- shard_handle: &ShellShardHandle<B, MJ, S>,
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
sessions: &Rc<RefCell<SessionManager>>,
system_config: Arc<NgSystemConfig>,
max_tokens_per_user: u32,
@@ -454,6 +460,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let shard_handle = Rc::clone(shard_handle);
let sessions = Rc::clone(sessions);
@@ -510,14 +517,15 @@
/// proposal. Spawns a task so the awaiting peer is woken once the op
/// commits; replies `None` on transient submit failure so the peer never
/// blocks forever.
-pub(crate) fn make_metadata_submit_handler<B, MJ, S>(
- shard_handle: &ShellShardHandle<B, MJ, S>,
+pub(crate) fn make_metadata_submit_handler<B, MJ, S, SB>(
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
) -> shard::MetadataSubmitHandler
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let shard_handle = Rc::clone(shard_handle);
Rc::new(move |submit| {
@@ -634,8 +642,8 @@
// empty-reply fail-fast below and must log in.
#[allow(clippy::too_many_arguments)]
-fn enqueue_client_request<B, MJ, S>(
- shard: Rc<ShellShard<B, MJ, S>>,
+fn enqueue_client_request<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
sessions: Rc<RefCell<SessionManager>>,
system_config: Arc<NgSystemConfig>,
max_tokens_per_user: u32,
@@ -648,6 +656,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
queues
.borrow_mut()
@@ -674,8 +683,8 @@
}
#[allow(clippy::future_not_send)]
-async fn drain_client_requests<B, MJ, S>(
- shard: Rc<ShellShard<B, MJ, S>>,
+async fn drain_client_requests<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
sessions: Rc<RefCell<SessionManager>>,
system_config: Arc<NgSystemConfig>,
max_tokens_per_user: u32,
@@ -687,6 +696,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
loop {
let Some(message) = pop_next_client_request(&queues, &active, client_id) else {
@@ -725,8 +735,8 @@
}
#[allow(clippy::future_not_send, clippy::too_many_lines)]
-async fn handle_client_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_client_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
system_config: &Arc<NgSystemConfig>,
max_tokens_per_user: u32,
@@ -737,6 +747,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let request = match message.try_into_typed::<RequestHeader>() {
Ok(request) => request,
@@ -1033,8 +1044,8 @@
/// out of the Users STM. Built here rather than in `build_non_replicated_response`
/// which has no session context.
#[allow(clippy::future_not_send)]
-async fn handle_get_personal_access_tokens<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_get_personal_access_tokens<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: &Message<RequestHeader>,
@@ -1043,6 +1054,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let response = build_get_personal_access_tokens_response(shard, sessions, transport_client_id);
send_non_replicated_bytes(
@@ -1059,8 +1071,8 @@
/// `SessionManager` (not `IggyMetadata`), so built here rather than in
/// `build_non_replicated_response`.
#[allow(clippy::future_not_send)]
-async fn handle_get_me<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_get_me<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: &Message<RequestHeader>,
@@ -1069,6 +1081,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let response = build_get_me_response(shard, sessions, transport_client_id);
send_non_replicated_bytes(
@@ -1099,8 +1112,8 @@
/// `vsr_client_id` keys the consumer-group offset fence (the member id),
/// not the transport id stamped into the partition-op header.
#[allow(clippy::future_not_send)]
-pub(crate) async fn dispatch_partition_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn dispatch_partition_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RequestHeader>,
vsr_client_id: u128,
bound_session: u64,
@@ -1111,6 +1124,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let header = *request.header();
let namespace = match resolve_partition_request_namespace(
@@ -1237,8 +1251,8 @@
}
#[allow(clippy::future_not_send, clippy::too_many_lines)]
-async fn handle_non_replicated_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_non_replicated_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
system_config: &Arc<NgSystemConfig>,
transport_client_id: u128,
@@ -1248,6 +1262,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
const CODE_RANGE: std::ops::Range<usize> = 0..4;
let code = u32::from_le_bytes(request.header().reserved[CODE_RANGE].try_into().unwrap());
@@ -1392,8 +1407,8 @@
}
#[allow(clippy::future_not_send, clippy::too_many_arguments)]
-async fn handle_default_non_replicated<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_default_non_replicated<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
code: u32,
request: &Message<RequestHeader>,
@@ -1405,6 +1420,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
// Gate by command code before the shared builder runs. The builder stays
// authz-free (it is byte-shared with the HTTP read path, which gates
@@ -1463,8 +1479,8 @@
/// plain authentication must not suffice), then await the off-thread
/// collection (see `snapshot::collect`) and reply with the raw ZIP bytes.
#[allow(clippy::future_not_send)]
-async fn handle_get_snapshot<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_get_snapshot<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
system_config: &Arc<NgSystemConfig>,
transport_client_id: u128,
request: &Message<RequestHeader>,
@@ -1474,6 +1490,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_snapshot) {
send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
@@ -1544,8 +1561,8 @@
/// metadata commit. Shared by the `get_me` / `get_clients` / `get_client`
/// arms.
#[allow(clippy::future_not_send)]
-async fn send_non_replicated_bytes<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn send_non_replicated_bytes<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: &Message<RequestHeader>,
transport_client_id: u128,
bytes: Bytes,
@@ -1555,6 +1572,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = NonReplicatedResponse::Bytes(bytes).into_reply(
@@ -1580,14 +1598,15 @@
/// eviction context is best-effort off the metadata consensus (peer shards
/// have none; zeroes are cosmetic -- the SDK only reads the reason).
#[allow(clippy::future_not_send)]
-async fn send_unauthenticated_eviction<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn send_unauthenticated_eviction<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let ctx = shard.plane.metadata().consensus.as_ref().map_or(
consensus::EvictionContext {
@@ -1621,8 +1640,8 @@
/// groups + rebalances via the replicated `Logout`) and sends a session-
/// terminal `Eviction(StaleClient)` so the client fails fast and can reconnect.
#[allow(clippy::future_not_send)]
-pub(crate) async fn run_heartbeat_verifier<B, MJ, S>(
- shard: Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn run_heartbeat_verifier<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
sessions: Rc<RefCell<SessionManager>>,
interval: std::time::Duration,
stop_rx: shard::Receiver<()>,
@@ -1631,6 +1650,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
// Legacy `MAX_THRESHOLD`: a client is stale once it misses ~1.2 intervals.
let max_age = interval.mul_f64(1.2);
@@ -1680,8 +1700,8 @@
/// membership through a replicated `Logout`) and notify the client with a
/// session-terminal `Eviction(StaleClient)`.
#[allow(clippy::future_not_send)]
-async fn evict_stale_client<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn evict_stale_client<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
) where
@@ -1689,6 +1709,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let bound = sessions.borrow_mut().remove_connection(transport_client_id);
if let Some((vsr_client_id, session)) = bound {
@@ -1732,8 +1753,8 @@
/// Failures reply with an empty body so the SDK fails fast on decode
/// instead of hanging until its read timeout.
#[allow(clippy::future_not_send)]
-async fn handle_poll_messages<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_poll_messages<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request: &Message<RequestHeader>,
user_id: Option<u32>,
@@ -1742,6 +1763,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let Ok(wire) = PollMessagesRequest::decode_from(request_body(request)) else {
// Undecodable poll: keep the fail-fast empty-poll shape.
@@ -1830,8 +1852,8 @@
/// Serve `get_consumer_offset`. An empty body decodes as `None` on the SDK
/// side (no offset stored / partition unknown).
#[allow(clippy::future_not_send)]
-async fn handle_get_consumer_offset<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_get_consumer_offset<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request: &Message<RequestHeader>,
user_id: Option<u32>,
@@ -1840,6 +1862,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let Ok(wire) = GetConsumerOffsetRequest::decode_from(request_body(request)) else {
// Undecodable: an empty body decodes as None (no offset) on the SDK.
@@ -1902,8 +1925,8 @@
/// The member is keyed by the connection's bound VSR client id
/// (`header().client`). An empty body decodes as "no assignment" on the SDK.
#[allow(clippy::future_not_send)]
-async fn handle_sync_consumer_group<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_sync_consumer_group<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request: &Message<RequestHeader>,
) where
@@ -1911,6 +1934,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let body = match SyncConsumerGroupRequest::decode_from(request_body(request)) {
Ok(wire) => shard
@@ -1955,8 +1979,8 @@
/// in lockstep, so a silent drop wedges every subsequent request on that
/// connection.
#[allow(clippy::future_not_send)]
-async fn send_empty_partition_reply<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn send_empty_partition_reply<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RequestHeader,
) where
@@ -1964,6 +1988,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = build_empty_reply(request_header, transport_client_id, 0, commit);
@@ -2002,8 +2027,8 @@
/// cover - a row seeded from the hash by a shard that owns nothing. Readiness
/// belongs to the owner, which is where it is now enforced.
#[allow(clippy::future_not_send)]
-async fn wait_for_partition_routable<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn wait_for_partition_routable<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
namespace: IggyNamespace,
) -> bool
where
@@ -2011,6 +2036,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
const ATTEMPT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
// 3s budget at 50ms per attempt. Counting attempts, not reading a
@@ -2048,8 +2074,8 @@
/// id = the connection's bound VSR client) and the HTTP route (client id 0,
/// which fences group polls closed).
#[allow(clippy::cast_possible_truncation)]
-pub(crate) fn resolve_poll_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn resolve_poll_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
wire: &PollMessagesRequest,
client_id: u128,
) -> Result<DecodedPollRequest, IggyError>
@@ -2058,6 +2084,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let strategy = polling_strategy_from_wire(&wire.strategy)?;
let args = PollingArgs::new(strategy, wire.count, wire.auto_commit);
@@ -2114,8 +2141,8 @@
/// namespace, partition, and polling consumer. Shared by the TCP dispatch and
/// the HTTP route; needs no client id because offset reads are not fenced
/// (any client may read a group's offset, member or not).
-pub(crate) fn resolve_consumer_offset_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn resolve_consumer_offset_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
wire: &GetConsumerOffsetRequest,
) -> Result<(IggyNamespace, u32, PollingConsumer), IggyError>
where
@@ -2123,6 +2150,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
// Omitted partition reads partition 0, matching the legacy resolver for
// both consumer kinds (`unwrap_or(0)`).
@@ -2199,8 +2227,8 @@
/// committed op. A dropped reply (shard-0 inbox full / shutdown) maps to a
/// transient `Canceled`, which the caller wraps so the SDK replays.
#[allow(clippy::future_not_send)]
-pub(crate) async fn submit_register_on_owner<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn submit_register_on_owner<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
vsr_client_id: u128,
user_id: u32,
) -> Result<BoundSession, MetadataSubmitError>
@@ -2209,6 +2237,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
if shard.id == 0 {
return shard
@@ -2232,8 +2261,8 @@
/// Logout counterpart of [`submit_register_on_owner`].
#[allow(clippy::future_not_send)]
-pub(crate) async fn submit_logout_on_owner<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn submit_logout_on_owner<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
vsr_client_id: u128,
session: u64,
request: u64,
@@ -2243,6 +2272,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
if shard.id == 0 {
return shard
@@ -2275,8 +2305,8 @@
/// of mistaking a dropped delete for success. Only a malformed / unresolvable
/// request is acked empty without a commit.
#[allow(clippy::future_not_send)]
-async fn handle_delete_segments_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_delete_segments_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
bound: Option<(u128, u64)>,
request: &Message<RequestHeader>,
@@ -2285,6 +2315,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let header = *request.header();
let body = request_body(request);
@@ -2406,8 +2437,8 @@
/// the error.
#[allow(clippy::future_not_send)]
#[allow(clippy::cast_possible_truncation)]
-pub(crate) async fn resolve_delete_segments_truncate<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn resolve_delete_segments_truncate<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
template: &RequestHeader,
client_id: u128,
session: u64,
@@ -2418,6 +2449,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let parsed = DeleteSegmentsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
let namespace_raw = match resolve_partition_request_namespace(
@@ -2528,8 +2560,8 @@
/// shard 0 and forwards for peer-homed connections; its session guard drops a
/// stale logout for a reused client id.
#[allow(clippy::future_not_send)]
-fn submit_disconnect_logout<B, MJ, S>(
- shard: Rc<ShellShard<B, MJ, S>>,
+fn submit_disconnect_logout<B, MJ, S, SB>(
+ shard: Rc<ShellShard<B, MJ, S, SB>>,
vsr_client_id: u128,
session: u64,
) where
@@ -2537,6 +2569,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
// Synthetic request id: header validation rejects `request == 0` for
// non-register ops, and a disconnect has no client-issued request id.
@@ -2569,8 +2602,8 @@
/// `client` id (it's the VSR id, not the transport/home-shard-encoding id).
/// `None` = transient submit failure (SDK read-timeout replays).
#[allow(clippy::future_not_send)]
-pub(crate) async fn submit_client_request_on_owner<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn submit_client_request_on_owner<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RequestHeader>,
) -> Option<Message<GenericHeader>>
where
@@ -2578,6 +2611,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
if shard.id == 0 {
return shard
@@ -2596,8 +2630,8 @@
}
#[allow(clippy::future_not_send)]
-async fn handle_logout_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_logout_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: Message<RequestHeader>,
@@ -2606,6 +2640,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let Some((vsr_client_id, session)) = sessions.borrow().get_session(transport_client_id) else {
warn!(
@@ -2640,8 +2675,8 @@
}
}
-fn ensure_transport_connection<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn ensure_transport_connection<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
) where
@@ -2649,6 +2684,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let Some(meta) = shard.bus.client_meta(transport_client_id) else {
return;
@@ -2659,8 +2695,8 @@
}
#[allow(clippy::future_not_send, clippy::too_many_lines)]
-async fn handle_login_register_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_login_register_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
request: Message<RequestHeader>,
@@ -2669,6 +2705,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let body = request_body(&request);
let vsr_client_id = request.header().client;
@@ -2810,8 +2847,8 @@
/// metadata shard and zeroed elsewhere -- the SDK only reads the reason,
/// plus the protocol window on `IncompatibleProtocol`.
#[allow(clippy::future_not_send)]
-pub(crate) async fn send_login_eviction<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn send_login_eviction<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
vsr_client_id: u128,
reason: EvictionReason,
@@ -2820,6 +2857,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let ctx = shard.plane.metadata().consensus.as_ref().map_or(
EvictionContext {
@@ -2849,14 +2887,15 @@
}
}
-pub(crate) fn upgrade_shard_handle<B, MJ, S>(
- shard_handle: &ShellShardHandle<B, MJ, S>,
-) -> Option<Rc<ShellShard<B, MJ, S>>>
+pub(crate) fn upgrade_shard_handle<B, MJ, S, SB>(
+ shard_handle: &ShellShardHandle<B, MJ, S, SB>,
+) -> Option<Rc<ShellShard<B, MJ, S, SB>>>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
shard_handle
.borrow()
diff --git a/core/server-ng/src/dispatch/authz.rs b/core/server-ng/src/dispatch/authz.rs
index f6f30f6..08ed5ca 100644
--- a/core/server-ng/src/dispatch/authz.rs
+++ b/core/server-ng/src/dispatch/authz.rs
@@ -40,6 +40,7 @@
use iggy_binary_protocol::requests::topics::{GetTopicRequest, GetTopicsRequest};
use iggy_binary_protocol::{Operation, PrepareHeader, RequestHeader, WireDecode, WireIdentifier};
use iggy_common::IggyError;
+use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use metadata::impls::metadata::StreamsFrontend;
use metadata::permissioner::Permissioner;
@@ -56,8 +57,8 @@
/// namespace already resolved, so the entity exists; a `None` user id (which
/// the bound-session gate should preclude) fails closed with `Unauthenticated`
/// rather than allow an unattributed write.
-pub(super) fn authorize_partition_op<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) fn authorize_partition_op<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
operation: Operation,
user_id: Option<u32>,
stream_id: usize,
@@ -68,6 +69,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let Some(user_id) = user_id else {
return Some(IggyError::Unauthenticated.as_code());
@@ -134,8 +136,8 @@
/// better, the connection decodes replies in lockstep and would wedge on every
/// later request.
#[allow(clippy::future_not_send)]
-pub(super) async fn send_partition_deny_reply<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) async fn send_partition_deny_reply<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RequestHeader,
status: u32,
@@ -144,6 +146,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = build_deny_reply(request_header, transport_client_id, 0, commit, status);
@@ -164,8 +167,8 @@
/// Run an unscoped non-replicated-read rule for the acting user. A `None` user
/// id (only the pre-auth path, which serves ungated codes) fails closed.
-pub(super) fn authorize_uid<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) fn authorize_uid<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
user_id: Option<u32>,
rule: impl FnOnce(&Permissioner, u32) -> Result<(), IggyError>,
) -> Result<(), IggyError>
@@ -174,6 +177,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let user_id = user_id.ok_or(IggyError::Unauthenticated)?;
shard
@@ -188,8 +192,8 @@
/// (stream, topic). `None` proceeds (allowed, or a resolution miss the caller's
/// own not-found path handles); `Some(status)` denies. A `None` user id fails
/// closed.
-pub(super) fn authorize_partition_read<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) fn authorize_partition_read<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
user_id: Option<u32>,
@@ -200,6 +204,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let Some(user_id) = user_id else {
return Some(IggyError::Unauthenticated.as_code());
@@ -225,8 +230,8 @@
/// pre-auth (bootstrap / leader discovery; the dispatch allowlist admits it
/// unauthenticated) and, like every other code the builder serves, is ungated
/// here.
-pub(super) fn authorize_default_read<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) fn authorize_default_read<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
code: u32,
body: &[u8],
user_id: Option<u32>,
@@ -236,6 +241,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
// A `u32` match cannot be exhaustive: every gated code is named explicitly,
// and the final arm is the ungated set the builder serves without a rule.
@@ -247,35 +253,35 @@
// permissioner rule to run (legacy runs none either).
GET_PERSONAL_ACCESS_TOKENS_CODE => user_id.map(|_| ()).ok_or(IggyError::Unauthenticated),
GET_STREAMS_CODE => authorize_uid(shard, user_id, Permissioner::get_streams),
- GET_STREAM_CODE => gate_stream_scoped::<GetStreamRequest, _, _, _>(
+ GET_STREAM_CODE => gate_stream_scoped::<GetStreamRequest, _, _, _, _>(
shard,
user_id,
body,
|request| &request.stream_id,
Permissioner::get_stream,
),
- GET_TOPICS_CODE => gate_stream_scoped::<GetTopicsRequest, _, _, _>(
+ GET_TOPICS_CODE => gate_stream_scoped::<GetTopicsRequest, _, _, _, _>(
shard,
user_id,
body,
|request| &request.stream_id,
Permissioner::get_topics,
),
- GET_TOPIC_CODE => gate_topic_scoped::<GetTopicRequest, _, _, _>(
+ GET_TOPIC_CODE => gate_topic_scoped::<GetTopicRequest, _, _, _, _>(
shard,
user_id,
body,
|request| (&request.stream_id, &request.topic_id),
Permissioner::get_topic,
),
- GET_CONSUMER_GROUP_CODE => gate_topic_scoped::<GetConsumerGroupRequest, _, _, _>(
+ GET_CONSUMER_GROUP_CODE => gate_topic_scoped::<GetConsumerGroupRequest, _, _, _, _>(
shard,
user_id,
body,
|request| (&request.stream_id, &request.topic_id),
Permissioner::get_consumer_group,
),
- GET_CONSUMER_GROUPS_CODE => gate_topic_scoped::<GetConsumerGroupsRequest, _, _, _>(
+ GET_CONSUMER_GROUPS_CODE => gate_topic_scoped::<GetConsumerGroupsRequest, _, _, _, _>(
shard,
user_id,
body,
@@ -291,8 +297,8 @@
/// surfaces the typed error, so a poll denial never reaches the empty-poll
/// "0 messages" body path.
#[allow(clippy::future_not_send)]
-pub(super) async fn send_non_replicated_deny<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) async fn send_non_replicated_deny<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: &Message<RequestHeader>,
transport_client_id: u128,
status: u32,
@@ -301,6 +307,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = build_deny_reply(
@@ -328,8 +335,8 @@
/// resolve it to the committed slab id, then run `rule`. A malformed body or a
/// resolution miss returns `Ok(())` so the builder's own error / not-found
/// reply is what the client sees (decode-and-notfound-before-permission).
-fn gate_stream_scoped<T: WireDecode, B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn gate_stream_scoped<T: WireDecode, B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
user_id: Option<u32>,
body: &[u8],
stream_id: impl FnOnce(&T) -> &WireIdentifier,
@@ -340,6 +347,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let Ok(request) = T::decode_from(body) else {
return Ok(());
@@ -356,8 +364,8 @@
/// topic) pair, resolve both to committed slab ids, then run `rule`. A malformed
/// body or a resolution miss on either returns `Ok(())` so the builder's own
/// error / not-found reply holds (decode-and-notfound-before-permission).
-fn gate_topic_scoped<T: WireDecode, B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn gate_topic_scoped<T: WireDecode, B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
user_id: Option<u32>,
body: &[u8],
ids: impl FnOnce(&T) -> (&WireIdentifier, &WireIdentifier),
@@ -368,6 +376,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let Ok(request) = T::decode_from(body) else {
return Ok(());
@@ -383,8 +392,8 @@
/// Resolve a wire stream identifier to its committed slab id, or `None` on a
/// miss (the gate then falls through to the builder's not-found reply).
-fn resolve_stream_scope<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn resolve_stream_scope<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
) -> Option<usize>
where
@@ -392,6 +401,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
shard
.plane
@@ -403,8 +413,8 @@
/// Resolve a wire (stream, topic) pair to committed slab ids, or `None` if
/// either misses.
-fn resolve_topic_scope<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn resolve_topic_scope<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
) -> Option<(usize, usize)>
@@ -413,6 +423,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.streams().read(|inner| {
let stream_id = resolve_stream_id(inner, stream_id)?;
diff --git a/core/server-ng/src/partition_helpers.rs b/core/server-ng/src/partition_helpers.rs
index e76da44..020c515 100644
--- a/core/server-ng/src/partition_helpers.rs
+++ b/core/server-ng/src/partition_helpers.rs
@@ -28,20 +28,22 @@
use crate::server_error::ServerNgError;
use compio::fs::create_dir_all;
use configs::server_ng::ServerNgConfig;
-use consensus::{LocalPipeline, VsrConsensus};
+use consensus::{LocalPipeline, VsrConsensus, VsrState};
use iggy_common::{
ConsumerGroupOffsets, ConsumerOffsets, IggyError, IggyTimestamp, PartitionStats,
};
+use journal::superblock::{PingPongSuperblock, SuperblockContents};
use message_bus::IggyMessageBus;
+use metadata::{IdentityField, ReplicaIdentity};
use partitions::{IggyIndexWriter, IggyPartition, MessagesWriter, Segment};
use server_common::SegmentStorage;
use server_common::fs_utils::remove_dir_all;
use server_common::sharding::IggyNamespace;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::Ordering;
-use tracing::{error, warn};
+use tracing::{error, info, warn};
/// Validate that a namespace fits within the static caps declared in
/// `config.extra.namespace`.
@@ -338,13 +340,26 @@
return Ok(());
}
- let messages_path = config
- .system
- .get_messages_file_path(stream_id, topic_id, partition_id, 0);
+ // At the RESTORED FRONTIER, not always 0: after a crash inside the install's
+ // swap window the chain is empty while the recorded frontier is N, and a
+ // segment named 0 would then take the first append's `base_offset = N` --
+ // `rposition(|s| s.start_offset <= offset)` routes every poll for `0..N-1`
+ // into it, the next boot makes that shape durable, and this replica starts
+ // offering peers a segment that claims `[0..N]`.
+ let start_offset = partition.offset_frontier();
+ let messages_path =
+ config
+ .system
+ .get_messages_file_path(stream_id, topic_id, partition_id, start_offset);
let index_path = config
.system
- .get_index_path(stream_id, topic_id, partition_id, 0);
+ .get_index_path(stream_id, topic_id, partition_id, start_offset);
let enforce_fsync = config.system.partition.enforce_fsync;
+ // `file_exists = false` TRUNCATES both files, which is load-bearing here: a
+ // fenced-and-rebuilt partition (or one whose quarantine failed) can reach
+ // this with a stale `.index` at offset 0 on disk. The `partitions`-side
+ // writers with the same names do NOT truncate, so opening them directly
+ // instead would read index entries from a previous generation.
let storage = SegmentStorage::new(
&messages_path,
&index_path,
@@ -378,7 +393,7 @@
.map(|writer| writer.size_counter())
.unwrap_or_default();
partition.log.add_persisted_segment(
- Segment::new(0, config.system.segment.size),
+ Segment::new(start_offset, config.system.segment.size),
storage,
Some(Rc::new(
MessagesWriter::new(
@@ -426,6 +441,117 @@
Ok(())
}
+/// Open the durable superblock for one partition's consensus group and read
+/// back the last recorded VSR state.
+///
+/// Mirrors the metadata plane's recovery contract: an EMPTY superblock is a
+/// genuinely fresh group (or one that never changed view) and yields `None`;
+/// a present record must decode and match this replica's identity; a present
+/// but unverifiable record is an error, because treating it as fresh would
+/// let this replica re-enter a view it already acted in. The boot path
+/// tombstones just that partition rather than refusing the whole node.
+///
+/// The returned store is the ONE open instance for this group: the partition
+/// keeps writing through it, and re-opening later would fork the ping-pong
+/// sequence counter.
+///
+/// # Errors
+///
+/// [`ServerNgError::PartitionSuperblockIo`] when the directory or a slot
+/// cannot be read; the `VersionUnknown` / `Unverifiable` / `Undecodable` /
+/// `IdentityMismatch` variants when a record exists but cannot be trusted.
+pub(crate) async fn open_partition_superblock(
+ partition_dir: &str,
+ identity: ReplicaIdentity,
+) -> Result<(Rc<PingPongSuperblock>, Option<VsrState>), ServerNgError> {
+ let io_error = |source| ServerNgError::PartitionSuperblockIo {
+ dir: PathBuf::from(partition_dir),
+ source,
+ };
+ // The load path can reach a partition whose directory was never
+ // materialized on this replica (a committed create it missed); the
+ // superblock lives inside that directory either way.
+ create_dir_all(partition_dir).await.map_err(io_error)?;
+ let (superblock, latest) = PingPongSuperblock::open_with_latest(partition_dir)
+ .await
+ .map_err(io_error)?;
+ let recovered_state = match latest {
+ SuperblockContents::Present(bytes) => {
+ Some(VsrState::try_from(bytes.as_slice()).map_err(|source| {
+ ServerNgError::PartitionSuperblockUndecodable {
+ dir: PathBuf::from(partition_dir),
+ source,
+ }
+ })?)
+ }
+ SuperblockContents::Unreadable {
+ version: Some(version),
+ } => {
+ return Err(ServerNgError::PartitionSuperblockVersionUnknown {
+ dir: PathBuf::from(partition_dir),
+ version,
+ });
+ }
+ SuperblockContents::Unreadable { version: None } => {
+ return Err(ServerNgError::PartitionSuperblockUnverifiable {
+ dir: PathBuf::from(partition_dir),
+ });
+ }
+ SuperblockContents::Empty => None,
+ };
+ if let Some(state) = recovered_state.as_ref() {
+ let mismatch = |field, expected: u128, found: u128| {
+ Err(ServerNgError::PartitionSuperblockIdentityMismatch {
+ dir: PathBuf::from(partition_dir),
+ field,
+ expected,
+ found,
+ })
+ };
+ if state.cluster != identity.cluster {
+ return mismatch(IdentityField::Cluster, identity.cluster, state.cluster);
+ }
+ if state.replica_id != identity.replica_id {
+ return mismatch(
+ IdentityField::ReplicaId,
+ identity.replica_id.into(),
+ state.replica_id.into(),
+ );
+ }
+ if state.replica_count != identity.replica_count {
+ return mismatch(
+ IdentityField::ReplicaCount,
+ identity.replica_count.into(),
+ state.replica_count.into(),
+ );
+ }
+ }
+ Ok((Rc::new(superblock), recovered_state))
+}
+
+/// Restore `(view, log_view)` from a recovered superblock record and mark
+/// them durable (read back from disk, durable by definition). Runs BEFORE
+/// `init` / `init_as_backup` so the join path never advertises a view older
+/// than the recorded one.
+pub(crate) fn restore_partition_view(
+ consensus: &mut VsrConsensus<Rc<IggyMessageBus>>,
+ state: &VsrState,
+) {
+ // The one line proving the durable record was READ BACK, not merely written:
+ // the group's whole anti-regression guarantee rests on this call running, and
+ // a replica that came back at view 0 is otherwise indistinguishable from one
+ // that resumed correctly until it votes.
+ info!(
+ namespace_raw = consensus.namespace(),
+ view = state.view,
+ log_view = state.log_view,
+ "restored partition view from its superblock"
+ );
+ consensus.set_view(state.view);
+ consensus.set_log_view(state.log_view);
+ consensus.mark_superblock_durable(state.view, state.log_view);
+}
+
/// Materialise a brand-new [`IggyPartition`] for a namespace that has no on-disk state yet.
///
/// Counterpart to bootstrap's `load_partition`, which hydrates from
@@ -437,7 +563,7 @@
/// Steps performed (all idempotent on retry after a partial failure):
/// 1. Validate namespace fits within the configured caps.
/// 2. Create directory hierarchy on disk.
-/// 3. Build per-partition VSR consensus group at view 0.
+/// 3. Build per-partition VSR consensus group, resuming any superblock-recorded view.
/// 4. Configure empty consumer-offset storage with the on-disk paths set.
/// 5. Provision the initial segment + writers (offset 0).
///
@@ -448,7 +574,7 @@
/// # Errors
///
/// Returns [`ServerNgError`] when bounds validation, directory creation,
-/// or segment provisioning fails.
+/// superblock recovery, or segment provisioning fails.
pub async fn build_partition_fresh(
config: &ServerNgConfig,
namespace: IggyNamespace,
@@ -490,7 +616,7 @@
// Request queue holds 2x the prepare depth (buffered requests drain as
// prepares commit); depth is the per-partition `[partition]` knob.
let prepare_queue_depth = config.partition.prepare_queue_depth;
- let consensus = VsrConsensus::new(
+ let mut consensus = VsrConsensus::new(
cluster_id,
self_replica_id,
replica_count,
@@ -506,6 +632,27 @@
consensus.set_view_change_status_ticks(crate::bootstrap::view_change_status_ticks(config));
consensus.set_request_start_view_ticks(crate::bootstrap::request_start_view_ticks(config));
consensus.set_probe_attempts_max(config.cluster.view_probe_attempts_max);
+
+ // The hierarchy create above guarantees the directory exists; recover this
+ // group's durable (view, log_view) before choosing how to join, so a
+ // restart materialization resumes from the view it last recorded instead
+ // of re-entering an older one.
+ let partition_dir = config
+ .system
+ .get_partition_path(stream_id, topic_id, partition_id);
+ let (superblock, recovered_state) = open_partition_superblock(
+ &partition_dir,
+ ReplicaIdentity {
+ cluster: cluster_id,
+ replica_id: self_replica_id,
+ replica_count,
+ },
+ )
+ .await?;
+ if let Some(state) = recovered_state.as_ref() {
+ restore_partition_view(&mut consensus, state);
+ }
+
// A partition directory that already holds segment bytes is a RESTART
// materialization, not a fresh create: this replica's group state died
// with the process, so claiming view-0 primaryship would heartbeat
@@ -523,6 +670,7 @@
}
let mut partition = IggyPartition::new(stats, consensus);
+ partition.set_superblock(superblock, recovered_state.as_ref());
// Surface the evicted-ring ceilings from config onto the fresh journal.
// IggyPartition::new has already disabled retention for single-replica
// groups (nobody to serve), so this only sizes the multi-replica ring; the
@@ -531,11 +679,7 @@
config.partition.evicted_ring_capacity,
config.partition.evicted_ring_bytes_max.as_bytes_u64(),
);
- partition.set_partition_dir(config.system.get_partition_path(
- stream_id,
- topic_id,
- partition_id,
- ));
+ partition.set_partition_dir(partition_dir);
partition.created_at = IggyTimestamp::now();
partition.offset.store(0, Ordering::Release);
partition.dirty_offset.store(0, Ordering::Relaxed);
@@ -546,7 +690,24 @@
"fresh partition must not carry recovered segments"
);
- configure_consumer_offsets(&mut partition, config, namespace, 0)?;
+ // A "fresh" build is also how a FENCED partition comes back (the shard
+ // tombstones it and the reconciler rebuilds through here), and the fence
+ // deliberately leaves the superblock in place, so the recorded frontier is
+ // the rebuild's only anchor.
+ //
+ // It is a LOWER BOUND, not a guarantee: the record is written on view
+ // changes and transfer installs, so it lags the counter arbitrarily -- a
+ // fresh joiner that adopted a view while empty and then filled via repair
+ // has a record still reading 0, and this rebuild would re-seed at 0. For
+ // ordinary crash recovery that staleness is harmless (segments survive and
+ // win the max); it is the fence paths that promote the stale bound to sole
+ // source of truth. Closing it needs the runtime fence to persist the
+ // frontier before quarantining, and the boot-path chain refusal to carry
+ // the refused chain's max `end_offset` on its error.
+ partition.restore_offset_frontier(recovered_state.as_ref());
+ let current_offset = partition.offset.load(Ordering::Acquire);
+
+ configure_consumer_offsets(&mut partition, config, namespace, current_offset)?;
ensure_initial_segment(&mut partition, config, stream_id, topic_id, partition_id).await?;
Ok(partition)
@@ -607,3 +768,108 @@
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use journal::superblock::SuperblockStore;
+
+ const CLUSTER: u128 = 7;
+ const REPLICA: u8 = 1;
+ const REPLICAS: u8 = 3;
+
+ fn recorded_state(view: u32, log_view: u32) -> VsrState {
+ VsrState {
+ cluster: CLUSTER,
+ replica_id: REPLICA,
+ replica_count: REPLICAS,
+ view,
+ log_view,
+ commit_max: 42,
+ checkpoint_op: 0,
+ checkpoint_checksum: 0,
+ offset_frontier: 0,
+ }
+ }
+
+ fn partition_dir(root: &tempfile::TempDir) -> String {
+ root.path().join("partition").to_string_lossy().into_owned()
+ }
+
+ const fn test_identity() -> ReplicaIdentity {
+ ReplicaIdentity {
+ cluster: CLUSTER,
+ replica_id: REPLICA,
+ replica_count: REPLICAS,
+ }
+ }
+
+ #[compio::test]
+ async fn given_fresh_partition_dir_when_superblock_opened_should_yield_no_state() {
+ let root = tempfile::tempdir().expect("tempdir");
+ // A not-yet-materialized directory must open as fresh, not error: the
+ // helper creates it, since a follower can reach load before its first
+ // segment write.
+ let (_store, recovered) = open_partition_superblock(&partition_dir(&root), test_identity())
+ .await
+ .expect("open a fresh partition superblock");
+ assert!(
+ recovered.is_none(),
+ "an empty superblock is a fresh group, never an error"
+ );
+ }
+
+ #[compio::test]
+ async fn given_recorded_view_when_superblock_reopened_should_recover_state() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let dir = partition_dir(&root);
+ let (store, recovered) = open_partition_superblock(&dir, test_identity())
+ .await
+ .expect("first open");
+ assert!(recovered.is_none());
+ let state = recorded_state(3, 2);
+ store
+ .write(&state.to_bytes())
+ .await
+ .expect("record the advanced view");
+ drop(store);
+
+ let (_store, recovered) = open_partition_superblock(&dir, test_identity())
+ .await
+ .expect("reopen after a restart");
+
+ assert_eq!(
+ recovered,
+ Some(state),
+ "a restarted partition must recover exactly the state it recorded"
+ );
+ }
+
+ #[compio::test]
+ async fn given_foreign_cluster_record_when_superblock_opened_should_refuse_boot() {
+ let root = tempfile::tempdir().expect("tempdir");
+ let dir = partition_dir(&root);
+ let (store, _) = open_partition_superblock(&dir, test_identity())
+ .await
+ .expect("first open");
+ let foreign = VsrState {
+ cluster: CLUSTER + 1,
+ ..recorded_state(1, 1)
+ };
+ store
+ .write(&foreign.to_bytes())
+ .await
+ .expect("record a foreign identity");
+ drop(store);
+
+ let refused = open_partition_superblock(&dir, test_identity()).await;
+
+ match refused {
+ Err(ServerNgError::PartitionSuperblockIdentityMismatch { field, .. }) => {
+ assert_eq!(field, IdentityField::Cluster);
+ }
+ Err(other) => panic!("expected an identity mismatch, got {other}"),
+ Ok(_) => panic!("a copied or misplaced partition directory must refuse boot"),
+ }
+ }
+}
diff --git a/core/server-ng/src/partition_reconciler.rs b/core/server-ng/src/partition_reconciler.rs
index fde1d68..de8c036 100644
--- a/core/server-ng/src/partition_reconciler.rs
+++ b/core/server-ng/src/partition_reconciler.rs
@@ -193,6 +193,11 @@
const BACKOFF_BASE: Duration = Duration::from_secs(1);
const BACKOFF_MAX: Duration = Duration::from_mins(1);
+/// Consecutive same-cause failures before [`ReconcilerCtx::record_failure`]
+/// escalates to an operator-visible error (the backoff is capped, so
+/// retries alone never surface a permanently failing partition).
+const ESCALATE_AFTER_ATTEMPTS: u32 = 10;
+
/// Doubles per attempt, clamped at `BACKOFF_MAX`.
fn next_backoff(attempts: u32) -> Duration {
let shift = attempts.saturating_sub(1).min(6);
@@ -294,6 +299,25 @@
});
entry.attempts = entry.attempts.saturating_add(1);
entry.next_retry_at = now + next_backoff(entry.attempts);
+ // Operator escalation: persistent on-disk corruption makes every
+ // retry fail identically, and unlike the boot path (which refuses
+ // loudly and fatally), this loop would hide the dead partition
+ // behind per-attempt error logs forever. Escalate once the backoff
+ // has long been at its ceiling, then again each doubling so a log
+ // pipeline cannot miss it.
+ if entry.attempts >= ESCALATE_AFTER_ATTEMPTS
+ && (entry.attempts == ESCALATE_AFTER_ATTEMPTS || entry.attempts.is_power_of_two())
+ {
+ error!(
+ namespace_raw = ns.inner(),
+ ?cause,
+ attempts = entry.attempts,
+ "partition reconciliation keeps failing; retries cannot repair \
+ persistent on-disk damage -- operator intervention needed \
+ (inspect the partition directory; moving it aside lets the \
+ reconciler rebuild the replica from its group)"
+ );
+ }
}
/// Drop records whose namespace left both target and local sets;
@@ -377,6 +401,13 @@
/// acted on is not answered: aging answers requests, discarding also
/// destroys prepares.
parked_reclaimed: usize,
+ /// Purges staged this pass. Counted so the pass does not arm the
+ /// fast-skip: the pump can DEFER a purge it could not record
+ /// (`PurgeError::FrontierNotRecorded`), which leaves
+ /// `applied_purge_generation` unmoved and bumps no revision, so an armed
+ /// skip would swallow the only re-issue and drop a committed `PurgeTopic`
+ /// on this replica for good.
+ purges_staged: usize,
/// Rebuilds deferred until an in-flight `ConfirmRemove` drains. Counted
/// so the pass does not arm the fast-skip: the pump's drop clears the
/// tombstone and re-wakes us without bumping `Streams::revision`, so an
@@ -394,6 +425,7 @@
+ self.stale
+ self.cg_offsets_purged
+ self.trims_pending
+ + self.purges_staged
+ self.deferred
+ self.parked_reclaimed
}
@@ -449,7 +481,7 @@
reconcile_parked_frames(ctx, &staged, &mut counters);
reconcile_consumer_group_offsets(ctx, &mut counters).await;
reconcile_segment_truncations(ctx, &mut counters);
- reconcile_partition_purges(ctx);
+ reconcile_partition_purges(ctx, &mut counters);
let local_set: AHashSet<IggyNamespace> =
ctx.shard.plane.partitions().namespaces().copied().collect();
@@ -1062,7 +1094,7 @@
/// `PurgeTopic` generation is newer than the one the local partition last
/// applied. The pump re-checks the generation before wiping, so a redundant
/// pass (e.g. from an unrelated revision bump) is a no-op.
-fn reconcile_partition_purges(ctx: &ReconcilerCtx) {
+fn reconcile_partition_purges(ctx: &ReconcilerCtx, counters: &mut PassCounters) {
let partitions = ctx.shard.plane.partitions();
let namespaces: Vec<_> = partitions.namespaces().copied().collect();
let streams = ctx.shard.plane.metadata().mux_stm.streams();
@@ -1072,11 +1104,19 @@
namespace.topic_id(),
namespace.partition_id(),
);
- let applied = partitions
- .get_by_ns(&namespace)
- .map_or(0, partitions::IggyPartition::applied_purge_generation);
+ // `namespaces()` is NOT tombstone-filtered while `get_by_ns` is, so an
+ // absent partition would read `applied = 0` and re-stage a purge on
+ // every pass for any ever-purged topic. That was inert while staging
+ // counted as nothing; now that it disarms the fast-skip it would pin
+ // the O(N) scan on forever and enqueue a lifecycle frame per pass that
+ // the pump's tombstone-gated handler silently discards.
+ let Some(partition) = partitions.get_by_ns(&namespace) else {
+ continue;
+ };
+ let applied = partition.applied_purge_generation();
if committed > applied {
ctx.shard.request_purge_partition(namespace, committed);
+ counters.purges_staged += 1;
}
}
}
diff --git a/core/server-ng/src/responses.rs b/core/server-ng/src/responses.rs
index 2254b1c..2896541 100644
--- a/core/server-ng/src/responses.rs
+++ b/core/server-ng/src/responses.rs
@@ -79,6 +79,7 @@
RequestHeader, WireDecode, WireEncode, WireIdentifier, WireName, WirePartitioning,
};
use iggy_common::{EncryptorKind, Identifier, IggyError, IggyExpiry, IggyTimestamp, MaxTopicSize};
+use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use metadata::impls::metadata::StreamsFrontend;
use partitions::PollFragments;
@@ -98,8 +99,8 @@
/// (`user_id`, transport kind, peer address) comes from the per-shard
/// [`SessionManager`]; the `consumer_groups` list is read from the
/// (replicated) consumer-group STM by the connection's bound VSR client id.
-pub(crate) fn build_get_personal_access_tokens_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn build_get_personal_access_tokens_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
) -> GetPersonalAccessTokensResponse
@@ -108,6 +109,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
// PATs are per-user; list the requesting connection's own tokens, resolved
// from this shard's `SessionManager` (like `get_me`) then read out of the
@@ -134,8 +136,8 @@
})
}
-pub(crate) fn build_get_me_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn build_get_me_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
) -> ClientDetailsResponse
@@ -144,6 +146,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let mut client = sessions
.borrow()
@@ -208,8 +211,8 @@
/// `consumer_groups_count` is resolved from the connection's bound VSR client
/// id against the replicated `Streams` STM (memberships are keyed by VSR id, not
/// transport id). Connections that never bound (pre-register) count 0.
-pub(crate) fn connected_client_to_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn connected_client_to_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
info: &ConnectedClientInfo,
) -> ClientResponse
where
@@ -217,6 +220,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let consumer_groups_count = info.vsr_client_id.map_or(0, |vsr_client_id| {
#[allow(clippy::cast_possible_truncation)]
@@ -245,8 +249,8 @@
/// touch the offset of a partition it currently owns. `Ok` for individual
/// consumers (no fence) and for owned group partitions; `Err` otherwise so a
/// stale client re-syncs instead of corrupting the shared group offset.
-fn fence_group_offset<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn fence_group_offset<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
consumer: &WireConsumer,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
@@ -258,6 +262,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
if consumer.kind != KIND_CONSUMER_GROUP {
return Ok(());
@@ -288,8 +293,8 @@
/// Fence a consumer-group offset op then resolve its target partition
/// namespace. Shared by the four `Store`/`Delete` consumer-offset arms.
-fn fence_and_resolve_offset_namespace<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn fence_and_resolve_offset_namespace<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
consumer: &WireConsumer,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
@@ -301,6 +306,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
fence_group_offset(
shard,
@@ -313,8 +319,8 @@
resolve_partition_namespace(shard, stream_id, topic_id, partition_id)
}
-pub(crate) fn resolve_partition_request_namespace<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn resolve_partition_request_namespace<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
operation: Operation,
body: &[u8],
client_id: u128,
@@ -324,6 +330,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let namespace = match operation {
Operation::SendMessages => {
@@ -405,8 +412,8 @@
Ok(namespace.inner())
}
-fn resolve_send_messages_namespace<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn resolve_send_messages_namespace<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
header: &SendMessagesHeader,
) -> Result<IggyNamespace, IggyError>
where
@@ -414,6 +421,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let partition_id = match &header.partitioning {
WirePartitioning::PartitionId(partition_id) => *partition_id,
@@ -440,8 +448,8 @@
)
}
-pub(crate) fn resolve_partition_namespace<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn resolve_partition_namespace<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
partition_id: Option<u32>,
@@ -451,6 +459,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let partition_id = partition_id.ok_or(IggyError::InvalidIdentifier)?;
shard
@@ -468,8 +477,8 @@
/// is the caller's transport-level peer address, used only by the
/// cluster-metadata read to pick each node's advertised address; `None`
/// degrades to the catch-all address.
-pub(crate) fn build_non_replicated_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn build_non_replicated_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
code: u32,
body: &[u8],
user_id: Option<u32>,
@@ -481,6 +490,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
match code {
GET_CLUSTER_METADATA_CODE => Ok(NonReplicatedResponse::Bytes(
@@ -599,9 +609,9 @@
/// The leader marking comes from this shard's consensus view; a shard without
/// consensus (any shard but 0) still serves the full roster, only with no node
/// marked leader.
-fn build_cluster_metadata_response<B, MJ, S>(
+fn build_cluster_metadata_response<B, MJ, S, SB>(
roster: &ClusterRoster,
- shard: &Rc<ShellShard<B, MJ, S>>,
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
client_ip: Option<IpAddr>,
) -> ClusterMetadataResponse
where
@@ -609,6 +619,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
// Shard 0 reads its live consensus; delegated shards use the view shard 0
// publishes into the roster, so leader marking works on every shard.
@@ -647,14 +658,15 @@
}
}
-fn build_stats_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_stats_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
) -> Result<StatsResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let (
streams_count,
@@ -850,8 +862,8 @@
}
}
-fn build_get_stream_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_stream_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
) -> Result<Option<GetStreamResponse>, IggyError>
where
@@ -859,6 +871,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let default_max_topic_size = shard.plane.metadata().default_max_topic_size();
let default_message_expiry = shard.plane.metadata().default_message_expiry();
@@ -883,14 +896,15 @@
})
}
-fn build_get_streams_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_streams_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
) -> Result<GetStreamsResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.streams().read(|streams| {
streams
@@ -912,14 +926,15 @@
})
}
-fn build_get_users_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_users_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
) -> Result<GetUsersResponse, IggyError>
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.users().read(|users| {
users
@@ -931,8 +946,8 @@
})
}
-fn build_get_user_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_user_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
user_id: &WireIdentifier,
) -> Result<Option<UserDetailsResponse>, IggyError>
where
@@ -940,6 +955,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.users().read(|users| {
let resolved = match user_id {
@@ -980,8 +996,8 @@
Ok(GetPersonalAccessTokensResponse { tokens })
}
-fn build_get_topic_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_topic_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
) -> Result<Option<GetTopicResponse>, IggyError>
@@ -990,6 +1006,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let default_max_topic_size = shard.plane.metadata().default_max_topic_size();
let default_message_expiry = shard.plane.metadata().default_message_expiry();
@@ -1019,8 +1036,8 @@
})
}
-fn build_get_topics_response<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_topics_response<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
) -> Result<GetTopicsResponse, IggyError>
where
@@ -1028,6 +1045,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let default_max_topic_size = shard.plane.metadata().default_max_topic_size();
let default_message_expiry = shard.plane.metadata().default_message_expiry();
@@ -1050,8 +1068,8 @@
/// Reject a consumer-group read whose parent stream/topic is absent with the
/// legacy typed error naming the level that missed; the group itself missing
/// stays the shared not-found reply (empty over TCP, 404 over HTTP).
-fn ensure_topic_exists<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+fn ensure_topic_exists<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
) -> Result<(), IggyError>
@@ -1060,6 +1078,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
shard.plane.metadata().mux_stm.streams().read(|streams| {
let resolved_stream =
@@ -1425,12 +1444,13 @@
reply
}
-pub(crate) fn current_metadata_commit<B, MJ, S>(shard: &Rc<ShellShard<B, MJ, S>>) -> u64
+pub(crate) fn current_metadata_commit<B, MJ, S, SB>(shard: &Rc<ShellShard<B, MJ, S, SB>>) -> u64
where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
shard
.plane
diff --git a/core/server-ng/src/segment_recovery.rs b/core/server-ng/src/segment_recovery.rs
index a82c7e3..ef9ba54 100644
--- a/core/server-ng/src/segment_recovery.rs
+++ b/core/server-ng/src/segment_recovery.rs
@@ -27,17 +27,20 @@
//! recovery panic). This module is the server-ng-owned loader, reading the same
//! 24-byte format its writer emits.
-use crate::server_error::ServerNgError;
+use crate::server_error::{PartitionChainRefusal, ServerNgError};
use configs::server_ng::ServerNgConfig;
use iggy_common::{IggyByteSize, IggyError, PartitionStats};
+use partitions::state_transfer::STAGING_SUFFIX;
use partitions::{IggyIndexReader, Segment};
use server_common::SegmentStorage;
-use server_common::send_messages2::{COMMAND_HEADER_SIZE, SendMessages2Header};
+use server_common::send_messages2::{COMMAND_HEADER_SIZE, SendMessages2Header, decode_batch_slice};
use std::fs;
use std::os::unix::fs::FileExt;
+use std::path::PathBuf;
use tracing::{error, warn};
const LOG_EXTENSION: &str = "log";
+const INDEX_EXTENSION: &str = "index";
/// A persisted segment recovered from disk: its metadata plus the storage
/// handles (readers/writers) opened over its `.log` / `.index` files.
@@ -67,7 +70,12 @@
let partition_path = config
.system
.get_partition_path(stream_id, topic_id, partition_id);
- let mut start_offsets = collect_segment_start_offsets(&partition_path)?;
+ // ONE directory walk feeds both: the sweep only ever unlinks `.staging` and
+ // orphan `.index` files, never a `.log`, so the log stems it already
+ // collects ARE the post-sweep start-offset set. Note the error policy is
+ // the collect side's (NotFound => empty, anything else => refuse boot); the
+ // sweep's silent return would swallow an EACCES that must not be ignored.
+ let mut start_offsets = sweep_scratch_files_and_collect_offsets(&partition_path)?;
start_offsets.sort_unstable();
let enforce_fsync = config.system.partition.enforce_fsync;
@@ -98,13 +106,15 @@
)
.await?;
- // An index without a single whole entry means any log bytes were torn
- // off mid-write (the crash landed between the message write and the
- // index write). Recover the segment as EMPTY: counting the bytes with
- // `end_offset == start_offset` would fabricate one phantom message for
- // the bootstrap non-empty filters, and appending after the torn bytes
- // would strand undecodable garbage inside the readable range. Zeroed
- // sizes make the next append overwrite the torn bytes instead.
+ // `bounds == None` now means the log holds no whole BATCH either (the
+ // index-less path above already tried walking the log), so there is
+ // nothing to recover: zeroed sizes make the next append overwrite the
+ // torn bytes, where counting them with `end_offset == start_offset`
+ // would fabricate one phantom message for the bootstrap non-empty
+ // filters and strand undecodable garbage inside the readable range.
+ // Note this is NOT tail-only -- a torn index is reachable mid-chain on
+ // the shipped `enforce_fsync = false`, which is why the walk above
+ // exists rather than refusing the partition.
let (start_timestamp, end_timestamp, end_offset, effective_messages_size) =
if let Some((start_timestamp, end_timestamp, end_offset, walked_size)) = bounds {
(start_timestamp, end_timestamp, end_offset, walked_size)
@@ -170,12 +180,93 @@
last.segment.sealed = false;
}
+ ensure_contiguous_chain(
+ &recovered,
+ &partition_path,
+ stream_id,
+ topic_id,
+ partition_id,
+ )?;
+
Ok(recovered)
}
-/// Parses the zero-padded start offset out of every `.log` file name in the
-/// partition directory. A missing directory means a never-persisted partition.
-fn collect_segment_start_offsets(partition_path: &str) -> Result<Vec<u64>, ServerNgError> {
+/// Contiguity guard: recovery takes every `.log` stem in the directory, so a
+/// stray file (an unlink a failed state-transfer install could not finish,
+/// an operator copy) would otherwise splice a hole or an overlap into the
+/// chain and push `current_offset` past data this replica does not hold.
+/// Refuse loudly instead of serving a holed log.
+///
+/// The refusal names the partition and its directory so the caller can fence
+/// THAT group rather than abort the node's boot: the shapes it rejects are
+/// exactly what a failed quarantine leaves behind, and one damaged local chain
+/// must not take the whole node down.
+fn ensure_contiguous_chain(
+ recovered: &[RecoveredSegment],
+ partition_path: &str,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+) -> Result<(), ServerNgError> {
+ let refused = |reason| {
+ Err(ServerNgError::PartitionChainRefused {
+ dir: PathBuf::from(partition_path),
+ stream_id,
+ topic_id,
+ partition_id,
+ reason,
+ })
+ };
+ for pair in recovered.windows(2) {
+ let previous = &pair[0].segment;
+ let next = &pair[1].segment;
+ // A NON-tail empty segment can only be an orphan pairing: the torn-
+ // tail leniency (an index-less crash tail recovered as empty) only
+ // ever applies to the LAST element, and a size-0 segment followed by
+ // more chain is exactly what a failed converge rebuild leaves behind.
+ // Skipping it here was the guard's blind spot.
+ if previous.size == IggyByteSize::default() {
+ return refused(PartitionChainRefusal::EmptyNonTailSegment {
+ empty_start: previous.start_offset,
+ next_start: next.start_offset,
+ });
+ }
+ if next.start_offset != previous.end_offset + 1 {
+ return refused(PartitionChainRefusal::Hole {
+ previous_start: previous.start_offset,
+ previous_end: previous.end_offset,
+ next_start: next.start_offset,
+ });
+ }
+ }
+ Ok(())
+}
+
+/// Unlink the partition directory's scratch leftovers: every `*.staging` spill
+/// file, and every `.index` with no `.log` beside it.
+///
+/// Boot is the one sweep that always runs. The install-time and reuse-time
+/// staging sweeps only fire on the NEXT transfer attempt, so a transfer
+/// abandoned for good would otherwise leak a full partition copy across
+/// restarts; staging files are pure scratch (never a rename source until an
+/// install owns them), so unlinking is always safe.
+///
+/// Orphaned indexes come from the state-transfer install, which renames ALL
+/// indexes to their final names, fsyncs the directory, and only then renames the
+/// logs -- a crash in that window is GUARANTEED to leave final-name `.index`
+/// files with no `.log`. Recovery keys on `.log` stems, so nothing else ever
+/// looks at them again: they are invisible to it and to the size stats, and
+/// without this they are a permanent leak at offsets the partition may never
+/// revisit. Unlinking rather than keeping them is safe because every path that
+/// recreates a segment at a given base offset opens its index through
+/// `SegmentStorage::new(.., file_exists = false)` first, which TRUNCATES: the
+/// stale entries are never read, only overwritten.
+/// Sweeps boot-time scratch (`.staging` spill, orphan `.index`) and returns the
+/// start offset parsed out of every remaining zero-padded `.log` file name. A
+/// missing directory means a never-persisted partition.
+fn sweep_scratch_files_and_collect_offsets(
+ partition_path: &str,
+) -> Result<Vec<u64>, ServerNgError> {
let entries = match fs::read_dir(partition_path) {
Ok(entries) => entries,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
@@ -188,22 +279,48 @@
return Err(IggyError::CannotReadPartitions.into());
}
};
-
+ let mut swept = Vec::new();
+ let mut orphan_candidates = Vec::new();
+ let mut log_stems = std::collections::HashSet::new();
let mut start_offsets = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
- if path.extension().and_then(|ext| ext.to_str()) != Some(LOG_EXTENSION) {
+ let Some(as_str) = path.to_str() else {
+ continue;
+ };
+ if as_str.ends_with(STAGING_SUFFIX) {
+ swept.push(path);
continue;
}
- if let Some(start_offset) = path
- .file_stem()
- .and_then(|stem| stem.to_str())
- .and_then(|stem| stem.parse::<u64>().ok())
- {
- start_offsets.push(start_offset);
+ match path.extension().and_then(|extension| extension.to_str()) {
+ Some(LOG_EXTENSION) => {
+ if let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) {
+ log_stems.insert(stem.to_owned());
+ if let Ok(start_offset) = stem.parse::<u64>() {
+ start_offsets.push(start_offset);
+ }
+ }
+ }
+ Some(INDEX_EXTENSION) => orphan_candidates.push(path),
+ _ => {}
}
}
-
+ swept.extend(orphan_candidates.into_iter().filter(|path| {
+ !path
+ .file_stem()
+ .and_then(|stem| stem.to_str())
+ .is_some_and(|stem| log_stems.contains(stem))
+ }));
+ for path in swept {
+ if let Err(error) = fs::remove_file(&path) {
+ warn!(
+ partition_path,
+ path = %path.display(),
+ %error,
+ "failed to sweep a stale scratch file at boot"
+ );
+ }
+ }
Ok(start_offsets)
}
@@ -219,6 +336,7 @@
/// `enforce_fsync` there is no ordering barrier between the message write and
/// the index write, and a tail torn mid-flush would otherwise pass while
/// `end_offset` claims offsets whose bytes are incomplete.
+#[allow(clippy::too_many_lines)]
async fn recover_segment_bounds(
index_path: &str,
messages_path: &str,
@@ -272,12 +390,20 @@
// true end offset; a header that no longer decodes marks a torn
// tail, which truncates the readable range to the last whole
// batch so the next append overwrites the torn bytes.
+ // Opened ONCE for the walk: the helper used to open the file per
+ // batch, which is an open + pread + close for every batch in the
+ // segment, synchronously, at boot. A failure to open a file that
+ // just stat'd walks nothing, which lands on the divergence refusal
+ // below rather than recovering an indexed segment as empty.
+ let messages = fs::File::open(messages_path).ok();
let mut position = last.position;
let mut end_offset = last.offset;
let mut end_timestamp = last.timestamp;
let mut walked_any = false;
- while position < messages_size {
- let Some(header) = read_batch_header(messages_path, position, messages_size) else {
+ while let Some(messages) = messages.as_ref()
+ && position < messages_size
+ {
+ let Some(header) = read_batch_header(messages, position, messages_size) else {
break;
};
let extent = position.saturating_add(header.total_size() as u64);
@@ -306,6 +432,81 @@
}
Ok(Some((first.timestamp, end_timestamp, end_offset, position)))
}
+ // No whole index entry, but the log holds bytes: recover the bounds by
+ // WALKING the log from byte 0 instead of declaring the segment empty.
+ //
+ // The index is not the only self-describing copy -- batch headers carry
+ // their own offsets, timestamps and lengths -- and with the shipped
+ // `enforce_fsync = false` there is no write ordering between a log and
+ // its index, so a torn index is reachable on default config for a
+ // MID-CHAIN segment too, not just the tail. Recovering that as empty
+ // then trips the contiguity guard and refuses the whole partition:
+ // total serve loss (and offset reuse from 0) for a chain whose bytes
+ // are all present. The walk stops at the first header that does not
+ // decode or does not fit, which keeps the torn-tail truncation the
+ // indexed path performs.
+ _ if messages_size > 0 => {
+ // Opened once, as above. Nothing walked means no whole batch,
+ // which is the `Ok(None)` the tail of this arm already returns.
+ let messages = fs::File::open(messages_path).ok();
+ let mut position = 0u64;
+ let mut start_timestamp = None;
+ let mut end_offset = start_offset;
+ let mut end_timestamp = 0;
+ let mut expected_offset = start_offset;
+ let mut scratch = Vec::new();
+ while let Some(messages) = messages.as_ref()
+ && position < messages_size
+ {
+ let Some(header) = read_batch_header(messages, position, messages_size) else {
+ break;
+ };
+ let extent = position.saturating_add(header.total_size() as u64);
+ if extent > messages_size {
+ break;
+ }
+ // The FILENAME is the only trustworthy anchor once the index is
+ // gone, and `read_batch_header` checks a length, not a checksum.
+ // A torn header claiming an offset below `start_offset` would
+ // underflow the message count the caller derives; one claiming a
+ // jump above becomes this partition's counter, and the next
+ // prepare stamps a `base_offset` diverged from every peer. So
+ // the chain has to be contiguous from the filename onward, and
+ // the batch has to verify before its header is believed.
+ if header.base_offset != expected_offset
+ || !batch_verifies(messages, position, &header, &mut scratch)
+ {
+ break;
+ }
+ if header.message_count > 0 {
+ end_offset = header
+ .base_offset
+ .saturating_add(u64::from(header.message_count) - 1);
+ end_timestamp = header.base_timestamp;
+ start_timestamp.get_or_insert(header.base_timestamp);
+ expected_offset = end_offset.saturating_add(1);
+ }
+ position = extent;
+ }
+ let Some(start_timestamp) = start_timestamp else {
+ // Not one whole batch either: the bytes really are unusable, so
+ // the caller's empty recovery is right after all.
+ return Ok(None);
+ };
+ warn!(
+ stream_id,
+ topic_id,
+ partition_id,
+ start_offset,
+ messages_size,
+ walked_size = position,
+ "sparse index holds no whole entry; recovered segment bounds by \
+ walking the log instead of discarding it (the index repopulates \
+ on the next flush, and polls take the index-less fallback until \
+ then)"
+ );
+ Ok(Some((start_timestamp, end_timestamp, end_offset, position)))
+ }
_ => Ok(None),
}
}
@@ -313,16 +514,34 @@
/// The batch command header at `position` in the messages file, or `None`
/// when the header does not fit / decode (`position` past the file, header
/// truncated, or garbage bytes).
+/// Whether the batch at `position` decodes and passes its own `batch_checksum`.
+///
+/// The index-less recovery walk trusts nothing else: without an index the only
+/// anchors are the filename and the payload's self-description, and a torn
+/// header is exactly what that walk exists to survive.
+fn batch_verifies(
+ messages: &fs::File,
+ position: u64,
+ header: &SendMessages2Header,
+ scratch: &mut Vec<u8>,
+) -> bool {
+ scratch.clear();
+ scratch.resize(header.total_size(), 0);
+ if messages.read_exact_at(scratch, position).is_err() {
+ return false;
+ }
+ decode_batch_slice(scratch).is_ok()
+}
+
fn read_batch_header(
- messages_path: &str,
+ messages: &fs::File,
position: u64,
messages_size: u64,
) -> Option<SendMessages2Header> {
if position.checked_add(COMMAND_HEADER_SIZE as u64)? > messages_size {
return None;
}
- let file = fs::File::open(messages_path).ok()?;
let mut header_bytes = [0u8; COMMAND_HEADER_SIZE];
- file.read_exact_at(&mut header_bytes, position).ok()?;
+ messages.read_exact_at(&mut header_bytes, position).ok()?;
SendMessages2Header::decode(&header_bytes).ok()
}
diff --git a/core/server-ng/src/server_error.rs b/core/server-ng/src/server_error.rs
index 262170b..49581a1 100644
--- a/core/server-ng/src/server_error.rs
+++ b/core/server-ng/src/server_error.rs
@@ -15,10 +15,12 @@
// specific language governing permissions and limitations
// under the License.
+use consensus::VsrStateError;
use metadata::impls::recovery::RecoveryError;
use server_common::log::LogError;
use shard::ShardCtorError;
use shard_allocator::ShardingError;
+use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
@@ -102,6 +104,65 @@
Logging(#[source] LogError),
#[error("failed to recover metadata snapshot and journal")]
MetadataRecovery(#[source] RecoveryError),
+ #[error("failed to open partition superblock at {dir}")]
+ PartitionSuperblockIo {
+ dir: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+ // Quarantines the one partition rather than treating the group as fresh or
+ // reading through to a superseded view: mirrors the metadata plane's
+ // `RecoveryError::SuperblockUnreadable` policy, minus the boot refusal,
+ // because one unreadable partition directory must not strand every healthy
+ // group on the shard.
+ #[error(
+ "partition superblock at {dir} is present but its format version \
+ {version} is unrecognized by this build (a downgrade, or a corrupt \
+ version field)"
+ )]
+ PartitionSuperblockVersionUnknown { dir: PathBuf, version: u16 },
+ #[error(
+ "partition superblock at {dir} is present but a copy holds bytes that \
+ do not verify (bit-rot or a checksum failure), so its latest \
+ generation cannot be established"
+ )]
+ PartitionSuperblockUnverifiable { dir: PathBuf },
+ #[error(
+ "partition superblock at {dir} was checksum-clean but did not decode; \
+ tombstoning this partition rather than inferring a stale view"
+ )]
+ PartitionSuperblockUndecodable {
+ dir: PathBuf,
+ #[source]
+ source: VsrStateError,
+ },
+ #[error(
+ "partition superblock at {dir} belongs to a different {field}: expected \
+ {expected}, found {found}; a copied or misplaced data directory, or the \
+ cluster was resized without reconfiguration"
+ )]
+ PartitionSuperblockIdentityMismatch {
+ dir: PathBuf,
+ field: metadata::IdentityField,
+ expected: u128,
+ found: u128,
+ },
+ // Per-partition, not fatal: the boot path fences this one group (quarantines
+ // its segment files and materialises it fresh) instead of taking the node
+ // down for one damaged local chain. The shapes it reports are exactly what a
+ // failed state-transfer quarantine leaves behind, and the rebuild recovers
+ // the data from a peer.
+ #[error(
+ "partition {stream_id}/{topic_id}/{partition_id} at {dir} recovered an \
+ unusable segment chain: {reason}"
+ )]
+ PartitionChainRefused {
+ dir: PathBuf,
+ stream_id: usize,
+ topic_id: usize,
+ partition_id: usize,
+ reason: PartitionChainRefusal,
+ },
#[error(
"shard {shard_id} aborted while waiting for shard-0 to broadcast the metadata \
factory bundle; shard 0 dropped its sender (most likely it failed to recover)"
@@ -195,6 +256,49 @@
ShardJoinFailures { failures: Vec<ShardJoinFailure> },
}
+/// Why a recovered segment chain cannot be served.
+///
+/// Both shapes mean the same thing operationally -- the local files do not form
+/// a chain this replica can serve -- but they are distinguished because they
+/// point at different causes: an empty non-tail segment is a failed rebuild's
+/// orphan pairing, a hole is a stray or half-unlinked file.
+#[derive(Debug)]
+pub enum PartitionChainRefusal {
+ EmptyNonTailSegment {
+ empty_start: u64,
+ next_start: u64,
+ },
+ Hole {
+ previous_start: u64,
+ previous_end: u64,
+ next_start: u64,
+ },
+}
+
+impl std::fmt::Display for PartitionChainRefusal {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::EmptyNonTailSegment {
+ empty_start,
+ next_start,
+ } => write!(
+ f,
+ "segment {empty_start} is empty yet {next_start} follows it, so the \
+ chain cannot be served past it"
+ ),
+ Self::Hole {
+ previous_start,
+ previous_end,
+ next_start,
+ } => write!(
+ f,
+ "segment {previous_start} ends at offset {previous_end} but the next \
+ starts at {next_start}, leaving a hole"
+ ),
+ }
+ }
+}
+
/// Per-shard outcome captured by [`crate::bootstrap::ShardHandles::join_all`]
/// when a shard either returned `Err` or panicked.
///
diff --git a/core/server-ng/src/users.rs b/core/server-ng/src/users.rs
index 2fb03af..94a5cb2 100644
--- a/core/server-ng/src/users.rs
+++ b/core/server-ng/src/users.rs
@@ -43,6 +43,7 @@
use iggy_binary_protocol::requests::users::{ChangePasswordRequest, CreateUserRequest};
use iggy_binary_protocol::{Operation, PrepareHeader, RequestHeader};
use iggy_common::IggyError;
+use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use metadata::impls::metadata::StreamsFrontend;
use server_common::{Message, crypto};
@@ -57,8 +58,8 @@
/// rejection (see [`verify_and_rewrite_change_password`]). Every other operation
/// passes through unchanged. Returns [`IggyError::InvalidCommand`] only on an
/// undecodable password body.
-pub(crate) fn maybe_rewrite_user_password_request<B, MJ, S>(
- shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn maybe_rewrite_user_password_request<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RequestHeader>,
) -> Result<Message<RequestHeader>, IggyError>
where
@@ -66,6 +67,7 @@
MJ: JournalHandle + 'static,
MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
+ SB: SuperblockStore + 'static,
{
let operation = request.header().operation;
let body = request_body(&request);
diff --git a/core/shard/src/builder.rs b/core/shard/src/builder.rs
index 4de7cee..f171784 100644
--- a/core/shard/src/builder.rs
+++ b/core/shard/src/builder.rs
@@ -36,6 +36,7 @@
};
use consensus::VsrConsensus;
use journal::JournalHandle;
+use journal::superblock::{PingPongSuperblock, SuperblockStore};
use message_bus::client_listener::RequestHandler;
use message_bus::replica::listener::MessageHandler;
use message_bus::{MessageBus, SendError};
@@ -47,17 +48,17 @@
use crate::shards_table::ShardsTable;
/// A freshly constructed [`IggyShard`].
-pub struct BuiltShard<B, MJ, S, M, T>
+pub struct BuiltShard<B, MJ, S, M, T, SB = PingPongSuperblock>
where
B: MessageBus,
{
- pub shard: IggyShard<B, MJ, S, M, T>,
+ pub shard: IggyShard<B, MJ, S, M, T, SB>,
}
/// Builder that pairs [`IggyShard`] construction with coordinator wiring
/// on shard 0. Non-zero shards skip the coordinator entirely; the
/// `coord_config` field is then ignored.
-pub struct IggyShardBuilder<B, MJ, S, M, T>
+pub struct IggyShardBuilder<B, MJ, S, M, T, SB = PingPongSuperblock>
where
B: MessageBus,
{
@@ -68,8 +69,8 @@
on_metadata_submit: MetadataSubmitHandler,
on_list_clients: ListClientsHandler,
on_partition_read: PartitionReadHandler,
- metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
- partitions: IggyPartitions<B>,
+ metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M, SB>,
+ partitions: IggyPartitions<B, SB>,
senders: Vec<TaggedSender>,
inbox: Receiver<ShardFrame>,
shards_table: T,
@@ -78,13 +79,14 @@
metrics: ShardMetrics,
}
-impl<B, MJ, S, M, T> IggyShardBuilder<B, MJ, S, M, T>
+impl<B, MJ, S, M, T, SB> IggyShardBuilder<B, MJ, S, M, T, SB>
where
B: MessageBus + Clone + 'static,
T: ShardsTable,
MJ: JournalHandle,
S: Send + 'static,
M: StateMachine,
+ SB: SuperblockStore,
{
/// Create a builder carrying every input needed by both
/// [`IggyShard::new`] and (for shard 0) `ShardZeroCoordinator::new`.
@@ -97,8 +99,8 @@
on_metadata_submit: MetadataSubmitHandler,
on_list_clients: ListClientsHandler,
on_partition_read: PartitionReadHandler,
- metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
- partitions: IggyPartitions<B>,
+ metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M, SB>,
+ partitions: IggyPartitions<B, SB>,
senders: Vec<TaggedSender>,
inbox: Receiver<ShardFrame>,
shards_table: T,
@@ -137,7 +139,7 @@
/// [`ShardCtorError::ShardCountOverflow`] if `senders.len()` does not
/// fit in `u16`. Both are bootstrap programming errors and the
/// `u16` overflow check fires on every shard, not only shard 0.
- pub fn build(self) -> Result<BuiltShard<B, MJ, S, M, T>, ShardCtorError> {
+ pub fn build(self) -> Result<BuiltShard<B, MJ, S, M, T, SB>, ShardCtorError> {
let is_shard_zero = self.identity.id == 0;
// Fail fast before installing forward closures: a misordered
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index aec89d6..5563f21 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -28,8 +28,10 @@
#[cfg(any(test, feature = "simulator"))]
use consensus::LocalPipeline;
use consensus::{
- CommitOutcome, Consensus, ConsensusClock, MetadataHandle, MuxPlane, PartitionsHandle, Pipeline,
- Plane, PlaneKind, Sequencer, VsrAction, VsrConsensus, build_deny_reply_from_request_header,
+ ChunkProgress, CommitOutcome, Consensus, ConsensusClock, MetadataHandle, MuxPlane,
+ PartitionsHandle, Pipeline, Plane, PlaneKind, STATE_TRANSFER_MAX_DECODE_RETRIES,
+ STATE_TRANSFER_MAX_STALL_RETRIES, Sequencer, VsrAction, VsrConsensus,
+ build_deny_reply_from_request_header,
};
#[cfg(any(test, feature = "simulator"))]
use crossfire::AsyncRxTrait;
@@ -46,6 +48,7 @@
use iggy_common::PartitionStats;
use iggy_common::variadic;
use iggy_common::{IggyError, IggyExpiry, IggyTimestamp};
+use journal::superblock::{PingPongSuperblock, SuperblockStore};
use journal::{Journal, JournalHandle};
use message_bus::MessageBus;
use message_bus::client_listener::RequestHandler;
@@ -56,13 +59,9 @@
use metadata::impls::metadata::StreamsFrontend;
use metadata::stm::StateMachine;
use metadata::{BoundSession, MetadataSubmitError};
+use partitions::state_transfer::TransferArtifact;
use partitions::{IggyPartition, IggyPartitions, PollFragments, PollingArgs, PollingConsumer};
use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
-// Read only by the durable-before-send tripwire, which is `debug_assertions`-only, so
-// an unconditional import warns in release builds. CI's `-D warnings` rides clippy,
-// which builds debug, so that warning goes unobserved there.
-#[cfg(debug_assertions)]
-use server_common::sharding::METADATA_CONSENSUS_NAMESPACE;
use server_common::{MESSAGE_ALIGN, Message, MessageBag, iobuf::Frozen};
use shards_table::ShardsTable;
use std::cell::{Cell, RefCell};
@@ -72,8 +71,8 @@
#[cfg(any(test, feature = "simulator"))]
use std::sync::Arc;
-pub type ShardPlane<B, J, S, M> =
- MuxPlane<variadic!(IggyMetadata<VsrConsensus<B>, J, S, M>, IggyPartitions<B>)>;
+pub type ShardPlane<B, J, S, M, SB = PingPongSuperblock> =
+ MuxPlane<variadic!(IggyMetadata<VsrConsensus<B>, J, S, M, SB>, IggyPartitions<B, SB>)>;
pub struct ShardIdentity {
pub id: u16,
@@ -644,7 +643,7 @@
/// Funnelling through the pump keeps `IggyPartitions` single-writer:
/// without it the cooperative `.await` scheduler would race
/// `insert` / `remove` against the pump's live `&mut IggyPartition` (UB).
-pub enum ReconcileOp<B>
+pub enum ReconcileOp<B, SB = PingPongSuperblock>
where
B: MessageBus,
{
@@ -654,7 +653,7 @@
/// reconcile pass can detect a slab-key-reused stale partition.
InsertOwned {
namespace: IggyNamespace,
- partition: Box<IggyPartition<B>>,
+ partition: Box<IggyPartition<B, SB>>,
epoch: u64,
},
/// Seed a routing row for a partition owned by a peer shard.
@@ -797,20 +796,6 @@
/// 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
-/// node; repair then re-picks a target and re-arms a transfer if the gap is
-/// still below the new peer's retained floor.
-const STATE_TRANSFER_MAX_STALL_RETRIES: u32 = 5;
-
-/// Decode-failure rounds a receiver spends on ONE snapshot generation before
-/// refusing to pull it again. Keyed on the offered `snapshot_seq`: a peer that
-/// checkpoints resets the budget (new bytes are worth full retries), while a
-/// generation this build cannot decode ends up costing one refused descriptor
-/// per repair round instead of a full snapshot pull.
-const STATE_TRANSFER_MAX_DECODE_RETRIES: u32 = 5;
-
/// Serving-side offer lifetime, as a multiple of the repair-retry interval. An
/// offer resets its counter on every chunk it serves, so this only expires one
/// that stopped being pulled -- a receiver that finished installing (the
@@ -825,21 +810,6 @@
/// 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).
-#[derive(Debug)]
-struct ArtifactProgress {
- entry: consensus::StateArtifact,
- buf: Vec<u8>,
-}
-
-impl ArtifactProgress {
- const fn complete(&self) -> bool {
- self.buf.len() as u64 == self.entry.len
- }
-}
-
/// One in-flight metadata state transfer (shard 0 only): a cluster-restart
/// rejoin replacing its snapshot-shaped state (metadata snapshot + client
/// table) from the live primary before tail repair.
@@ -850,9 +820,18 @@
peer: u8,
/// Serving peer's applied frontier from the accepted descriptor.
commit_op: u64,
+ /// Snapshot generation of the ACCEPTED descriptor, the key the decode
+ /// budget is charged against.
+ ///
+ /// Recorded at accept because the install-time scan can fail to find it --
+ /// a manifest whose snapshot entry is absent, or a checksum mismatch on an
+ /// earlier artifact aborting the scan -- and an uncharged failure re-armed
+ /// the same peer forever: an unbounded full-manifest re-pull loop. The
+ /// descriptor cannot be accepted without one, so it is always present here.
+ generation: u64,
/// Empty until the `StateTransferTarget` manifest is accepted, then one
/// entry per offered artifact, pulled in manifest order.
- artifacts: Vec<ArtifactProgress>,
+ artifacts: Vec<consensus::ArtifactProgress>,
/// Whether a descriptor has been accepted (an accepted EMPTY manifest is
/// distinguishable from "still waiting").
target_accepted: bool,
@@ -868,9 +847,216 @@
///
/// The offer itself is refcounted, so simultaneous rejoiners on the same
/// snapshot generation share one copy of the snapshot bytes.
+/// Which plane's offer a served entry holds, and -- for partitions -- the
+/// at-most-one segment payload currently resident. Partition offers address
+/// segment bytes by path; the serving side loads one artifact at a time at
+/// chunk-serve time, so n retained gigabytes never pin n resident gigabytes.
+enum ServedOffer {
+ Metadata(Rc<metadata::StateTransferOffer>),
+ Partition(Rc<partitions::state_transfer::PartitionStateTransferOffer>),
+}
+
+/// Largest `segment.size` any configuration can set, mirroring
+/// `configs::server_config::validators::SEGMENT_MAX_SIZE_BYTES` (the `configs`
+/// crate is not a dependency here). Both the served-payload budget and the
+/// per-artifact alloc cap are derived from it rather than hand-tuned.
+const SEGMENT_SIZE_CEILING_BYTES: u64 = 1 << 30;
+
+/// The most one segment can overshoot its size cap: rotation checks the cap
+/// AFTER appending, so a segment closes at most one maximum-size batch past it.
+///
+/// Derived from the BUS frame cap, not `MAX_PAYLOAD_SIZE`: server-ng never
+/// enforces the latter (its only enforcement sites are the legacy server and the
+/// SDK batch types), so the largest appendable batch is whatever the message bus
+/// will frame. This tracks the shipped `message_bus.max_message_size` default; an
+/// operator raising that is caught by the config validator, which requires
+/// `partition.transfer_artifact_bytes_max` to cover `system.segment.size` plus
+/// the configured bus cap.
+const SEGMENT_SIZE_OVERSHOOT_BYTES: u64 = 64 * 1024 * 1024;
+
+/// Default alloc ceiling for ONE received state-transfer artifact.
+///
+/// Mirrors `[partition] transfer_artifact_bytes_max`. Free const so the config
+/// crate's copy can be pinned to it by a `const _: () = assert!(..)` at the
+/// server-ng build edge, the way every other runtime default is.
+pub const PARTITION_ARTIFACT_LEN_DEFAULT: u64 =
+ SEGMENT_SIZE_CEILING_BYTES + SEGMENT_SIZE_OVERSHOOT_BYTES;
+
+/// Default per-shard resident budget for served segment payloads
+/// (`[partition] transfer_served_cache_bytes_max`). Pinned like
+/// [`PARTITION_ARTIFACT_LEN_DEFAULT`].
+pub const SERVED_SEGMENT_CACHE_BYTES_DEFAULT: u64 =
+ PARTITION_ARTIFACT_LEN_DEFAULT * CONCURRENT_SERVED_SEGMENTS;
+
+/// Distinct max-size segments the served-payload budget holds at once.
+///
+/// TWO, not the receiver's in-flight cap of four: the budget is PER SHARD and
+/// shard count defaults to core count, so each segment here multiplies by the
+/// core count during a whole-node rejoin, on top of page cache and the receive
+/// side's own in-flight artifacts.
+///
+/// The gap between this and the in-flight cap is closed by ADMITTING fewer
+/// concurrent transfers rather than by holding more bytes: see
+/// `IggyShard::partition_transfer_admission_cap`, which derives its cap from
+/// this budget so the two can never disagree. Overrunning the budget does not
+/// degrade gracefully -- distinct groups are distinct cache keys, so a surplus
+/// pull evicts the others on every chunk and none of them converge -- and an
+/// operator who wants more concurrency raises the knob, which raises the cap
+/// with it.
+const CONCURRENT_SERVED_SEGMENTS: u64 = 2;
+
+/// Shard-wide cache of segment payloads loaded to serve partition chunks,
+/// content-addressed by `(namespace, manifest checksum)` so every requester
+/// pulling the same offer generation shares ONE resident copy (per-requester
+/// slots pinned R copies on whole-node rejoins), while requesters on
+/// different generations never alias. LRU-evicted under a byte budget; an
+/// oversized single segment still loads (the serve could not proceed
+/// otherwise) and simply owns the budget until aged out.
+#[derive(Default)]
+struct ServedSegmentCache {
+ entries: HashMap<(u64, u64), CachedSegmentPayload>,
+ resident_bytes: u64,
+ use_seq: u64,
+ /// Idle sweeps run so far; entries carry the reading at their last use, so
+ /// their age is measured on the OFFER clock rather than the raw tick.
+ sweeps: u64,
+}
+
+/// One resident payload, its last-use sequence (the LRU key), and the sweep
+/// reading at that use (the age key).
+struct CachedSegmentPayload {
+ payload: Rc<Vec<u8>>,
+ last_use: u64,
+ last_use_sweep: u64,
+}
+
+impl ServedSegmentCache {
+ /// Byte budget across all resident segment payloads on ONE shard, so the
+ /// process-wide bound is this times the shard count. LRU pressure from new
+ /// inserts plus the idle sweep below reclaim it; a single segment larger than
+ /// the budget still loads (the serve could not proceed otherwise) and owns
+ /// the budget until it ages out. A config knob can follow if operators need
+ /// to trade it against page cache.
+ ///
+ /// Sized for CONCURRENT pulls, not one: at exactly one max-size segment
+ /// (`segment.size` defaults to and is capped at 1 GiB) a single receiver
+ /// arming its `PARTITION_TRANSFERS_INFLIGHT_MAX` transfers thrashes the
+ /// cache by itself -- distinct partitions are distinct keys, so the pulls
+ /// evict each other on every chunk, and each miss re-reads and re-hashes a
+ /// whole segment to serve one 256 KiB chunk. That is the 4096:1 read
+ /// amplification this cache exists to prevent, plus an offer eviction per
+ /// failed re-verify feeding the hard-failure backoff.
+ /// Drop every payload that has served nothing for `idle_sweeps_max` sweeps.
+ ///
+ /// The budget comes from the caller because the two clocks differ: this
+ /// sweep runs on the raw 10 ms consensus tick while the offers these
+ /// payloads back expire on `retry_ticks * MULTIPLE`. Counting bare sweeps
+ /// gave a payload ~100 ms against an offer's ~10 s, so one dropped chunk
+ /// frame -- whose only re-drive is the 1 s stall sweep -- evicted the
+ /// payload and made the resume re-read and re-hash the whole segment to
+ /// serve the next 256 KiB. The trade in the other direction: an abandoned
+ /// pull now pins its resident payload for the full offer window.
+ ///
+ /// Runs from the same place offers expire: without it, one rejoin leaves a
+ /// permanent high-water of resident bytes (nothing else releases the cache
+ /// once the pulls stop).
+ fn expire_idle(&mut self, idle_sweeps_max: u64) {
+ self.sweeps += 1;
+ // Strictly BELOW the floor: at `<=` an entry stamped on sweep 0 matches
+ // `0 <= 0` on the very first sweep and is dropped whatever the budget
+ // says, and every other entry loses one sweep of its lifetime. Harmless
+ // in production, but it makes the budget untestable at its boundary.
+ let floor = self.sweeps.saturating_sub(idle_sweeps_max);
+ let stale: Vec<(u64, u64)> = self
+ .entries
+ .iter()
+ .filter(|(_, cached)| cached.last_use_sweep < floor)
+ .map(|(&key, _)| key)
+ .collect();
+ for key in stale {
+ if let Some(evicted) = self.entries.remove(&key) {
+ self.resident_bytes = self
+ .resident_bytes
+ .saturating_sub(evicted.payload.len() as u64);
+ }
+ }
+ }
+
+ /// Drop every payload cached for `namespace`, crediting their bytes back.
+ ///
+ /// A purge unlinks the segments these payloads copy, and the cache key is
+ /// the manifest checksum over the PRE-purge bytes, so nothing about a hit
+ /// can notice: the serve path answers from the resident copy without
+ /// touching disk, and every served chunk resets the expiry clock, so an
+ /// active puller keeps purged data alive indefinitely.
+ fn evict_namespace(&mut self, namespace: u64) {
+ let stale: Vec<(u64, u64)> = self
+ .entries
+ .keys()
+ .filter(|(entry_namespace, _)| *entry_namespace == namespace)
+ .copied()
+ .collect();
+ for key in stale {
+ if let Some(evicted) = self.entries.remove(&key) {
+ self.resident_bytes = self
+ .resident_bytes
+ .saturating_sub(evicted.payload.len() as u64);
+ }
+ }
+ }
+
+ fn get(&mut self, namespace: u64, checksum: u64) -> Option<Rc<Vec<u8>>> {
+ self.use_seq += 1;
+ let use_seq = self.use_seq;
+ let sweeps = self.sweeps;
+ let cached = self.entries.get_mut(&(namespace, checksum))?;
+ cached.last_use = use_seq;
+ cached.last_use_sweep = sweeps;
+ Some(Rc::clone(&cached.payload))
+ }
+
+ fn insert(&mut self, namespace: u64, checksum: u64, payload: Rc<Vec<u8>>, budget: u64) {
+ let incoming = payload.len() as u64;
+ // Credited BEFORE the eviction scan: re-inserting an existing key frees
+ // its own slot, and charging that only afterwards evicted neighbours to
+ // make room for bytes that were about to be released.
+ if let Some(replaced) = self.entries.remove(&(namespace, checksum)) {
+ self.resident_bytes = self
+ .resident_bytes
+ .saturating_sub(replaced.payload.len() as u64);
+ }
+ while self.resident_bytes.saturating_add(incoming) > budget && !self.entries.is_empty() {
+ let Some((&key, _)) = self
+ .entries
+ .iter()
+ .min_by_key(|(_, cached)| cached.last_use)
+ else {
+ break;
+ };
+ if let Some(evicted) = self.entries.remove(&key) {
+ self.resident_bytes = self
+ .resident_bytes
+ .saturating_sub(evicted.payload.len() as u64);
+ }
+ }
+ self.use_seq += 1;
+ // The key was removed above, so this never replaces an entry whose bytes
+ // still need crediting back.
+ self.entries.insert(
+ (namespace, checksum),
+ CachedSegmentPayload {
+ payload,
+ last_use: self.use_seq,
+ last_use_sweep: self.sweeps,
+ },
+ );
+ self.resident_bytes = self.resident_bytes.saturating_add(incoming);
+ }
+}
+
struct ServedStateTransfer {
nonce: u128,
- offer: Rc<metadata::StateTransferOffer>,
+ offer: ServedOffer,
/// 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
@@ -886,22 +1072,71 @@
fully_served: bool,
}
+/// One `StateTransferTarget` descriptor: the offer if there is one, plus what
+/// the serving replica knows about its own progress.
+///
+/// The progress fields ride along even on a refusal, so a receiver can tell a
+/// peer that is momentarily behind from one that knows less than it does. They
+/// are CONSTRUCTOR arguments rather than an optional builder step: as an
+/// optional step every one of the eight construction sites had to remember it,
+/// and two did not.
+struct TransferDescriptor<'a> {
+ /// `Some((manifest, commit_op))` when the peer can serve.
+ offer: Option<(&'a [consensus::StateArtifact], u64)>,
+ /// Serving replica's view and commit frontier at build time.
+ view: u32,
+ commit_max: u64,
+ /// A refusal the requester should retry soon WITHOUT charging its
+ /// consecutive-failure count. Always false when `offer` is `Some`.
+ transient: bool,
+}
+
+impl<'a> TransferDescriptor<'a> {
+ const fn available(
+ offer: &'a [consensus::StateArtifact],
+ commit_op: u64,
+ view: u32,
+ commit_max: u64,
+ ) -> Self {
+ Self {
+ offer: Some((offer, commit_op)),
+ view,
+ commit_max,
+ transient: false,
+ }
+ }
+
+ const fn unavailable(transient: bool, view: u32, commit_max: u64) -> Self {
+ Self {
+ offer: None,
+ view,
+ commit_max,
+ transient,
+ }
+ }
+}
+
/// What `on_request_state_chunk` decided inside its offers borrow; the wire
/// sends run after the borrow drops.
enum ChunkReply {
Chunk(Message<StateChunkHeader>),
- /// Offer evicted (e.g. the serving process restarted): the requester
- /// gets an unavailable descriptor and restarts its session.
- UnknownOffer,
+ /// Offer evicted (e.g. the serving process restarted, or the segment it
+ /// named can no longer be served): the requester gets an unavailable
+ /// descriptor and restarts its session. `transient` carries whether the
+ /// cause was this node's fault, which is what decides if the requester
+ /// charges a failure.
+ Unavailable {
+ transient: bool,
+ },
}
-pub struct IggyShard<B, MJ, S, M, T = ()>
+pub struct IggyShard<B, MJ, S, M, T = (), SB = PingPongSuperblock>
where
B: MessageBus,
{
pub id: u16,
pub name: String,
- pub plane: ShardPlane<B, MJ, S, M>,
+ pub plane: ShardPlane<B, MJ, S, M, SB>,
/// Handle to the local bus. Retained alongside the bus owned by every
/// consensus plane so the router can reach the `ConnectionInstaller`
@@ -927,9 +1162,22 @@
/// repair takes over at install. See [`MetadataTransferSession`].
metadata_transfer: RefCell<Option<MetadataTransferSession>>,
- /// Serving-side cache of state-transfer offers, keyed by requester
- /// replica id. Bounded by the replica count; replaced per fresh nonce.
- metadata_transfer_offers: RefCell<HashMap<u8, ServedStateTransfer>>,
+ /// Serving-side cache of state-transfer offers, both planes, keyed by
+ /// `(namespace, requester replica id)`. Bounded by the replica count times
+ /// the groups this shard serves; replaced per fresh nonce.
+ state_transfer_offers: RefCell<HashMap<(u64, u8), ServedStateTransfer>>,
+ /// Partition groups with an offer build under way but no offer yet, keyed
+ /// by namespace and carrying ticks since the last request that advanced it.
+ ///
+ /// A build spans rounds (the checksum pass is budgeted per frame body), and
+ /// during those rounds nothing in `state_transfer_offers` names the group,
+ /// so admission control cannot see it without this. Aged out on the same
+ /// clock as an idle offer, since a requester that walked away leaves
+ /// nothing else to release the slot.
+ partition_offer_builds: RefCell<HashMap<u64, u32>>,
+
+ /// See [`ServedSegmentCache`].
+ served_segment_cache: RefCell<ServedSegmentCache>,
/// Handler for inbound [`MetadataSubmit`] frames. Only shard 0 receives
/// these (it owns the metadata consensus group); peers send them here
@@ -990,7 +1238,7 @@
/// Reconciler → pump funnel. Borrow discipline: every push / drain
/// runs without `.await` inside the borrow.
- reconcile_queue: RefCell<VecDeque<ReconcileOp<B>>>,
+ reconcile_queue: RefCell<VecDeque<ReconcileOp<B, SB>>>,
/// Partition-plane frames that arrived before this shard's reconciler
/// materialised the namespace (post-`CreateTopic` convergence window).
@@ -1049,6 +1297,17 @@
/// `[cluster] repair_retry_interval` at bootstrap.
repair_retry_ticks: Cell<u32>,
+ /// Live `[partition] transfer_served_cache_bytes_max`: the byte budget for
+ /// segment payloads this shard keeps resident to serve chunk requests.
+ /// Defaults to [`SERVED_SEGMENT_CACHE_BYTES_DEFAULT`]; server-ng
+ /// overrides it at bootstrap.
+ served_segment_cache_bytes_max: Cell<u64>,
+
+ /// Live `[partition] transfer_artifact_bytes_max`: the alloc ceiling for one
+ /// RECEIVED artifact. Defaults to [`PARTITION_ARTIFACT_LEN_DEFAULT`];
+ /// server-ng overrides it at bootstrap.
+ partition_artifact_len_max: Cell<u64>,
+
/// 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
@@ -1078,10 +1337,11 @@
metadata_transfer_decode_failures: Cell<Option<(u64, u32)>>,
}
-impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
+impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
where
B: MessageBus + 'static,
T: ShardsTable,
+ SB: SuperblockStore,
{
/// Depth of this shard's inbound frame queue.
///
@@ -1131,8 +1391,8 @@
on_metadata_submit: MetadataSubmitHandler,
on_list_clients: ListClientsHandler,
on_partition_read: PartitionReadHandler,
- metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
- partitions: IggyPartitions<B>,
+ metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M, SB>,
+ partitions: IggyPartitions<B, SB>,
senders: Vec<TaggedSender>,
inbox: Receiver<ShardFrame>,
shards_table: T,
@@ -1172,7 +1432,11 @@
shard_park_shedding: Cell::new(false),
metadata_repair: RefCell::new(None),
metadata_transfer: RefCell::new(None),
- metadata_transfer_offers: RefCell::new(HashMap::new()),
+ state_transfer_offers: RefCell::new(HashMap::new()),
+ partition_offer_builds: RefCell::new(HashMap::new()),
+ served_segment_cache: RefCell::new(ServedSegmentCache::default()),
+ served_segment_cache_bytes_max: Cell::new(SERVED_SEGMENT_CACHE_BYTES_DEFAULT),
+ partition_artifact_len_max: Cell::new(PARTITION_ARTIFACT_LEN_DEFAULT),
repair_chunk_max: Cell::new(REPAIR_CHUNK_MAX),
repair_retry_ticks: Cell::new(partitions::REPAIR_RETRY_TICKS),
bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
@@ -1188,6 +1452,18 @@
self.repair_retry_ticks.set(ticks);
}
+ /// Override the serving-side resident payload budget from configuration.
+ /// Called once per shard at bootstrap.
+ pub fn set_served_segment_cache_bytes_max(&self, bytes: u64) {
+ self.served_segment_cache_bytes_max.set(bytes);
+ }
+
+ /// Override the per-artifact receive ceiling from configuration. Called once
+ /// per shard at bootstrap.
+ pub fn set_partition_artifact_len_max(&self, bytes: u64) {
+ self.partition_artifact_len_max.set(bytes);
+ }
+
/// Override the per-round repair-serving chunk ceiling from configuration.
/// Called once per shard at bootstrap; the simulator and tests keep the
/// compile-time [`REPAIR_CHUNK_MAX`] default.
@@ -1368,8 +1644,8 @@
pub fn without_inbox(
identity: ShardIdentity,
bus: B,
- metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
- partitions: IggyPartitions<B>,
+ metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M, SB>,
+ partitions: IggyPartitions<B, SB>,
shards_table: T,
partition_consensus: PartitionConsensusConfig<B>,
) -> Self {
@@ -1410,7 +1686,11 @@
shard_park_shedding: Cell::new(false),
metadata_repair: RefCell::new(None),
metadata_transfer: RefCell::new(None),
- metadata_transfer_offers: RefCell::new(HashMap::new()),
+ state_transfer_offers: RefCell::new(HashMap::new()),
+ partition_offer_builds: RefCell::new(HashMap::new()),
+ served_segment_cache: RefCell::new(ServedSegmentCache::default()),
+ served_segment_cache_bytes_max: Cell::new(SERVED_SEGMENT_CACHE_BYTES_DEFAULT),
+ partition_artifact_len_max: Cell::new(PARTITION_ARTIFACT_LEN_DEFAULT),
repair_chunk_max: Cell::new(REPAIR_CHUNK_MAX),
repair_retry_ticks: Cell::new(partitions::REPAIR_RETRY_TICKS),
bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
@@ -1487,7 +1767,7 @@
/// Marker `try_send` is best-effort; the pump's tail drain on every
/// frame and its consensus-tick drain catch dropped markers, so the
/// queue never strands ops for longer than one tick.
- pub fn enqueue_reconcile_op(&self, op: ReconcileOp<B>) {
+ pub fn enqueue_reconcile_op(&self, op: ReconcileOp<B, SB>) {
self.reconcile_queue.borrow_mut().push_back(op);
let Some(sender) = self.senders.get(self.id as usize) else {
return;
@@ -1584,7 +1864,7 @@
// Ahead of the staged ops and outside their empty-queue early return: a
// re-parked frame waits on inbox capacity, not on a reconcile op.
self.retry_reparked_frames();
- let staged: Vec<ReconcileOp<B>> = {
+ let staged: Vec<ReconcileOp<B, SB>> = {
let mut q = self.reconcile_queue.borrow_mut();
if q.is_empty() {
return;
@@ -1635,13 +1915,15 @@
}
ReconcileOp::ConfirmRemove { namespace } => {
// Tombstone bit set + shards_table row removed synchronously
- // by the reconciler before this op was enqueued, so no
- // in-flight frame can reach the partition between `remove`
- // and the drop here. Teardown already unlinked the on-disk
- // hierarchy via `delete_partitions_from_disk`, so the
- // partition drops inline: its compio file handles close
- // through io_uring without blocking, and no fsync is wanted
- // on data that is already gone.
+ // before this op was enqueued -- by the reconciler on a real
+ // delete, by `fence_partition_for_rebuild` when a partition
+ // is retired for rebuild -- so no in-flight frame can reach
+ // the partition between `remove` and the drop here. On the
+ // delete path teardown already unlinked the on-disk hierarchy
+ // via `delete_partitions_from_disk`, so the partition drops
+ // inline: its compio file handles close through io_uring
+ // without blocking, and no fsync is wanted on data that is
+ // already gone.
let removed = partitions.remove(&namespace);
partitions.untombstone(&namespace);
// A topic created then deleted before its `InsertOwned`
@@ -1678,6 +1960,96 @@
}
}
+/// The serving replica's `(view, commit_max)` for a descriptor.
+///
+/// Sampled per branch, always AFTER any offer build: the build force-flushes and
+/// hashes a budgeted slice of the un-memoized segments (a first multi-GiB
+/// serve takes several rounds to complete an offer at all) while
+/// reading its `commit_op` post-flush, so a pre-build sample could advertise a
+/// `commit_max` below the descriptor's own `commit_op`. Harmless on the receiver
+/// (the values are only compared against its own locals) but it makes its gate
+/// refuse, and refusals feed a backoff.
+const fn serving_progress<B, SB>(partition: &IggyPartition<B, SB>) -> (u32, u64)
+where
+ B: MessageBus,
+ SB: SuperblockStore,
+{
+ (
+ partition.consensus().view(),
+ partition.consensus().commit_max(),
+ )
+}
+
+/// The next replica to try after a transfer against `failed_peer` failed.
+///
+/// Prefers the view's primary: it is the only replica that can pass the serving
+/// side's caught-up-primary gate, so rotating by ring index alone can spend a
+/// full backoff round on a backup that must refuse -- and, worse, can land on a
+/// phantom view-0 primary of an empty group. Falls back to walking the ring past
+/// the failed peer, skipping this replica; a cluster of two has no alternative
+/// and retries the same peer.
+///
+/// `failed_peer` and `primary` are both bounded by `replica_count` at their
+/// ingress, which is what keeps the `+ 1` here from wrapping a peer id of 255
+/// onto replica 0.
+const fn next_transfer_peer(self_id: u8, failed_peer: u8, replica_count: u8, primary: u8) -> u8 {
+ if replica_count <= 1 {
+ return failed_peer;
+ }
+ if primary != self_id && primary != failed_peer {
+ return primary;
+ }
+ let mut candidate = (failed_peer + 1) % replica_count;
+ if candidate == self_id {
+ candidate = (candidate + 1) % replica_count;
+ }
+ if candidate == self_id {
+ failed_peer
+ } else {
+ candidate
+ }
+}
+
+/// Consecutive transient refusals before the re-arm starts logging at `error`,
+/// and the interval it re-logs at afterwards. Sized so a peer that is briefly
+/// behind stays quiet while a partition that never rejoins becomes loud.
+const TRANSFER_REFUSALS_BEFORE_ESCALATION: u32 = 10;
+
+/// Exponential re-arm backoff, scaled by the consecutive-failure count and
+/// capped at 1024x the base so a long outage settles into a slow poll
+/// instead of climbing forever.
+fn transfer_rearm_backoff(base_ticks: u32, failures: u32) -> u32 {
+ base_ticks.saturating_mul(1 << failures.min(10))
+}
+
+/// Split a handler's action list into `(local, wire)`. A failed superblock
+/// persist must fence only the WIRE sends: the local actions -- pipeline
+/// rebuild, commit walk -- flip no externally visible view state, and
+/// dropping them can wedge the group permanently. Concretely,
+/// `complete_view_change_as_primary` clears its pipeline before emitting
+/// `RebuildPipeline`; dropping that rebuild leaves a primary that drops
+/// every backup `PrepareOk` for the orphaned window as `UnknownPrepare`, and
+/// once the persist heals (backoff ceiling ~1s) the probing backups adopt
+/// this primary and stop escalating, so the 5s election that would rescue
+/// the group never fires -- writes are accepted and never commit again. The
+/// DVC quorum latch does not re-emit on retried DVCs, making the drop
+/// permanent. A short persist hiccup must not be worse than a sustained
+/// outage.
+///
+/// Partition-plane callers must route BOTH halves through BOTH dispatchers:
+/// the partition `RebuildPipeline` executes in
+/// `dispatch_partition_journal_actions` (its journal lives on the
+/// partition), while `dispatch_vsr_actions` runs it only for the metadata
+/// plane -- locals sent to one dispatcher alone silently skip the rebuild.
+fn split_local_actions(actions: Vec<VsrAction>) -> (Vec<VsrAction>, Vec<VsrAction>) {
+ actions.into_iter().partition(|action| {
+ matches!(
+ action,
+ VsrAction::RebuildPipeline { .. } | VsrAction::CommitJournal
+ )
+ })
+}
+
/// Routing verdict of [`IggyShard::park_if_unmaterialised`].
enum ParkOutcome<H> {
/// Namespace is materialised (or the frame is not a partition op):
@@ -1835,9 +2207,10 @@
/// Local message processing — these methods handle messages that have been
/// routed to this shard via the message pump.
-impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
+impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
where
B: MessageBus,
+ SB: SuperblockStore,
{
/// Dispatch an incoming network message to the appropriate consensus plane.
///
@@ -1855,7 +2228,7 @@
#[allow(clippy::future_not_send)]
pub async fn on_message(&self, message: Message<GenericHeader>)
where
- B: MessageBus,
+ B: MessageBus + 'static,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
@@ -1917,8 +2290,16 @@
let planes = self.plane.inner();
let config = planes.1.0.config();
let namespace = IggyNamespace::from_raw(routing.1);
+ // Same transfer gate as the view-change walks: a
+ // walk during a transfer can advance commit_min
+ // past the incoming frontier and trip the
+ // install's StaleTransfer refusal after the full
+ // pull. This is the highest-frequency walk (one
+ // per replicated prepare), so it needs the gate
+ // most.
if let Some(partition) = planes.1.0.get_mut_by_ns(&namespace)
&& partition.consensus().is_follower()
+ && !partition.consensus().is_transferring()
{
partition.commit_journal(config).await;
}
@@ -2756,9 +3137,25 @@
/// `-p iggy-server-ng` build excludes the `simulator` feature and this
/// method; `cargo build --workspace` compiles it in but with no
/// production caller.
+ /// `superblock` is this group's durable `(view, log_view)` store. Passing
+ /// `None` keeps the storeless branch, where the persist gate marks every view
+ /// durable without writing anything -- fine for specs that never restart a
+ /// replica, but it means the gate itself, its write-failure fence, and view
+ /// recovery are all unexercised. A caller that hands one in (the simulator,
+ /// which retains the store across a replica rebuild) gets the production
+ /// contract: a recorded view is restored before the group joins, and a failed
+ /// write withholds every view-scoped send.
+ ///
+ /// `recovered_state` is that store's last record, read by the caller (the
+ /// store's read is async and this is not), mirroring how `new_shard` takes the
+ /// metadata plane's.
#[cfg(any(test, feature = "simulator"))]
- pub fn init_partition(&self, namespace: IggyNamespace)
- where
+ pub fn init_partition(
+ &self,
+ namespace: IggyNamespace,
+ superblock: Option<Rc<SB>>,
+ recovered_state: Option<consensus::VsrState>,
+ ) where
B: MessageBus + Clone,
{
let partitions = self.plane.partitions();
@@ -2766,7 +3163,7 @@
return;
}
- let consensus = VsrConsensus::with_clock(
+ let mut consensus = VsrConsensus::with_clock(
self.partition_consensus.cluster_id,
self.partition_consensus.self_replica_id,
self.partition_consensus.replica_count,
@@ -2775,15 +3172,31 @@
LocalPipeline::new(),
self.partition_consensus.clock.clone(),
);
+ // Recorded view first, exactly as the two boot paths order it: restoring
+ // after `init` would advertise a view older than the recorded one.
+ if let Some(state) = recovered_state.as_ref() {
+ consensus.set_view(state.view);
+ consensus.set_log_view(state.log_view);
+ consensus.mark_superblock_durable(state.view, state.log_view);
+ }
consensus.init();
let stats = Arc::new(PartitionStats::default());
- let partition = IggyPartition::with_in_memory_storage(
+ let mut partition = IggyPartition::with_in_memory_storage(
stats,
consensus,
partitions.config().segment_size,
partitions.config().enforce_fsync,
);
+ if let Some(superblock) = superblock {
+ partition.set_superblock(superblock, recovered_state.as_ref());
+ }
+ // The SAME call the boot paths make, not a copy of it: this restore is
+ // a max against what the segments already proved, and a harness running
+ // a divergent copy of that rule cannot catch a violation of it. Without
+ // the restore at all, a simulator replica rebuilt against a retained
+ // store resumes minting at 0 while its group is at N.
+ partition.restore_offset_frontier(recovered_state.as_ref());
partitions.insert(namespace, partition);
}
@@ -2796,12 +3209,12 @@
#[allow(clippy::mut_from_ref)]
fn resolve_partition_target<'a>(
&self,
- partitions: &'a IggyPartitions<B>,
+ partitions: &'a IggyPartitions<B, SB>,
namespace: u64,
view: u32,
replica: u8,
frame: &'static str,
- ) -> Option<&'a mut IggyPartition<B>>
+ ) -> Option<&'a mut IggyPartition<B, SB>>
where
B: MessageBus,
{
@@ -2845,8 +3258,10 @@
&& consensus.namespace() == header.namespace
{
let actions = consensus.handle_start_view_change(PlaneKind::Metadata, &header);
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &local_actions).await;
if planes.0.persist_superblock_if_needed(consensus).await {
- dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
+ dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &wire_actions).await;
}
return;
}
@@ -2862,8 +3277,15 @@
};
let consensus = partition.consensus();
let actions = consensus.handle_start_view_change(PlaneKind::Partitions, &header);
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
- dispatch_partition_journal_actions(consensus, partition, &actions).await;
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ // Locals go to the partition dispatcher ONLY: `RebuildPipeline`
+ // executes there (`dispatch_vsr_actions` bails on `journal: None`)
+ // and `CommitJournal` is a no-op in both.
+ dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+ if partition.persist_superblock_if_needed().await {
+ dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+ dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+ }
}
#[allow(clippy::future_not_send)]
@@ -2885,12 +3307,14 @@
&& consensus.namespace() == header.namespace
{
let actions = consensus.handle_do_view_change(PlaneKind::Metadata, &header);
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &local_actions).await;
if planes.0.persist_superblock_if_needed(consensus).await {
- dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
+ dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &wire_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
+ if local_actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
&& !consensus.is_transferring()
@@ -2912,17 +3336,29 @@
};
let consensus = partition.consensus();
let actions = consensus.handle_do_view_change(PlaneKind::Partitions, &header);
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
- dispatch_partition_journal_actions(consensus, partition, &actions).await;
- if actions
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ // Locals go to the partition dispatcher ONLY: `RebuildPipeline`
+ // executes there (`dispatch_vsr_actions` bails on `journal: None`)
+ // and `CommitJournal` is a no-op in both.
+ dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+ if partition.persist_superblock_if_needed().await {
+ dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+ dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+ }
+ // Outside the gate: the persist fences the SEND, not the local commit
+ // walk (state a crash forgets is state no peer ever saw). Same
+ // transfer gate as the metadata arm: no walk while transferring.
+ if local_actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
+ && !partition.consensus().is_transferring()
{
partition.commit_journal(config).await;
}
}
#[allow(clippy::future_not_send)]
+ #[allow(clippy::too_many_lines)]
async fn on_start_view(&self, msg: Message<StartViewHeader>)
where
B: MessageBus,
@@ -2947,8 +3383,10 @@
// 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();
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &local_actions).await;
if planes.0.persist_superblock_if_needed(consensus).await {
- dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
+ dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &wire_actions).await;
}
// State transfer (rejoin behind the peers' retained floor): the
// adopted view names a live primary to fetch snapshot-shaped state
@@ -2997,7 +3435,7 @@
// committed stay journaled-but-unapplied forever, because the
// follow-up heartbeats see commit_max already advanced and skip
// their own commit_journal.
- if actions
+ if local_actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
{
@@ -3013,6 +3451,21 @@
}
let config = planes.1.0.config();
+ // Counted BEFORE the `&mut partition` below exists: the scan takes
+ // shared borrows of every partition (see `arm_partition_transfer`).
+ // Gated on the arm actually being possible, so a stale or misdirected
+ // frame -- and every StartView for a group that is not awaiting a
+ // transfer, which is all of them during an ordinary view change -- does
+ // not pay a node-wide scan. (A shard-level counter would remove the scan
+ // entirely, but `IggyPartition::transfer` is `pub` and cleared inside the
+ // partitions crate, so an externally maintained count would drift; that
+ // refactor is a prerequisite, not a detail.)
+ let transfers_inflight = if Self::may_arm_partition_transfer(&planes.1.0, header.namespace)
+ {
+ self.partition_transfers_inflight()
+ } else {
+ 0
+ };
let Some(partition) = self.resolve_partition_target(
&planes.1.0,
header.namespace,
@@ -3024,9 +3477,49 @@
};
let consensus = partition.consensus();
let actions = consensus.handle_start_view(PlaneKind::Partitions, &header);
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
- dispatch_partition_journal_actions(consensus, partition, &actions).await;
- if actions
+ let adopted = !actions.is_empty();
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ // Locals go to the partition dispatcher ONLY: `RebuildPipeline`
+ // executes there (`dispatch_vsr_actions` bails on `journal: None`)
+ // and `CommitJournal` is a no-op in both.
+ dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+ if partition.persist_superblock_if_needed().await {
+ dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+ dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+ }
+ // Gate on actual adoption: a rejected StartView returns no actions,
+ // and re-arming on one would re-mint the nonce and drop an in-flight
+ // descriptor.
+ if adopted
+ && partition.consensus().state_transfer_stage()
+ == consensus::StateTransferStage::AwaitingTarget
+ {
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ peer = header.replica,
+ "adopted a live view while awaiting transfer; requesting partition state transfer"
+ );
+ // The announcing replica becomes `session.peer`, which the re-arm
+ // path feeds to `next_transfer_peer`'s ring arithmetic, so an id
+ // outside the cluster must not get that far.
+ if self.peer_is_known(header.replica, "StartView") {
+ let _ = self
+ .arm_partition_transfer(partition, header.replica, transfers_inflight)
+ .await;
+ }
+ return;
+ }
+ // A commit walk during Fetching can advance commit_min past the
+ // incoming frontier (or trip the install's anti-rewind refusal), so
+ // gate on the whole transfer, not one stage.
+ if partition.consensus().is_transferring() {
+ return;
+ }
+ // Outside the gate: the persist fences the SEND, not the local commit
+ // walk or the repair fetch below (a fetch asks to LEARN, it does not
+ // advertise this replica's view).
+ if local_actions
.iter()
.any(|action| matches!(action, VsrAction::CommitJournal))
{
@@ -3034,36 +3527,11 @@
}
// Same gap-fill as the metadata arm: a journal-less rejoiner that
// adopted the new view still lacks the window's entries; repair from
- // the announcing primary, floor settled by its RangeEvicted.
- let consensus = partition.consensus();
- if consensus.is_normal()
- && consensus.commit_min() < consensus.commit_max()
- && partition.repair.is_none()
- {
- let nonce = iggy_common::random_id::get_uuid();
- let to_op = consensus.commit_max();
- let from_op = consensus.commit_min() + 1;
- let cluster = consensus.cluster();
- let self_id = consensus.replica();
- partition.repair = Some(partitions::RepairSession {
- nonce,
- to_op,
- floor: None,
- peer: header.replica,
- first_batch_offset: None,
- idle_ticks: 0,
- });
- self.send_request_prepares(
- cluster,
- self_id,
- header.replica,
- nonce,
- from_op,
- to_op,
- header.namespace,
- )
+ // the announcing primary, floor settled by its RangeEvicted. The shared
+ // helper carries one guard more than this site needs (`is_transferring`,
+ // already covered by the early return above) and logs the arm.
+ self.maybe_request_partition_repair(partition, header.replica)
.await;
- }
}
#[allow(clippy::future_not_send)]
@@ -3139,12 +3607,28 @@
};
let consensus = partition.consensus();
match consensus.handle_commit(&header) {
- CommitOutcome::Advanced => partition.commit_journal(config).await,
+ CommitOutcome::Advanced => {
+ if !partition.consensus().is_transferring() {
+ partition.commit_journal(config).await;
+ // Same-view late-joiner backstop: a lagging backup drops
+ // out-of-order prepares silently and StartView adoption
+ // is otherwise the only repair-arming site, so without
+ // this a same-view gap wedges until a view change. If
+ // the primary compacted past the gap, repair answers
+ // RangeEvicted and the refusal path converts to
+ // transfer.
+ self.maybe_request_partition_repair(partition, header.replica)
+ .await;
+ }
+ }
CommitOutcome::RespondStartView => {
- // Partition consensus is not superblock-durable yet, so there is no
- // view to persist before answering here; the metadata arm above
- // gates its StartView on the durable view.
- respond_start_view::<B, _, MJ>(consensus).await;
+ // Durable-before-send, as the metadata arm above: the StartView
+ // advertises this replica's current view. Withhold on failure;
+ // the stale peer keeps heartbeating, so it re-triggers once a
+ // later persist succeeds.
+ if partition.persist_superblock_if_needed().await {
+ respond_start_view::<B, _, MJ>(consensus).await;
+ }
}
CommitOutcome::Accepted => {}
}
@@ -3171,8 +3655,10 @@
&& consensus.namespace() == header.namespace
{
let actions = consensus.handle_request_start_view(PlaneKind::Metadata, &header);
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &local_actions).await;
if planes.0.persist_superblock_if_needed(consensus).await {
- dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
+ dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &wire_actions).await;
}
return;
}
@@ -3185,7 +3671,19 @@
};
let consensus = partition.consensus();
let actions = consensus.handle_request_start_view(PlaneKind::Partitions, &header);
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ // Locals go to the partition dispatcher ONLY: `RebuildPipeline`
+ // executes there (`dispatch_vsr_actions` bails on `journal: None`)
+ // and `CommitJournal` is a no-op in both.
+ dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+ // Wire to BOTH, like every other partition site: the journal
+ // dispatcher owns SendPrepareOk and the debug durable-before-send
+ // tripwire, and skipping it would drop both silently the day this
+ // handler emits one.
+ if partition.persist_superblock_if_needed().await {
+ dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+ dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+ }
}
/// Serve a repair range from this replica's journal: stream
@@ -3312,11 +3810,25 @@
let cluster = partition.consensus().cluster();
let self_id = partition.consensus().replica();
let to_op = header.to_op.min(partition.consensus().commit_max());
- let retained_from = partition.log.journal().inner.repair_retained_from();
+ // `None` means the journal holds NOTHING, not "nothing was evicted":
+ // the partition journal is memory-only and `clear_all` wipes the
+ // evicted ring with it, so a freshly installed or freshly restarted
+ // peer answers `None` for every op it once had. Reading that as "no
+ // eviction" served a bare `RepairDone`, left the requester's floor at
+ // `None`, and `FloorRefused` -- the ONLY route that arms a partition
+ // state transfer -- never fired: a lagging replica on an idle
+ // partition spun repair forever against a peer-sticky retry. An empty
+ // journal instead reports eviction from the commit frontier, which
+ // refuses the floor into a transfer (the empty window passes the
+ // completeness check) and heals in one round.
+ let retained_from = partition
+ .log
+ .journal()
+ .inner
+ .repair_retained_from()
+ .unwrap_or_else(|| partition.consensus().commit_min().saturating_add(1));
let mut from_op = header.from_op;
- if let Some(retained_from) = retained_from
- && retained_from > from_op
- {
+ if retained_from > from_op {
self.send_repair_range_reply(
cluster,
self_id,
@@ -3393,8 +3905,24 @@
});
let header = *msg.header();
let planes = self.plane.inner();
+ // Legacy acceptance: pre-upgrade metadata WAL entries were journaled
+ // before prepares stamped `consensus.namespace()`, and repair ships
+ // stored bytes verbatim, so without it a mixed-version metadata repair
+ // re-ships the same 0-stamped entries forever.
+ //
+ // Keyed on the OPERATION, not on whether partition 0/0/0 exists: raw
+ // namespace 0 is `IggyNamespace::new(0, 0, 0)` and ids slab-allocate
+ // from 0, so 0/0/0 is the first partition every cluster creates -- on a
+ // single-shard node a "no partition 0 materialised" conjunct goes false
+ // the moment one topic exists and disables this migration exactly where
+ // it is needed. `is_metadata_plane` is the plane's OWN applicability
+ // predicate (the session ops `Register`/`Logout` replicate here without
+ // being metadata mutations, so `is_metadata` alone is too narrow), which
+ // is why both sites share it rather than re-deriving the set.
+ let metadata_plane_op = header.operation.is_metadata_plane();
+ let legacy_metadata_claim = header.namespace == 0 && metadata_plane_op;
if let Some(ref consensus) = planes.0.consensus
- && consensus.namespace() == header.namespace
+ && (consensus.namespace() == header.namespace || legacy_metadata_claim)
{
let session = *self.metadata_repair.borrow();
let Some(session) = session else {
@@ -3435,6 +3963,25 @@
consensus.set_last_prepare_checksum(header.checksum);
return;
}
+ // A metadata-plane op that did not match above (no metadata consensus on
+ // this shard, or a namespace neither plane claims) is DROPPED, never
+ // offered to the partition arm. Falling through let a metadata prepare
+ // reach `apply_repaired_prepare`: it journals nothing, but it resets the
+ // partition repair session's idle ticks (masking a genuine stall) and
+ // carries the metadata prepare's checksum into the partition consensus
+ // via `set_last_prepare_checksum` -- inert only while prepare checksums
+ // are structurally zero, and a cross-plane parent stamp the moment the
+ // checksum chain is activated (see the note in `consensus::impls`).
+ if metadata_plane_op {
+ tracing::debug!(
+ shard = self.id,
+ op = header.op,
+ operation = ?header.operation,
+ namespace_raw = header.namespace,
+ "dropping a metadata-plane repair prepare this shard cannot journal"
+ );
+ return;
+ }
let Some(partition) = planes
.1
.0
@@ -3553,6 +4100,15 @@
}
return;
}
+ // Counted BEFORE the `&mut partition` below exists, and only when an arm
+ // is possible at all: see the StartView site for why the scan is gated
+ // rather than replaced with a counter.
+ let transfers_inflight = if Self::may_arm_partition_transfer(&planes.1.0, header.namespace)
+ {
+ self.partition_transfers_inflight()
+ } else {
+ 0
+ };
let config = planes.1.0.config().clone();
let Some(partition) = planes
.1
@@ -3581,7 +4137,57 @@
// the next chunk is pulled immediately; a stalled window is
// left to the retry timer.
let before = partition.consensus().commit_min();
- partition.complete_repair(&config).await;
+ if let partitions::RepairConclusion::FloorRefused { floor, to_op } =
+ partition.complete_repair(&config).await
+ {
+ if partition.consensus().state_transfer_stage()
+ == consensus::StateTransferStage::Idle
+ && partition.transfer_rearm.is_none()
+ {
+ // Repair proved the gap below the floor is neither
+ // locally durable nor repairable: the one authoritative
+ // "repair is impossible" signal. Arm from Idle only; a
+ // transfer already in flight owns the stage, and its own
+ // post-install tail repair can re-raise through this
+ // path, each round lifting the floor, so it converges.
+ // A pending scheduled re-arm owns recovery likewise --
+ // arming here would defeat its backoff.
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ floor,
+ to_op,
+ peer = header.replica,
+ attempts = partition.transfer_attempts(),
+ "partition repair floor unreachable; converting to state transfer"
+ );
+ // Same reason as the StartView arm: this id becomes
+ // `session.peer` and later reaches the peer rotation.
+ if self.peer_is_known(header.replica, "RepairRangeReply") {
+ partition.consensus().begin_state_transfer_await();
+ let _ = self
+ .arm_partition_transfer(
+ partition,
+ header.replica,
+ transfers_inflight,
+ )
+ .await;
+ }
+ } else {
+ // The refusal cleared the repair session, so falling
+ // through would log "repair complete" right after
+ // the refusal diagnostic. The in-flight transfer (or
+ // the scheduled re-arm) owns recovery from here.
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ floor,
+ to_op,
+ "partition repair floor refused; transfer in flight or scheduled"
+ );
+ }
+ return;
+ }
if partition.repair.is_none() {
tracing::info!(
shard = self.id,
@@ -3807,11 +4413,13 @@
target: u8,
nonce: u128,
namespace: u64,
- offer: Option<&metadata::StateTransferOffer>,
+ descriptor: TransferDescriptor<'_>,
) where
B: MessageBus,
{
- let manifest = offer.map(|offer| consensus::encode_state_manifest(&offer.manifest()));
+ let manifest = descriptor
+ .offer
+ .map(|(entries, _)| consensus::encode_state_manifest(entries));
let total_size =
size_of::<StateTransferTargetHeader>() + manifest.as_ref().map_or(0, Vec::len);
let mut msg = Message::<StateTransferTargetHeader>::new(total_size);
@@ -3825,9 +4433,15 @@
h.nonce = nonce;
h.namespace = namespace;
h.size = total_size as u32;
- if let Some(offer) = offer {
+ // The serving replica's own progress travels with every descriptor,
+ // available or not: it is what lets a receiver refuse an offer from
+ // a replica that knows less than it does.
+ h.view = descriptor.view;
+ h.commit_max = descriptor.commit_max;
+ h.unavailable_transient = u8::from(descriptor.transient);
+ if let Some((_, commit_op)) = descriptor.offer {
h.available = 1;
- h.commit_op = offer.commit_op;
+ h.commit_op = commit_op;
}
});
let _ = self
@@ -3874,7 +4488,7 @@
/// Serve one `RequestStateTransfer`: build a fresh offer (or refuse),
/// cache it for the chunk pulls, and answer with the descriptor.
- #[allow(clippy::future_not_send)]
+ #[allow(clippy::future_not_send, clippy::too_many_lines)]
async fn on_request_state_transfer(&self, msg: &Message<RequestStateTransferHeader>)
where
B: MessageBus,
@@ -3888,12 +4502,17 @@
{
let header = *msg.header();
let planes = self.plane.inner();
+ let metadata_frame = planes
+ .0
+ .consensus
+ .as_ref()
+ .is_some_and(|consensus| consensus.namespace() == header.namespace);
+ if !metadata_frame {
+ return self.on_partition_request_state_transfer(msg).await;
+ }
let Some(ref consensus) = planes.0.consensus else {
return;
};
- if consensus.namespace() != header.namespace {
- return;
- }
let cluster = consensus.cluster();
let self_id = consensus.replica();
@@ -3905,17 +4524,20 @@
// 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
+ .state_transfer_offers
.borrow_mut()
- .get_mut(&header.replica)
+ .get_mut(&(header.namespace, header.replica))
.filter(|served| served.nonce == header.nonce)
- .map(|served| {
+ .and_then(|served| {
+ let ServedOffer::Metadata(offer) = &served.offer else {
+ return None;
+ };
// 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)
+ Some(Rc::clone(offer))
});
if let Some(offer) = cached {
tracing::debug!(
@@ -3929,7 +4551,12 @@
header.replica,
header.nonce,
header.namespace,
- Some(&offer),
+ TransferDescriptor::available(
+ &offer.manifest(),
+ offer.commit_op,
+ consensus.view(),
+ consensus.commit_max(),
+ ),
)
.await;
return;
@@ -3952,14 +4579,19 @@
header.replica,
header.nonce,
header.namespace,
- Some(&offer),
+ TransferDescriptor::available(
+ &offer.manifest(),
+ offer.commit_op,
+ consensus.view(),
+ consensus.commit_max(),
+ ),
)
.await;
- self.metadata_transfer_offers.borrow_mut().insert(
- header.replica,
+ self.state_transfer_offers.borrow_mut().insert(
+ (header.namespace, header.replica),
ServedStateTransfer {
nonce: header.nonce,
- offer,
+ offer: ServedOffer::Metadata(offer),
idle_ticks: 0,
fully_served: false,
},
@@ -3982,7 +4614,11 @@
header.replica,
header.nonce,
header.namespace,
- None,
+ TransferDescriptor::unavailable(
+ false,
+ consensus.view(),
+ consensus.commit_max(),
+ ),
)
.await;
}
@@ -3994,7 +4630,8 @@
#[allow(clippy::future_not_send, clippy::too_many_lines)]
async fn on_state_transfer_target(&self, msg: &Message<StateTransferTargetHeader>)
where
- B: MessageBus,
+ B: MessageBus + 'static,
+ T: ShardsTable,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
@@ -4017,12 +4654,17 @@
let header = *msg.header();
let planes = self.plane.inner();
+ let metadata_frame = planes
+ .0
+ .consensus
+ .as_ref()
+ .is_some_and(|consensus| consensus.namespace() == header.namespace);
+ if !metadata_frame {
+ return self.on_partition_state_transfer_target(msg).await;
+ }
let Some(ref consensus) = planes.0.consensus else {
return;
};
- if consensus.namespace() != header.namespace {
- return;
- }
let session_matches = self
.metadata_transfer
.borrow()
@@ -4139,7 +4781,7 @@
{
session.artifacts = manifest
.iter()
- .map(|&entry| ArtifactProgress {
+ .map(|&entry| consensus::ArtifactProgress {
entry,
buf: Vec::with_capacity(entry.len as usize),
})
@@ -4183,17 +4825,9 @@
if !session.target_accepted {
return None;
}
- let (index, artifact) = session
- .artifacts
- .iter()
- .enumerate()
- .find(|(_, artifact)| !artifact.complete())?;
- let offset = artifact.buf.len() as u64;
- let remaining = artifact.entry.len - offset;
- #[allow(clippy::cast_possible_truncation)]
- let len = remaining.min(chunk_len_max) as u32;
- #[allow(clippy::cast_possible_truncation)]
- Some((session.nonce, session.peer, index as u32, offset, len))
+ consensus::next_pending_chunk(&session.artifacts, chunk_len_max).map(
+ |(artifact, offset, len)| (session.nonce, session.peer, artifact, offset, len),
+ )
})
};
if let Some((nonce, peer, artifact, offset, len)) = request {
@@ -4229,6 +4863,9 @@
nonce,
peer,
commit_op: 0,
+ // Set when a descriptor is accepted; a session with no accepted
+ // descriptor never reaches the install path that reads it.
+ generation: 0,
artifacts: Vec::new(),
target_accepted: false,
idle_ticks: 0,
@@ -4308,12 +4945,17 @@
{
let header = *msg.header();
let planes = self.plane.inner();
+ let metadata_frame = planes
+ .0
+ .consensus
+ .as_ref()
+ .is_some_and(|consensus| consensus.namespace() == header.namespace);
+ if !metadata_frame {
+ return self.on_partition_request_state_chunk(msg).await;
+ }
let Some(ref consensus) = planes.0.consensus else {
return;
};
- if consensus.namespace() != header.namespace {
- return;
- }
let cluster = consensus.cluster();
let self_id = consensus.replica();
@@ -4327,60 +4969,66 @@
// RefCell borrow must not cross an await on the shard).
// Out-of-bounds requests are dropped silently inside the block.
let reply = {
- let mut offers = self.metadata_transfer_offers.borrow_mut();
+ let mut offers = self.state_transfer_offers.borrow_mut();
let served = offers
- .get_mut(&header.replica)
+ .get_mut(&(header.namespace, header.replica))
.filter(|served| served.nonce == header.nonce);
- served.map_or(Some(ChunkReply::UnknownOffer), |served| {
- // 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.len().saturating_sub(1);
- let artifact_bytes = served.offer.payload(header.artifact as usize)?;
- let start = header.offset as usize;
- // 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;
- }
- // 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;
- },
- )))
- })
+ served.map_or(
+ Some(ChunkReply::Unavailable { transient: true }),
+ |served| {
+ let ServedOffer::Metadata(offer) = &served.offer else {
+ return Some(ChunkReply::Unavailable { transient: true });
+ };
+ // Manifest-index addressing: an index past the offer is a
+ // requester bug (or a stale frame) and is dropped below.
+ let last_artifact = offer.len().saturating_sub(1);
+ let artifact_bytes = offer.payload(header.artifact as usize)?;
+ let start = header.offset as usize;
+ // 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;
+ }
+ // 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 {
Some(ChunkReply::Chunk(chunk)) => {
@@ -4389,10 +5037,11 @@
.send_to_replica(header.replica, chunk.into_generic().into_frozen())
.await;
}
- Some(ChunkReply::UnknownOffer) => {
+ Some(ChunkReply::Unavailable { transient }) => {
tracing::info!(
shard = self.id,
requester = header.replica,
+ transient,
"state chunk request for an unknown offer; telling requester to restart"
);
self.send_state_transfer_target(
@@ -4401,7 +5050,11 @@
header.replica,
header.nonce,
header.namespace,
- None,
+ TransferDescriptor::unavailable(
+ transient,
+ consensus.view(),
+ consensus.commit_max(),
+ ),
)
.await;
}
@@ -4422,7 +5075,7 @@
#[allow(clippy::future_not_send, clippy::too_many_lines)]
async fn on_state_chunk(&self, msg: &Message<StateChunkHeader>)
where
- B: MessageBus,
+ B: MessageBus + 'static,
MJ: JournalHandle,
<MJ as JournalHandle>::Target: Journal<
<MJ as JournalHandle>::Storage,
@@ -4430,14 +5083,17 @@
Header = PrepareHeader,
>,
M: RestorableMetadataStm,
+ T: ShardsTable,
{
let header = *msg.header();
let planes = self.plane.inner();
- let Some(ref consensus) = planes.0.consensus else {
- return;
- };
- if consensus.namespace() != header.namespace {
- return;
+ let metadata_frame = planes
+ .0
+ .consensus
+ .as_ref()
+ .is_some_and(|consensus| consensus.namespace() == header.namespace);
+ if !metadata_frame {
+ return self.on_partition_state_chunk(msg).await;
}
{
@@ -4448,34 +5104,17 @@
if session.nonce != header.nonce || !session.target_accepted {
return;
}
- let Some(artifact) = session.artifacts.get_mut(header.artifact as usize) else {
- return;
- };
let payload = &msg.as_slice()[size_of::<StateChunkHeader>()..header.size as usize];
- // Chunks are pulled sequentially with one in flight; anything
- // else is a duplicate or reorder and is dropped (the stall retry
- // re-requests from the current frontier).
- if header.offset != artifact.buf.len() as u64 {
+ // Sequential-offset, overrun, and zero-byte-payload guards live in
+ // the shared session math so both planes keep the exact invariants.
+ if !consensus::append_chunk(
+ &mut session.artifacts,
+ header.artifact,
+ header.offset,
+ payload,
+ ) {
return;
}
- if artifact.buf.len() as u64 + payload.len() as u64 > artifact.entry.len {
- tracing::warn!(
- shard = self.id,
- artifact = header.artifact,
- "state chunk overruns the declared artifact length; dropping frame"
- );
- 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();
@@ -4529,9 +5168,10 @@
let complete = {
let session = self.metadata_transfer.borrow();
match session.as_ref() {
- Some(session) if session.target_accepted => {
- session.artifacts.iter().all(ArtifactProgress::complete)
- }
+ Some(session) if session.target_accepted => session
+ .artifacts
+ .iter()
+ .all(consensus::ArtifactProgress::complete),
_ => return,
}
};
@@ -4548,6 +5188,12 @@
.expect("session checked above");
let peer = session.peer;
let commit_op = session.commit_op;
+ // From the ACCEPTED descriptor, not re-derived from the artifacts: a
+ // scan that aborts before the snapshot entry (unknown kind first, or a
+ // checksum mismatch ahead of it) would leave nothing to charge, and an
+ // uncharged decode failure re-arms the same peer for the same manifest
+ // forever.
+ let generation = session.generation;
// Per-artifact integrity, then pick the pieces this plane installs.
// Unknown kinds are refused rather than skipped: an artifact the
@@ -4555,14 +5201,8 @@
// install would otherwise be silently dropped.
let mut snapshot: Option<Vec<u8>> = None;
let mut table: Option<(Vec<u8>, u64)> = None;
- // Captured before the integrity checks so a damaged pull still knows
- // which generation to charge the decode budget against.
- let mut generation: Option<u64> = None;
let mut damaged = false;
for (index, artifact) in session.artifacts.into_iter().enumerate() {
- if artifact.entry.kind == consensus::artifact_kind::METADATA_SNAPSHOT {
- generation = Some(artifact.entry.frontier);
- }
let actual = consensus::state_artifact_checksum(&artifact.buf);
if actual != artifact.entry.checksum {
tracing::error!(
@@ -4631,9 +5271,7 @@
// failures are charged per snapshot generation instead: a
// generation past its budget is refused at descriptor time until
// the peer checkpoints a new one.
- let exhausted =
- generation.is_some_and(|generation| self.burn_decode_failure(generation));
- if exhausted {
+ if self.burn_decode_failure(generation) {
tracing::warn!(
shard = self.id,
peer,
@@ -4714,6 +5352,7 @@
/// Tick partition consensuses. Loop partitions. No partitions-plane journal.
#[allow(clippy::future_not_send)]
+ #[allow(clippy::too_many_lines)]
pub async fn tick_partitions(&self)
where
B: MessageBus,
@@ -4736,6 +5375,56 @@
// still pays one Vec per heartbeat.
let namespaces: Vec<_> = partitions.namespaces().copied().collect();
+ // Pre-pass: issue every group's pending superblock persist
+ // CONCURRENTLY. A cluster-wide view change makes every group on
+ // this shard need one in the same tick, and each `atomic_replace`
+ // is a create + write + 2 fsyncs; run serially, a few hundred
+ // groups on ordinary storage exceed the 5s view-change escalation
+ // and loop elections. The persists are independent (each group owns
+ // its store, lock, and failure bookkeeping, all behind `&self`),
+ // and the per-group loop below re-checks the gate on its lock-free
+ // fast path, so gating semantics are unchanged.
+ let pending_persists: Vec<_> = namespaces
+ .iter()
+ .copied()
+ .filter(|namespace| {
+ partitions
+ .get_by_ns(namespace)
+ .is_some_and(|partition| partition.consensus().needs_superblock_persist())
+ })
+ .map(|namespace| async move {
+ if let Some(partition) = partitions.get_by_ns(&namespace) {
+ // The only dropped durability verdict in the tree: this pre-pass
+ // exists to coalesce the writes, and the per-group loop below re-runs
+ // the same gate on its lock-free fast path and withholds every
+ // view-scoped send when it fails, so the verdict here is redundant
+ // rather than ignored.
+ let _ = partition.persist_superblock_if_needed().await;
+ }
+ })
+ .collect();
+ // Capped fan-out: each persist is a create + write + 2 fsyncs, and a
+ // node-wide view change over many partitions must not dump an
+ // unbounded fd/fsync burst onto the reactor in one tick.
+ let mut pending_persists = pending_persists.into_iter();
+ loop {
+ let chunk: Vec<_> = pending_persists.by_ref().take(16).collect();
+ if chunk.is_empty() {
+ break;
+ }
+ futures::future::join_all(chunk).await;
+ }
+
+ // Counted at most ONCE per sweep and only if a re-arm actually fires,
+ // then tracked locally as arms land. Counting per namespace is a full
+ // scan per partition, so with per-partition groups the sweep would be
+ // O(P^2) exactly when every group is re-arming at once (node-wide view
+ // change or rejoin) -- and counting eagerly every tick pays that scan on
+ // every quiet tick too, since the re-arm branch is rare. A slot freed
+ // mid-sweep is seen on the next tick, the same latency a capped arm
+ // already accepts.
+ let mut transfers_inflight: Option<usize> = None;
+
for namespace in namespaces {
let Some(partition) = partitions.get_by_ns(&namespace) else {
continue;
@@ -4743,8 +5432,17 @@
let consensus = partition.consensus();
let actions = consensus.tick(PlaneKind::Partitions);
- dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
- dispatch_partition_journal_actions(consensus, partition, &actions).await;
+ // The tick emits view-scoped sends (heartbeats, view-change
+ // retransmits), so it persists first like every dispatch site;
+ // it is also what retries a persist an earlier site withheld on.
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ // Locals to the partition dispatcher only; see the view-change
+ // sites for the rationale.
+ dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+ if partition.persist_superblock_if_needed().await {
+ dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+ dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+ }
// Stall retry: repair frames are fire-and-forget, so a lost
// frame (or a peer that went silent mid-stream) would leave the
@@ -4800,6 +5498,93 @@
)
.await;
}
+
+ // Transfer stall retry: descriptor and chunk frames are
+ // fire-and-forget, so a lost one must not wedge the session (and
+ // the rejoin behind it) forever. Budget-bounded: a peer that died
+ // mid-transfer is abandoned back to journal repair, which
+ // re-targets the current primary.
+ let transfer_stalled = {
+ let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+ continue;
+ };
+ partition.transfer.as_mut().and_then(|session| {
+ session.idle_ticks += 1;
+ if session.idle_ticks < repair_retry_ticks {
+ return None;
+ }
+ session.idle_ticks = 0;
+ Some((session.peer, session.nonce, session.target_accepted))
+ })
+ };
+ if let Some((peer, nonce, target_accepted)) = transfer_stalled {
+ let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+ continue;
+ };
+ if partition.burn_transfer_attempt() {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ peer,
+ "partition state transfer stalled past its retry budget; abandoning with a backed-off re-arm"
+ );
+ // Staging files are KEPT: a later attempt adopts every
+ // segment whose manifest entry still matches. The shared
+ // path charges the failure, rotates the peer, schedules
+ // the re-arm, and re-arms journal repair meanwhile.
+ self.abandon_or_rearm_partition_transfer(partition, peer)
+ .await;
+ } else if target_accepted {
+ self.request_pending_partition_chunk(namespace.inner())
+ .await;
+ } else {
+ self.send_request_state_transfer(partition.consensus(), peer, nonce)
+ .await;
+ }
+ }
+
+ // Scheduled transfer re-arm: count the backoff down and fire
+ // once nothing else recovered the partition in the meantime (a
+ // live session or a non-Idle stage owns the slot; the pending
+ // entry is then dropped as superseded).
+ let rearm_peer = {
+ let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+ continue;
+ };
+ match partition.transfer_rearm.as_mut() {
+ Some(pending) if pending.after_ticks > 0 => {
+ pending.after_ticks -= 1;
+ None
+ }
+ Some(pending) => {
+ let peer = pending.peer;
+ partition.transfer_rearm = None;
+ if partition.transfer.is_none()
+ && partition.consensus().state_transfer_stage()
+ == consensus::StateTransferStage::Idle
+ {
+ Some(peer)
+ } else {
+ None
+ }
+ }
+ None => None,
+ }
+ };
+ if let Some(peer) = rearm_peer {
+ // Counted here, before the `&mut partition` below exists: the
+ // scan takes shared borrows of every partition.
+ let inflight =
+ *transfers_inflight.get_or_insert_with(|| self.partition_transfers_inflight());
+ let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+ continue;
+ };
+ partition.consensus().begin_state_transfer_await();
+ let armed = self.arm_partition_transfer(partition, peer, inflight).await;
+ if armed {
+ transfers_inflight = Some(inflight + 1);
+ }
+ }
}
}
@@ -4836,23 +5621,1597 @@
}
}
+ /// Whether this shard may build a partition offer for `namespace` without
+ /// pushing the served-payload working set past its byte budget.
+ ///
+ /// Counts DISTINCT groups rather than requesters: the payload cache is
+ /// content-addressed, so every requester pulling one group's offer shares
+ /// one resident copy, and it is the group count that decides how many
+ /// segments must be resident at once. A group already being served always
+ /// passes, so admission cannot revoke a transfer midway.
+ ///
+ /// BOTH inputs are the configured ones. Dividing by the compile-time
+ /// segment ceiling instead of the deployed `system.segment.size` would make
+ /// the numerator the only thing an operator controls: on a 64 MiB-segment
+ /// deployment the same budget holds sixteen times as many payloads as a cap
+ /// derived from the 1 GiB ceiling would admit, and rejoins serialise for no
+ /// reason.
+ ///
+ /// The divisor is the size a SEALED segment actually reaches, not the
+ /// configured target: rotation fires after the append that crosses it, so a
+ /// sealed segment runs up to one maximum batch past `segment.size`. Dividing
+ /// by the bare target says two payloads fit a two-target budget when they do
+ /// not, and `ServedSegmentCache::insert` then evicts one per chunk -- the
+ /// thrash this cap exists to prevent, reintroduced through the arithmetic.
+ ///
+ /// That size is `partition_artifact_len_max`, which the config validator
+ /// floors at `segment.size` plus the CONFIGURED `message_bus.max_message_size`.
+ /// The compile-time [`SEGMENT_SIZE_OVERSHOOT_BYTES`] only tracks the shipped
+ /// bus cap, so using it would restore the same thrash on any deployment that
+ /// raised that knob: the sealed segment grows with the bus cap while the
+ /// divisor would not. It is kept as a floor for the case where an operator
+ /// sets the artifact ceiling below what a segment can reach.
+ ///
+ /// At least one is always admitted, since refusing every rejoin is worse
+ /// than re-reading for a single one; the quotient rather than the divisor
+ /// carries that clamp, so a zero segment size fails CLOSED at one slot
+ /// instead of disabling admission control.
+ fn partition_transfer_admission_cap(&self) -> usize {
+ let segment_size = self.plane.partitions().config().segment_size.as_bytes_u64();
+ let resident_len = self
+ .partition_artifact_len_max
+ .get()
+ .max(segment_size.saturating_add(SEGMENT_SIZE_OVERSHOOT_BYTES));
+ let slots = self
+ .served_segment_cache_bytes_max
+ .get()
+ .checked_div(resident_len)
+ .unwrap_or(1);
+ usize::try_from(slots).unwrap_or(usize::MAX).max(1)
+ }
+
+ fn may_serve_another_partition_transfer(&self, namespace: u64) -> bool {
+ let builds = self.partition_offer_builds.borrow();
+ if builds.contains_key(&namespace) {
+ return true;
+ }
+ let offers = self.state_transfer_offers.borrow();
+ let mut served: Vec<u64> = offers
+ .iter()
+ .filter(|(_, served)| matches!(served.offer, ServedOffer::Partition(_)))
+ .map(|((offer_namespace, _), _)| *offer_namespace)
+ .collect();
+ if served.contains(&namespace) {
+ return true;
+ }
+ // Builds count too. A multi-round checksum pass holds no offer yet, so
+ // counting only completed offers admitted every requester's whole
+ // in-flight set at once and let each run its own pass: the frame bodies
+ // stay bounded, but the pump carries N budgets per round-cycle and
+ // every other frame, produce included, queues behind them.
+ served.extend(builds.keys().copied());
+ served.sort_unstable();
+ served.dedup();
+ served.len() < self.partition_transfer_admission_cap()
+ }
+
+ /// Serve one partition `RequestStateTransfer`: build (or re-serve) this
+ /// group's offer and answer with the descriptor.
+ #[allow(clippy::future_not_send, clippy::too_many_lines)]
+ async fn on_partition_request_state_transfer(&self, msg: &Message<RequestStateTransferHeader>)
+ where
+ B: MessageBus,
+ {
+ let header = *msg.header();
+ // Before anything keyed by the requester: the offer map's documented
+ // bound is the replica count, and an unvalidated id makes it 256 entries
+ // per served group, each pinning an offer for its full expiry.
+ if !self.peer_is_known(header.replica, "RequestStateTransfer") {
+ return;
+ }
+ let planes = self.plane.inner();
+ let config = planes.1.0.config().clone();
+ let Some(partition) = planes
+ .1
+ .0
+ .get_mut_by_ns(&IggyNamespace::from_raw(header.namespace))
+ else {
+ return;
+ };
+ let cluster = partition.consensus().cluster();
+ let self_id = partition.consensus().replica();
+ // First-wins per (requester, nonce), exactly as the metadata arm: a
+ // stall retry reuses the nonce, and rebuilding under it could hand
+ // the receiver chunks from a different offer than the manifest it
+ // accepted. Re-answering with the SAME offer keeps the retry
+ // idempotent.
+ let cached = self
+ .state_transfer_offers
+ .borrow_mut()
+ .get_mut(&(header.namespace, header.replica))
+ .filter(|served| served.nonce == header.nonce)
+ .and_then(|served| {
+ let ServedOffer::Partition(offer) = &served.offer else {
+ return None;
+ };
+ served.idle_ticks = 0;
+ Some(Rc::clone(offer))
+ });
+
+ // One resolve, one send: the three outcomes differ only in the
+ // descriptor they produce, and duplicating the send made it possible for
+ // them to drift on the progress they advertise.
+ let offer = match cached {
+ Some(offer) => {
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ "re-answering a partition state transfer request from the offer \
+ already served"
+ );
+ Some(offer)
+ }
+ None if !self.may_serve_another_partition_transfer(header.namespace) => {
+ // Admission control, because the served-payload budget is a
+ // BYTE budget and the pulls that overrun it do not degrade
+ // gracefully. Each concurrent pull holds a different segment
+ // resident, so admitting more distinct groups than the budget
+ // has max-size slots makes them evict each other on every
+ // chunk: every request then re-reads and re-hashes a whole
+ // segment to serve one 256 KiB range, and a per-chunk serve
+ // that outruns the requester's stall interval exhausts its
+ // retry budget, so the pull rotates peers and never converges.
+ // Refusing the surplus is what makes the admitted ones finish.
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ "already serving as many partition transfers as the served-payload \
+ budget holds; refusing until one completes"
+ );
+ let (view, commit_max) = serving_progress(partition);
+ self.send_state_transfer_target(
+ cluster,
+ self_id,
+ header.replica,
+ header.nonce,
+ header.namespace,
+ TransferDescriptor::unavailable(true, view, commit_max),
+ )
+ .await;
+ return;
+ }
+ None => {
+ // Claim the admission slot for the whole build, not just for a
+ // completed offer: the checksum pass runs over several rounds
+ // and holds nothing in the offers map meanwhile.
+ self.partition_offer_builds
+ .borrow_mut()
+ .insert(header.namespace, 0);
+ match partition.state_transfer_offer(&config).await {
+ Ok(offer) => {
+ self.partition_offer_builds
+ .borrow_mut()
+ .remove(&header.namespace);
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ commit_op = offer.commit_op,
+ artifacts = offer.artifact_count(),
+ total_len = offer.total_len(),
+ "serving partition state transfer"
+ );
+ self.state_transfer_offers.borrow_mut().insert(
+ (header.namespace, header.replica),
+ ServedStateTransfer {
+ nonce: header.nonce,
+ offer: ServedOffer::Partition(Rc::clone(&offer)),
+ idle_ticks: 0,
+ fully_served: false,
+ },
+ );
+ Some(offer)
+ }
+ Err(reason) => {
+ // The ACTUAL reason: "not the caught-up primary" is routine
+ // (the requester re-targets), an unreadable segment is an
+ // operator-visible fault on THIS node. The requester cannot
+ // see the reason, only whether it was transient, which is
+ // what keeps a routine refusal from charging its failure
+ // count.
+ //
+ // The slot survives ONLY a budget-exhausted round, which is
+ // a build that will resume; every other refusal abandons
+ // the build and must not keep the group admitted.
+ let building = matches!(
+ reason,
+ partitions::state_transfer::PartitionTransferUnavailable::OfferBuildInProgress { .. }
+ );
+ if !building {
+ self.partition_offer_builds
+ .borrow_mut()
+ .remove(&header.namespace);
+ }
+ let transient = reason.transient();
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ transient,
+ %reason,
+ "cannot serve partition state transfer; requester falls back"
+ );
+ let (view, commit_max) = serving_progress(partition);
+ self.send_state_transfer_target(
+ cluster,
+ self_id,
+ header.replica,
+ header.nonce,
+ header.namespace,
+ TransferDescriptor::unavailable(transient, view, commit_max),
+ )
+ .await;
+ return;
+ }
+ }
+ }
+ };
+ let Some(offer) = offer else {
+ return;
+ };
+ // Sampled AFTER any build: that build force-flushes and hashes a
+ // budgeted slice of the un-memoized segments (a first multi-GiB serve
+ // spans several rounds before an offer exists) while reading
+ // `commit_op` post-flush, so a pre-build sample could advertise a
+ // `commit_max` below the descriptor's own `commit_op` -- which only
+ // makes the receiver's gate refuse, and refusals feed a backoff.
+ let (view, commit_max) = serving_progress(partition);
+ self.send_state_transfer_target(
+ cluster,
+ self_id,
+ header.replica,
+ header.nonce,
+ header.namespace,
+ TransferDescriptor::available(&offer.manifest(), offer.commit_op, view, commit_max),
+ )
+ .await;
+ }
+
+ /// Serve one partition chunk. Segment payloads are loaded from disk on
+ /// demand into the shard-wide [`ServedSegmentCache`] (content-addressed,
+ /// so simultaneous rejoiners share one resident copy); a load or
+ /// re-verification failure (GC unlinked the file, bytes changed) evicts
+ /// the offer and tells the requester to restart with a fresh one --
+ /// which then reflects the current segment set, so the retry converges.
+ #[allow(
+ clippy::future_not_send,
+ clippy::cast_possible_truncation,
+ clippy::too_many_lines
+ )]
+ async fn on_partition_request_state_chunk(&self, msg: &Message<RequestStateChunkHeader>)
+ where
+ B: MessageBus,
+ {
+ // Pass 1 inside the borrow decides; a segment artifact that is not
+ // resident exits with its path and is loaded OUTSIDE the borrow (a
+ // RefCell borrow must not be held across an await), then pass 2
+ // stores and serves it.
+ enum ChunkAttempt {
+ Reply(Option<ChunkReply>),
+ Load {
+ log_path: String,
+ entry: consensus::StateArtifact,
+ },
+ }
+
+ let header = *msg.header();
+ // The requester id keys the offer map, whose bound is the replica count.
+ if !self.peer_is_known(header.replica, "RequestStateChunk") {
+ return;
+ }
+ let planes = self.plane.inner();
+ let Some(partition) = planes
+ .1
+ .0
+ .get_by_ns(&IggyNamespace::from_raw(header.namespace))
+ else {
+ return;
+ };
+ let cluster = partition.consensus().cluster();
+ let self_id = partition.consensus().replica();
+ let serving_view = partition.consensus().view();
+ let serving_commit_max = partition.consensus().commit_max();
+ let chunk_len_max = self.state_chunk_len_max();
+ let reply = loop {
+ let attempt = 'attempt: {
+ let mut offers = self.state_transfer_offers.borrow_mut();
+ let served = offers
+ .get_mut(&(header.namespace, header.replica))
+ .filter(|served| served.nonce == header.nonce);
+ let Some(served) = served else {
+ break 'attempt ChunkAttempt::Reply(Some(ChunkReply::Unavailable {
+ transient: true,
+ }));
+ };
+ let ServedOffer::Partition(offer) = &served.offer else {
+ break 'attempt ChunkAttempt::Reply(Some(ChunkReply::Unavailable {
+ transient: true,
+ }));
+ };
+ let last_artifact = offer.artifact_count().saturating_sub(1);
+ let artifact = header.artifact as usize;
+ // Keeps a segment payload alive past the cache borrow below.
+ let segment_payload: Rc<Vec<u8>>;
+ let artifact_bytes: &[u8] = match offer.artifact_at(artifact) {
+ Some(partitions::state_transfer::PartitionArtifactSource::Offsets(bytes)) => {
+ bytes
+ }
+ Some(partitions::state_transfer::PartitionArtifactSource::Segment(source)) => {
+ match self
+ .served_segment_cache
+ .borrow_mut()
+ .get(header.namespace, source.entry.checksum)
+ {
+ Some(payload) => {
+ segment_payload = payload;
+ &segment_payload
+ }
+ None => {
+ break 'attempt ChunkAttempt::Load {
+ log_path: source.log_path.clone(),
+ entry: source.entry,
+ };
+ }
+ }
+ }
+ // Index past the manifest: requester bug or stale frame.
+ None => break 'attempt ChunkAttempt::Reply(None),
+ };
+ let start = header.offset as usize;
+ // `start >= len` is the empty-chunk livelock refusal; see the
+ // metadata arm for the full story.
+ if start >= artifact_bytes.len() {
+ break 'attempt ChunkAttempt::Reply(None);
+ }
+ let end = start
+ .saturating_add((header.len as usize).min(chunk_len_max))
+ .min(artifact_bytes.len());
+ let Some(payload) = artifact_bytes.get(start..end) else {
+ break 'attempt ChunkAttempt::Reply(None);
+ };
+ if artifact == last_artifact && end >= artifact_bytes.len() && !served.fully_served
+ {
+ served.fully_served = true;
+ // Once per transfer, at the last byte of the last artifact.
+ // The descriptor log only proves a REQUEST arrived; this is
+ // the serving side's proof that the pull ran to completion.
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ "partition state transfer fully served"
+ );
+ }
+ 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);
+ ChunkAttempt::Reply(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 attempt {
+ ChunkAttempt::Reply(reply) => break reply,
+ ChunkAttempt::Load { log_path, entry } => {
+ // Chunked read + incremental hash: this runs on the pump to
+ // answer ONE 256 KiB chunk request, so a whole-file read
+ // plus a single hash pass over up to a segment would be one
+ // long uninterruptible CPU+IO block. The chunking keeps the
+ // REACTOR moving; this shard's consensus ticks are a sibling
+ // select arm of the same task and stay frozen either way.
+ let loaded = partitions::state_transfer::load_verified_segment_artifact(
+ &log_path, &entry,
+ )
+ .await;
+ let reason = match loaded {
+ Ok(bytes) => {
+ self.served_segment_cache.borrow_mut().insert(
+ header.namespace,
+ entry.checksum,
+ Rc::new(bytes),
+ self.served_segment_cache_bytes_max.get(),
+ );
+ continue;
+ }
+ Err(reason) => reason,
+ };
+ // The CAUSE decides what the requester is told: a racing GC
+ // or a stale offer is transient and costs it nothing, while
+ // an unreadable device is this node's fault and must charge,
+ // or a dying disk reads as a momentary blip forever.
+ let transient = reason.transient();
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ artifact = header.artifact,
+ path = %log_path,
+ transient,
+ %reason,
+ "cannot serve the requested segment; evicting the offer"
+ );
+ self.state_transfer_offers
+ .borrow_mut()
+ .remove(&(header.namespace, header.replica));
+ // The builder cache too: it is keyed by commit_op alone,
+ // and GC unlinks files WITHOUT a commit, so the restarted
+ // requester would otherwise be handed the same offer with
+ // the same dead path, forever.
+ partition.clear_state_transfer_offer_cache();
+ break Some(ChunkReply::Unavailable { transient });
+ }
+ }
+ };
+ match reply {
+ Some(ChunkReply::Chunk(chunk)) => {
+ let _ = self
+ .bus
+ .send_to_replica(header.replica, chunk.into_generic().into_frozen())
+ .await;
+ }
+ Some(ChunkReply::Unavailable { transient }) => {
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ transient,
+ "partition chunk request for an unknown offer; telling requester to restart"
+ );
+ self.send_state_transfer_target(
+ cluster,
+ self_id,
+ header.replica,
+ header.nonce,
+ header.namespace,
+ // Usually TRANSIENT -- retention GC'd a served segment, or
+ // the offer aged out between two chunks, and the restarted
+ // session converges -- but a load that failed on a local
+ // fault says so, or a dying disk would read as a momentary
+ // blip forever.
+ TransferDescriptor::unavailable(transient, serving_view, serving_commit_max),
+ )
+ .await;
+ }
+ None => {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ artifact = header.artifact,
+ offset = header.offset,
+ "partition chunk request out of artifact bounds; ignoring"
+ );
+ }
+ }
+ }
+
+ /// Sanity cap across a partition manifest. Segment artifacts spill to
+ /// disk as they complete, so this bounds corruption, not memory.
+ const PARTITION_TRANSFER_TOTAL_LEN_MAX: u64 = 1 << 40;
+
+ /// Alloc cap for the `CONSUMER_OFFSETS` artifact, which accumulates whole
+ /// in `ArtifactProgress::buf` before decode can reject it. Its decoder
+ /// ceilings imply ~24 MiB (two sections of 2^20 12-byte entries); this
+ /// leaves headroom without letting a hostile manifest stage gigabytes.
+ const CONSUMER_OFFSETS_ARTIFACT_LEN_MAX: u64 = 32 << 20;
+
+ /// Concurrent partition transfers this shard will run as a RECEIVER. A
+ /// whole-node rejoin arms one per lagging partition; unbounded, the sum
+ /// of in-flight buffers and staging writes is partitions x segment
+ /// size. Capped-out arms retry via the scheduled re-arm sweep.
+ const PARTITION_TRANSFERS_INFLIGHT_MAX: usize = 4;
+
+ /// Whether arming a transfer for `namespace` is even possible right now.
+ ///
+ /// Takes a SHARED borrow and drops it before returning, so a caller may form
+ /// its `&mut partition` afterwards. The point is to keep the in-flight scan
+ /// -- which borrows every partition on the shard -- off frames that cannot
+ /// arm anything: a namespace this shard does not own, and the ordinary case
+ /// of a group that is neither awaiting a transfer nor idle-with-no-re-arm.
+ fn may_arm_partition_transfer(partitions: &IggyPartitions<B, SB>, namespace_raw: u64) -> bool
+ where
+ B: MessageBus,
+ {
+ partitions
+ .get_by_ns(&IggyNamespace::from_raw(namespace_raw))
+ .is_some_and(|partition| {
+ partition.transfer.is_none()
+ && matches!(
+ partition.consensus().state_transfer_stage(),
+ consensus::StateTransferStage::AwaitingTarget
+ | consensus::StateTransferStage::Idle
+ )
+ })
+ }
+
+ /// Receiving-side transfers currently in flight on this shard.
+ ///
+ /// One scan per call, so callers hoist it: with per-partition groups a
+ /// per-namespace call inside the tick sweep is O(P^2) exactly during a
+ /// node-wide view change or rejoin, and capped arms reschedule on the flat
+ /// retry interval, so the losers stay phase-locked and the sweep repeats
+ /// every interval for the whole rejoin.
+ fn partition_transfers_inflight(&self) -> usize {
+ let partitions = self.plane.partitions();
+ let namespaces: Vec<_> = partitions.namespaces().copied().collect();
+ namespaces
+ .iter()
+ .filter(|namespace| {
+ partitions
+ .get_by_ns(namespace)
+ .is_some_and(|partition| partition.transfer.is_some())
+ })
+ .count()
+ }
+
+ /// Drop every trace of `namespace`'s current bytes from the serving side:
+ /// the partition's own offer cache, this shard's cached offers, and the
+ /// resident payloads behind them.
+ ///
+ /// Called wherever a partition's segments stop being the bytes an offer
+ /// describes -- retention cleaning, a committed truncate, a purge. None of
+ /// the caches can detect that themselves: the builder cache is keyed on
+ /// `commit_op` (which a metadata-plane truncate never moves), the shard's
+ /// offers on the requester, and the payloads on a checksum over the bytes
+ /// that just went away -- so a puller mid-transfer keeps receiving deleted
+ /// data and keeps both expiry clocks reset while doing it.
+ pub(crate) fn drop_partition_transfer_state(
+ &self,
+ namespace: IggyNamespace,
+ partition: &IggyPartition<B, SB>,
+ ) where
+ B: MessageBus,
+ {
+ partition.clear_state_transfer_offer_cache();
+ self.drop_served_state_for(namespace.inner());
+ }
+
+ fn drop_served_state_for(&self, namespace: u64) {
+ // Including the build slot: the bytes a partial checksum pass covered are
+ // gone with the chain, so the slot behind it is no longer resumable
+ // work and must stop counting against other namespaces' admission.
+ self.partition_offer_builds.borrow_mut().remove(&namespace);
+ self.state_transfer_offers
+ .borrow_mut()
+ .retain(|(served_namespace, _), _| *served_namespace != namespace);
+ self.served_segment_cache
+ .borrow_mut()
+ .evict_namespace(namespace);
+ }
+
+ /// Whether a peer-supplied source replica id names a replica of this
+ /// cluster.
+ ///
+ /// `header.replica` arrives unvalidated on every frame, and the partition
+ /// transfer paths turn it into ring arithmetic ([`next_transfer_peer`], where
+ /// id 255 panics in debug and wraps to replica 0 in release -- a silent
+ /// retarget) and into the served-offer map key, whose documented bound is the
+ /// replica count rather than 256 entries per served group. One check at the
+ /// frame's ingress closes both.
+ fn peer_is_known(&self, replica: u8, frame: &'static str) -> bool {
+ let replica_count = self.partition_consensus.replica_count;
+ if replica < replica_count {
+ return true;
+ }
+ tracing::warn!(
+ shard = self.id,
+ frame,
+ replica,
+ replica_count,
+ "dropping a partition frame whose source replica is outside this cluster"
+ );
+ false
+ }
+
+ /// Fence one partition for rebuild: quarantine its segment files, drop it
+ /// from routing, and queue the retirement the reconciler re-materialises
+ /// from committed metadata.
+ ///
+ /// Used wherever a partition is left without a serviceable segment chain (a
+ /// failed state-transfer install whose convergence also failed, a purge that
+ /// could not plant its replacement segment): the next append or poll would
+ /// panic on `active_segment()`'s expect.
+ ///
+ /// `IggyPartitions` mandates external removals go through `ConfirmRemove` --
+ /// a direct `remove()` would invalidate the `&mut` the caller still holds --
+ /// but the tombstone and the routing row drop SYNCHRONOUSLY here, because
+ /// the tombstone is the only gate in `get_mut_by_ns` and the queue does not
+ /// drain until the end of the pump iteration.
+ ///
+ /// `intended_frontier` is the offset frontier the caller knows the group is
+ /// at, for the paths where the LIVE counter is not it. A failed install
+ /// under an advancing purge generation leaves the counter at the pre-purge
+ /// value while the group restarted its offset space lower, and the
+ /// advancing write would stamp that stale counter over the reset the
+ /// install just made, then quarantine the segments that would have
+ /// contradicted it. `None` where the counter is authoritative.
+ #[allow(clippy::future_not_send)]
+ async fn fence_partition_for_rebuild(
+ &self,
+ namespace: IggyNamespace,
+ partition: &IggyPartition<B, SB>,
+ intended_frontier: Option<u64>,
+ ) where
+ B: MessageBus + 'static,
+ T: ShardsTable,
+ {
+ // BEFORE the quarantine: it moves away the segments that are this
+ // partition's only other witness to the offset frontier, and the
+ // rebuild's sole anchor is then the durable record.
+ // Ungated by the write backoff on purpose: this is a one-shot write
+ // ahead of an irreversible quarantine, not a retry loop, so a skipped
+ // attempt is the last chance gone rather than deferred work.
+ let recorded = partition
+ .record_frontier_before_quarantine(intended_frontier)
+ .await;
+ if !recorded {
+ tracing::error!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ intended_frontier,
+ "could not record the fenced partition's offset frontier before quarantining \
+ its segments; the rebuild will re-seed from whatever the record still holds"
+ );
+ }
+ match partition.quarantine_partition_dir().await {
+ Ok(Some(fenced_dir)) => tracing::error!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ fenced_dir,
+ "quarantined the fenced partition's segment files; they are kept for \
+ inspection and never read again"
+ ),
+ Ok(None) => {}
+ Err(error) => {
+ // NO rebuild: `build_partition_fresh` plants segment 0 with
+ // `file_exists = false`, truncating whatever the failed
+ // quarantine left, so a rebuild here eats the chain one segment
+ // per attempt. Tombstone and stop -- the bytes stay for an
+ // operator, and the boot path makes the same call. The
+ // partition stays unreachable until it is dealt with; that is
+ // the intended fence, not a wait.
+ tracing::error!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ %error,
+ "failed to quarantine the fenced partition's segment files; leaving it \
+ tombstoned rather than rebuilding over them"
+ );
+ self.plane.partitions().tombstone(namespace);
+ self.shards_table.remove(&namespace);
+ return;
+ }
+ }
+ self.plane.partitions().tombstone(namespace);
+ self.shards_table.remove(&namespace);
+ self.enqueue_reconcile_op(ReconcileOp::ConfirmRemove { namespace });
+ self.signal_reconcile_wake();
+ }
+
+ /// Arm a fresh partition transfer session against `peer` and request its
+ /// descriptor. Every partition arming site goes through here. Drops any
+ /// repair session (transfer supersedes repair; a transfer-unavailable
+ /// fallback re-arms repair fresh) and leaves the stage to its callers (they
+ /// own the `AwaitingTarget` transition).
+ ///
+ /// Refuses past [`Self::PARTITION_TRANSFERS_INFLIGHT_MAX`]: the arm
+ /// converts to a scheduled re-arm (no failure charged -- the local slot
+ /// shortage is not the peer's fault) and the stage returns to Idle so
+ /// journal repair keeps the gap visible meanwhile.
+ ///
+ /// `transfers_inflight` is computed by the caller BEFORE it formed its
+ /// `&mut partition`: the counting scan takes shared borrows of every
+ /// partition, and deriving a sibling `&` to the element the caller's
+ /// protected `&mut` points at is UB under both stacked and tree borrows,
+ /// however innocuous the generated code is today. Returns whether a session
+ /// was armed, so a caller sweeping many groups can carry the count forward
+ /// instead of re-scanning per group.
+ #[allow(clippy::future_not_send)]
+ async fn arm_partition_transfer(
+ &self,
+ partition: &mut IggyPartition<B, SB>,
+ peer: u8,
+ transfers_inflight: usize,
+ ) -> bool
+ where
+ B: MessageBus,
+ {
+ if partition.transfer.is_none()
+ && transfers_inflight >= Self::PARTITION_TRANSFERS_INFLIGHT_MAX
+ {
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = partition.consensus().namespace(),
+ cap = Self::PARTITION_TRANSFERS_INFLIGHT_MAX,
+ "partition transfer slots exhausted; deferring this arm"
+ );
+ let consensus = partition.consensus();
+ if consensus.state_transfer_stage() != consensus::StateTransferStage::Idle {
+ consensus.set_state_transfer_stage(consensus::StateTransferStage::Idle);
+ }
+ partition.note_transfer_rearm_scheduled();
+ partition.transfer_rearm = Some(partitions::state_transfer::PendingTransferRearm {
+ peer,
+ after_ticks: self.repair_retry_ticks.get(),
+ });
+ return false;
+ }
+ partition.repair = None;
+ let nonce = iggy_common::random_id::get_uuid();
+ let armed = partition.transfer.is_none();
+ partition.transfer = Some(partitions::state_transfer::PartitionTransferSession {
+ nonce,
+ peer,
+ commit_op: 0,
+ artifacts: Vec::new(),
+ target_accepted: false,
+ idle_ticks: 0,
+ });
+ self.send_request_state_transfer(partition.consensus(), peer, nonce)
+ .await;
+ armed
+ }
+
+ /// Arm partition journal repair when this replica is lagging its group
+ /// and nothing else is recovering it. The idempotence guards mirror
+ /// `maybe_request_metadata_repair`: no-op unless Normal, not
+ /// transferring, behind the frontier, and no session live.
+ #[allow(clippy::future_not_send)]
+ async fn maybe_request_partition_repair(&self, partition: &mut IggyPartition<B, SB>, peer: u8)
+ where
+ B: MessageBus,
+ {
+ let consensus = partition.consensus();
+ if !consensus.is_normal()
+ || consensus.is_transferring()
+ || consensus.commit_min() >= consensus.commit_max()
+ || partition.repair.is_some()
+ {
+ return;
+ }
+ let nonce = iggy_common::random_id::get_uuid();
+ let from_op = consensus.commit_min() + 1;
+ let to_op = consensus.commit_max();
+ let cluster = consensus.cluster();
+ let self_id = consensus.replica();
+ let namespace = consensus.namespace();
+ partition.repair = Some(partitions::RepairSession {
+ nonce,
+ to_op,
+ floor: None,
+ peer,
+ first_batch_offset: None,
+ idle_ticks: 0,
+ });
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = namespace,
+ from_op,
+ to_op,
+ "partition behind the group frontier; requesting repair"
+ );
+ self.send_request_prepares(cluster, self_id, peer, nonce, from_op, to_op, namespace)
+ .await;
+ }
+
+ /// Receiver side of a partition descriptor: accept the manifest, adopt
+ /// any reusable staged segments from an earlier attempt, and start
+ /// pulling, or fall back to journal repair when the peer cannot serve.
+ #[allow(clippy::future_not_send, clippy::too_many_lines)]
+ async fn on_partition_state_transfer_target(&self, msg: &Message<StateTransferTargetHeader>)
+ where
+ B: MessageBus + 'static,
+ T: ShardsTable,
+ M: StreamsFrontend,
+ {
+ let header = *msg.header();
+ // The peer id reaches `next_transfer_peer`'s ring arithmetic through the
+ // re-arm below, so it is validated before anything uses it.
+ if !self.peer_is_known(header.replica, "StateTransferTarget") {
+ return;
+ }
+ let planes = self.plane.inner();
+ let Some(partition) = planes
+ .1
+ .0
+ .get_mut_by_ns(&IggyNamespace::from_raw(header.namespace))
+ else {
+ return;
+ };
+ let session_matches = partition
+ .transfer
+ .as_ref()
+ .is_some_and(|session| session.nonce == header.nonce);
+ if !session_matches {
+ return;
+ }
+ if header.available == 0 {
+ // A refusal the peer marked transient (it is momentarily not the
+ // caught-up primary, which `is_caught_up_primary` makes frequent
+ // under produce load) must not charge the consecutive-failure count:
+ // that count is reset only by a completed install, so ten routine
+ // refusals pin the re-arm backoff at its 1024x ceiling while nothing
+ // else recovers the partition -- repair keeps hitting the refused
+ // floor and will not arm while a re-arm is pending. A hard refusal
+ // (unreadable segment, failed flush) still charges.
+ let transient = header.unavailable_transient == 1;
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ peer = header.replica,
+ transient,
+ "partition transfer peer cannot serve; backing off before re-arming"
+ );
+ if transient {
+ // The peer that refused is the node that would otherwise serve,
+ // and on the partition arm only a caught-up primary can. Keep
+ // asking it unless it is not the primary this replica knows: a
+ // rotation spends the next round on a backup that can only
+ // refuse, and the serving side's partial offer-build progress
+ // is memoized per node, so that round advances no hashing.
+ let primary = {
+ let consensus = partition.consensus();
+ consensus.primary_index(consensus.view())
+ };
+ self.rearm_partition_transfer_after_refusal(
+ partition,
+ header.replica,
+ header.replica != primary,
+ )
+ .await;
+ } else {
+ self.abandon_or_rearm_partition_transfer(partition, header.replica)
+ .await;
+ }
+ return;
+ }
+ // The serving replica's own progress, carried by every descriptor: an
+ // offer from a replica that knows LESS than this one does is the phantom
+ // view-0 primary signature (a group whose directory vanished boots
+ // `init()`, comes up Normal at view 0, and an empty log is trivially
+ // caught up). Installing it would unlink a chain this replica already
+ // holds; nonce match alone cannot tell the two apart.
+ let local_view = partition.consensus().view();
+ let local_commit_max = partition.consensus().commit_max();
+ // `commit_op` past the sender's OWN `commit_max` is self-contradictory:
+ // the offer cannot be built past the frontier its builder had. Nothing
+ // downstream bounds it above -- the install only refuses values BELOW
+ // the local floor, and the offsets-artifact cross-check compares two
+ // numbers the same peer chose -- so without this a peer offering
+ // `commit_op = u64::MAX` drives this replica's commit floor, sequencer
+ // and `commit_max` there and it reports itself fully committed.
+ if header.commit_op > header.commit_max {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ peer = header.replica,
+ serving_commit_op = header.commit_op,
+ serving_commit_max = header.commit_max,
+ "refusing a partition transfer offer whose commit_op exceeds the sender's \
+ own commit frontier"
+ );
+ self.abandon_or_rearm_partition_transfer(partition, header.replica)
+ .await;
+ return;
+ }
+ if header.view < local_view || header.commit_max < local_commit_max {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ peer = header.replica,
+ serving_view = header.view,
+ serving_commit_max = header.commit_max,
+ local_view,
+ local_commit_max,
+ "refusing a partition transfer offer from a replica behind this one"
+ );
+ // ALWAYS rotate: this refusal is evidence about the peer, not about
+ // its timing, so re-asking it is the one thing that cannot help.
+ self.rearm_partition_transfer_after_refusal(partition, header.replica, true)
+ .await;
+ return;
+ }
+ let manifest_bytes =
+ &msg.as_slice()[size_of::<StateTransferTargetHeader>()..header.size as usize];
+ let entries = match consensus::decode_state_manifest(manifest_bytes) {
+ Ok(entries) => entries,
+ Err(error) => {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ %error,
+ "partition transfer manifest rejected"
+ );
+ return;
+ }
+ };
+ // Saturating: 65k entries of hostile lengths must refuse, not
+ // overflow-panic the debug-build sum before the cap check fires.
+ let total_len = entries
+ .iter()
+ .fold(0u64, |total, entry| total.saturating_add(entry.len));
+ // Per-KIND ceilings: only SEGMENT_LOG artifacts spill to disk as
+ // they complete, so anything else accumulates whole in memory and
+ // must be bounded by what its decoder could ever accept, not by the
+ // segment cap. An unknown kind is refused here rather than pulled:
+ // the install cannot represent it anyway.
+ let kind_capped = entries.iter().all(|entry| match entry.kind {
+ consensus::artifact_kind::SEGMENT_LOG => {
+ entry.len <= self.partition_artifact_len_max.get()
+ }
+ consensus::artifact_kind::CONSUMER_OFFSETS => {
+ entry.len <= Self::CONSUMER_OFFSETS_ARTIFACT_LEN_MAX
+ }
+ _ => false,
+ });
+ if !kind_capped || total_len > Self::PARTITION_TRANSFER_TOTAL_LEN_MAX {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ total_len,
+ "partition transfer manifest exceeds artifact caps; refusing descriptor"
+ );
+ return;
+ }
+ if partition
+ .transfer
+ .as_ref()
+ .is_some_and(|session| session.target_accepted)
+ {
+ // A crossed stall-retry descriptor: first-wins already served the
+ // same offer, and re-accepting would discard in-flight progress
+ // and re-run the staging scan for nothing.
+ return;
+ }
+ let reused = partition.reuse_staged_segments(&entries).await;
+ if !reused.is_empty() {
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ peer = header.replica,
+ adopted = reused.len(),
+ artifacts = entries.len(),
+ "adopted staged segments from an earlier transfer attempt"
+ );
+ }
+ // Re-check the nonce AFTER the await: the staging scan yields, and a
+ // session re-minted underneath it must not get stamped with this
+ // (now stale) offer's commit_op and manifest.
+ let Some(session) = partition
+ .transfer
+ .as_mut()
+ .filter(|session| session.nonce == header.nonce)
+ else {
+ return;
+ };
+ session.target_accepted = true;
+ session.commit_op = header.commit_op;
+ // No reservation here: the manifest's total is bounded only by
+ // `PARTITION_TRANSFER_TOTAL_LEN_MAX` (1 TiB), so reserving every
+ // artifact up front is an eager address-space commit of the whole
+ // manifest -- times the in-flight cap -- which turns fatal under strict
+ // overcommit, `RLIMIT_AS`, or cgroup accounting, and contradicts the
+ // session's own promise to bound receiver memory to ONE in-flight
+ // artifact. Artifacts adopted by the reuse scan below would also be
+ // reserved and then overwritten with `Staged`, making the retry path's
+ // reservation pure waste. `append_chunk` reserves the declared length on
+ // an artifact's FIRST chunk instead, and only ever for the artifact the
+ // cursor is actually pulling.
+ session.artifacts = entries
+ .iter()
+ .map(|&entry| {
+ TransferArtifact::Pending(consensus::ArtifactProgress {
+ entry,
+ buf: Vec::new(),
+ })
+ })
+ .collect();
+ session.idle_ticks = 0;
+ for (index, meta) in reused {
+ session.artifacts[index as usize] = TransferArtifact::Staged(meta);
+ }
+ let consensus = partition.consensus();
+ if consensus.state_transfer_stage() == consensus::StateTransferStage::AwaitingTarget {
+ consensus.set_state_transfer_stage(consensus::StateTransferStage::Fetching);
+ }
+ self.on_partition_transfer_progress(header.namespace).await;
+ }
+
+ /// Receive one partition chunk; spill a completed segment artifact, and
+ /// on the last artifact verify + install + hand the tail to repair.
+ #[allow(clippy::future_not_send)]
+ async fn on_partition_state_chunk(&self, msg: &Message<StateChunkHeader>)
+ where
+ B: MessageBus + 'static,
+ T: ShardsTable,
+ M: StreamsFrontend,
+ {
+ let header = *msg.header();
+ let planes = self.plane.inner();
+ let Some(partition) = planes
+ .1
+ .0
+ .get_mut_by_ns(&IggyNamespace::from_raw(header.namespace))
+ else {
+ return;
+ };
+ {
+ let Some(session) = partition.transfer.as_mut() else {
+ return;
+ };
+ if session.nonce != header.nonce || !session.target_accepted {
+ return;
+ }
+ // The sender too, not the nonce alone. The other three
+ // partition-transfer handlers all validate theirs; this one
+ // authenticated payload bytes by a 128-bit capability only, which
+ // is thin but real once a peer has seen one frame -- a rotated-away
+ // peer still holds the nonce until the session is re-minted. Its
+ // own `if` because folded into the condition above, clippy's
+ // `suspicious_operation_groupings` reads the operand asymmetry as a
+ // typo and proposes a `header.peer` that does not exist.
+ if session.peer != header.replica {
+ return;
+ }
+ let payload = &msg.as_slice()[size_of::<StateChunkHeader>()..header.size as usize];
+ if !consensus::append_chunk(
+ &mut session.artifacts,
+ header.artifact,
+ header.offset,
+ payload,
+ ) {
+ return;
+ }
+ session.idle_ticks = 0;
+ }
+ partition.note_transfer_progress();
+ self.on_partition_transfer_progress(header.namespace).await;
+ }
+
+ /// Drive an in-flight partition transfer: spill newly completed segment
+ /// artifacts, request the next missing chunk, or -- with everything
+ /// complete -- install and hand the tail to journal repair.
+ #[allow(clippy::future_not_send, clippy::too_many_lines)]
+ async fn on_partition_transfer_progress(&self, namespace: u64)
+ where
+ B: MessageBus + 'static,
+ T: ShardsTable,
+ M: StreamsFrontend,
+ {
+ let planes = self.plane.inner();
+ let config = planes.1.0.config().clone();
+ let target_namespace = IggyNamespace::from_raw(namespace);
+ let Some(partition) = planes.1.0.get_mut_by_ns(&target_namespace) else {
+ return;
+ };
+ // Stage/session desync bail: the probe-exhausted election fallback in
+ // core/consensus clears the stage without being able to reach this
+ // session; completing into an illegal Idle -> Installing transition
+ // would assert. Detect the out-from-under abandon and drop the
+ // session here (staging files are KEPT for reuse).
+ if partition.consensus().state_transfer_stage() != consensus::StateTransferStage::Fetching {
+ if partition.transfer.is_some() {
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = namespace,
+ "partition state transfer was abandoned out from under its session; dropping it"
+ );
+ partition.transfer = None;
+ }
+ return;
+ }
+ let Some(session) = partition.transfer.as_ref() else {
+ return;
+ };
+ if !session.target_accepted {
+ return;
+ }
+
+ // Spill any segment artifact that just completed, freeing its buffer.
+ let spill_candidate = session
+ .artifacts
+ .iter()
+ .enumerate()
+ .find_map(|(index, artifact)| {
+ artifact
+ .pending()
+ .is_some_and(|progress| {
+ progress.entry.kind == consensus::artifact_kind::SEGMENT_LOG
+ && progress.complete()
+ })
+ .then_some(index)
+ });
+ if let Some(index) = spill_candidate {
+ let (entry, bytes, peer, nonce) = {
+ let Some(session) = partition.transfer.as_mut() else {
+ return;
+ };
+ let Some(progress) = session.artifacts[index].pending_mut() else {
+ return;
+ };
+ let bytes = std::mem::take(&mut progress.buf);
+ (progress.entry, bytes, session.peer, session.nonce)
+ };
+ match partition.spill_transfer_segment(&entry, bytes).await {
+ Ok(meta) => {
+ // Nonce re-check across the spill await: a session
+ // re-minted underneath it has a fresh (possibly empty)
+ // artifact vec, and the stale index would panic. Pump-
+ // serial today, but nothing enforces that.
+ if let Some(session) = partition
+ .transfer
+ .as_mut()
+ .filter(|session| session.nonce == nonce)
+ {
+ session.artifacts[index] = TransferArtifact::Staged(meta);
+ }
+ }
+ Err(reason) => {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace,
+ artifact = index,
+ %reason,
+ "partition transfer segment failed validation at spill"
+ );
+ self.abandon_or_rearm_partition_transfer(partition, peer)
+ .await;
+ return;
+ }
+ }
+ // Tail-call for the next candidate / chunk request.
+ return Box::pin(self.on_partition_transfer_progress(namespace)).await;
+ }
+
+ let Some(session) = partition.transfer.as_ref() else {
+ return;
+ };
+ let all_done = session.artifacts.iter().all(ChunkProgress::complete);
+ if !all_done {
+ self.request_pending_partition_chunk(namespace).await;
+ return;
+ }
+
+ // Everything present: verify + decode the offsets artifact, install.
+ let Some(session) = partition.transfer.take() else {
+ return;
+ };
+ // `commit_op`, NOT a "generation": in this file that word means the
+ // committed PURGE generation, and the callee's parameter is `commit_op`.
+ let commit_op = session.commit_op;
+ let peer = session.peer;
+ let mut offsets_bytes: Option<Vec<u8>> = None;
+ let mut offsets_frontier: Option<u64> = None;
+ let mut damaged = false;
+ let mut staged = Vec::new();
+ for artifact in session.artifacts {
+ let progress = match artifact {
+ TransferArtifact::Staged(meta) => {
+ staged.push(meta);
+ continue;
+ }
+ TransferArtifact::Pending(progress) => progress,
+ };
+ match progress.entry.kind {
+ consensus::artifact_kind::CONSUMER_OFFSETS
+ if consensus::verify_state_artifact(&progress.entry, &progress.buf) =>
+ {
+ // Exactly one offsets table per manifest; a second one is
+ // a peer bug and is refused, never last-wins.
+ if offsets_bytes.is_some() {
+ damaged = true;
+ } else {
+ offsets_frontier = Some(progress.entry.frontier);
+ offsets_bytes = Some(progress.buf);
+ }
+ }
+ // An unknown kind is refused, never skipped: skipping would
+ // install a state this build cannot fully represent.
+ _ => damaged = true,
+ }
+ }
+ // Free self-consistency check on a durable input: the builder sets the
+ // descriptor's `commit_op` and the offsets artifact's frontier from ONE
+ // binding, and `commit_op` goes on to drive `set_commit_floor`,
+ // `set_sequence`, `advance_commit_max` and the reported
+ // `applied_commit_op`, while nothing else ever reads that frontier back.
+ if let Some(frontier) = offsets_frontier
+ && frontier != commit_op
+ {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace,
+ commit_op,
+ offsets_frontier = frontier,
+ "descriptor commit_op disagrees with its offsets artifact frontier; refusing the install"
+ );
+ damaged = true;
+ }
+ let Some(offsets_bytes) = offsets_bytes.filter(|_| !damaged) else {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace,
+ "partition transfer artifacts failed verification; refusing install"
+ );
+ self.abandon_or_rearm_partition_transfer(partition, peer)
+ .await;
+ return;
+ };
+ // A peer that has NOT yet applied a committed purge offers pre-purge
+ // segments under the stale generation. The install's own generation
+ // handling only widens permission (`max`), so it would resurrect the
+ // purged data durably: the local applied value stays at the newer
+ // generation, and the reconciler's `committed > applied` gate never
+ // re-fires. Compared against the METADATA plane's committed value, not
+ // this partition's applied one -- the latter is memory-only and reads 0
+ // after every restart. Routed through the ordinary failure arm, which
+ // rotates the peer; worst case is one wasted pull.
+ let committed_purge_generation = self
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .partition_purge_generation(
+ target_namespace.stream_id(),
+ target_namespace.topic_id(),
+ target_namespace.partition_id(),
+ );
+ let offered_purge_generation =
+ partitions::state_transfer::offered_purge_generation(&offsets_bytes);
+ if offered_purge_generation < committed_purge_generation {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace,
+ peer,
+ offered_purge_generation,
+ committed_purge_generation,
+ "refusing a partition transfer offer built before a committed purge; installing it would resurrect purged data"
+ );
+ self.abandon_or_rearm_partition_transfer(partition, peer)
+ .await;
+ return;
+ }
+ partition
+ .consensus()
+ .set_state_transfer_stage(consensus::StateTransferStage::Installing);
+ let outcome = partition
+ .install_state_transfer(
+ &config,
+ commit_op,
+ staged,
+ &offsets_bytes,
+ committed_purge_generation,
+ )
+ .await;
+ partition
+ .consensus()
+ .set_state_transfer_stage(consensus::StateTransferStage::Idle);
+ match outcome {
+ Ok(outcome) => {
+ partition.note_transfer_progress();
+ partition.note_transfer_installed();
+ partition.transfer_rearm = None;
+ if outcome.offsets_written {
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = namespace,
+ applied_commit_op = outcome.applied_commit_op,
+ "partition state transfer installed; handing tail to journal repair"
+ );
+ } else {
+ // Deliberately NOT prefixed with the success line's text:
+ // specs match log substrings, and a shared prefix would
+ // let them pass on the degraded path.
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace,
+ applied_commit_op = outcome.applied_commit_op,
+ "partition state transfer landed WITHOUT fully written consumer \
+ offsets; the next offset commit rewrites the files"
+ );
+ }
+ partition.commit_journal(&config).await;
+ self.maybe_request_partition_repair(partition, peer).await;
+ }
+ Err(
+ error @ partitions::state_transfer::PartitionInstallError::ConvergeFailed {
+ frontier,
+ ..
+ },
+ ) => {
+ // The partition holds no serviceable segment chain and its
+ // next append or poll would panic the shard. Fence exactly
+ // this group (a failed converge sweep can leave strays that
+ // `build_partition_fresh` would never clear and the boot
+ // contiguity guard would then trip on). The reconciler
+ // re-materialises a fresh partition from committed metadata;
+ // its first repair floor refusal re-arms a transfer, which
+ // re-seeds the offset frontier from the offsets artifact.
+ tracing::error!(
+ shard = self.id,
+ namespace_raw = namespace,
+ %error,
+ "partition unserviceable after failed install; fencing it for rebuild"
+ );
+ // Served state first, as the purge fence does: the quarantine
+ // below moves the chain those offers and cached payloads
+ // describe into `.fenced.N`, and a requester holding one would
+ // otherwise pull bytes that no longer exist.
+ self.drop_partition_transfer_state(IggyNamespace::from_raw(namespace), partition);
+ self.fence_partition_for_rebuild(
+ IggyNamespace::from_raw(namespace),
+ partition,
+ Some(frontier),
+ )
+ .await;
+ }
+ Err(error) => {
+ tracing::error!(
+ shard = self.id,
+ namespace_raw = namespace,
+ %error,
+ "partition state transfer install failed; falling back to journal repair"
+ );
+ self.abandon_or_rearm_partition_transfer(partition, peer)
+ .await;
+ }
+ }
+ }
+
+ /// Charge one transfer failure and schedule a backed-off re-arm against
+ /// the NEXT peer in the ring. Immediate same-peer retries were a
+ /// failure amplifier: a deterministic local failure (ENOSPC, an
+ /// undecodable artifact) re-ran the full pull -- including the serving
+ /// primary's whole-segment reads -- at network round-trip rate, and the
+ /// generation-keyed budget never exhausted on a committing cluster.
+ /// Journal repair is re-armed in the meantime so the gap stays visible
+ /// and anything repairable heals without waiting out the backoff.
+ #[allow(clippy::future_not_send)]
+ async fn abandon_or_rearm_partition_transfer(
+ &self,
+ partition: &mut IggyPartition<B, SB>,
+ peer: u8,
+ ) where
+ B: MessageBus,
+ {
+ let failures = partition.record_transfer_failure();
+ let after_ticks = transfer_rearm_backoff(self.repair_retry_ticks.get(), failures);
+ self.schedule_partition_transfer_rearm(partition, peer, failures, after_ticks, true)
+ .await;
+ }
+
+ /// Re-arm after a refusal the serving peer marked TRANSIENT: schedule the
+ /// next attempt on a flat interval and charge nothing.
+ ///
+ /// "The peer is momentarily not the caught-up primary" is the common case
+ /// under produce load, and `transfer_failures` is reset only by a completed
+ /// install, so charging it turns a transient into a stall measured in re-arm
+ /// ceilings: nothing else recovers the partition meanwhile, since repair
+ /// keeps hitting the refused floor and will not arm while a re-arm is
+ /// pending.
+ ///
+ /// `rotate` belongs to the CALLER because the two refusal sites mean
+ /// opposite things by it. A peer saying "not right now" is the node that
+ /// would otherwise serve, so staying on it is right. This replica refusing
+ /// a descriptor from a peer that knows LESS than it does is the one case
+ /// where the peer is provably the wrong one, and rotating is the whole
+ /// remedy: a restarted primary comes back at `commit_max = 0` (the
+ /// partition journal is memory-only), so a rejoining backup would otherwise
+ /// pin itself to it at a flat interval until the group's next election.
+ #[allow(clippy::future_not_send)]
+ async fn rearm_partition_transfer_after_refusal(
+ &self,
+ partition: &mut IggyPartition<B, SB>,
+ peer: u8,
+ rotate: bool,
+ ) where
+ B: MessageBus,
+ {
+ // Deliberately NOT `transfer_rearm_backoff`: a flat interval, so a peer
+ // that spends a minute catching up costs a minute of retries rather than
+ // a climb to the 1024x ceiling.
+ let after_ticks = self.repair_retry_ticks.get();
+ // The flat interval means a partition can sit here for hours without
+ // charging anything, so the ONLY operator signal is this count: it
+ // escalates the log level and feeds a metric, and it never touches the
+ // backoff.
+ let refusals = partition.record_transfer_refusal();
+ self.metrics.record_partition_transfer_refusal();
+ if refusals >= TRANSFER_REFUSALS_BEFORE_ESCALATION
+ && refusals.is_multiple_of(TRANSFER_REFUSALS_BEFORE_ESCALATION)
+ {
+ // Deliberately not phrased as "not rejoining": a serving primary
+ // building a large offer refuses one round per budget slice, so a
+ // healthy multi-GiB rejoin reaches this count while progressing
+ // normally. The descriptor carries no reason code, so this side
+ // cannot tell the two apart; the serving node's own logs can.
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = partition.consensus().namespace(),
+ peer,
+ refusals,
+ "partition state transfer has been refused {refusals} times in a row; the peer \
+ may be building a large offer or rate-limiting concurrent transfers, or it may \
+ be unable to serve at all -- check its logs before intervening"
+ );
+ }
+ self.schedule_partition_transfer_rearm(partition, peer, 0, after_ticks, rotate)
+ .await;
+ }
+
+ /// Drop the session, pick the next peer, and schedule the re-arm; shared by
+ /// the charged and uncharged paths.
+ ///
+ /// `rotate` is false where the refusing peer is the only one that could
+ /// have served: only a caught-up primary passes `is_caught_up_primary`, so
+ /// rotating off it asks a backup that can answer nothing but another
+ /// refusal, and the serving side's partial offer-build progress is memoized
+ /// PER NODE, so the round spent on the backup also advances no hashing.
+ #[allow(clippy::future_not_send)]
+ async fn schedule_partition_transfer_rearm(
+ &self,
+ partition: &mut IggyPartition<B, SB>,
+ peer: u8,
+ failures: u32,
+ after_ticks: u32,
+ rotate: bool,
+ ) where
+ B: MessageBus,
+ {
+ partition.transfer = None;
+ let consensus = partition.consensus();
+ if consensus.state_transfer_stage() != consensus::StateTransferStage::Idle {
+ consensus.set_state_transfer_stage(consensus::StateTransferStage::Idle);
+ }
+ let next_peer = if rotate {
+ next_transfer_peer(
+ consensus.replica(),
+ peer,
+ consensus.replica_count(),
+ consensus.primary_index(consensus.view()),
+ )
+ } else {
+ peer
+ };
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = partition.consensus().namespace(),
+ failures,
+ next_peer,
+ after_ticks,
+ "partition transfer did not land; scheduling a re-arm"
+ );
+ // The stall budget belongs to ONE attempt: carried across, an exhausted
+ // count left every later session a single retry-interval window to land
+ // its first response, against a backoff climbing to 1024x. Livelock
+ // across attempts is bounded by `transfer_failures` and that backoff.
+ partition.note_transfer_rearm_scheduled();
+ partition.transfer_rearm = Some(partitions::state_transfer::PendingTransferRearm {
+ peer: next_peer,
+ after_ticks,
+ });
+ let config = self.plane.partitions().config().clone();
+ partition.commit_journal(&config).await;
+ self.maybe_request_partition_repair(partition, peer).await;
+ }
+
+ /// Ask for the next missing partition chunk (first unspilled, incomplete
+ /// artifact in manifest order).
+ ///
+ /// LOCKSTEP by design: one chunk in flight, re-driven per reply, so transfer
+ /// throughput is `state_chunk_len_max / RTT` -- roughly 26 MB/s at a 10 ms
+ /// link, about 41 s for a 1 GiB segment. `state_chunk_len_max` only clamps
+ /// downward, so no operator knob raises that ceiling; it is worth knowing
+ /// when sizing `segment.size` and retention, since rejoin time scales with
+ /// retained bytes per partition. A small in-flight window would lift it, but
+ /// it has to grow `[partition] transfer_served_cache_bytes_max` in step -- that
+ /// budget is sized for exactly the concurrent lockstep pulls the in-flight
+ /// cap allows.
+ #[allow(clippy::future_not_send)]
+ async fn request_pending_partition_chunk(&self, namespace: u64)
+ where
+ B: MessageBus,
+ {
+ let planes = self.plane.inner();
+ let chunk_len_max = self.state_chunk_len_max() as u64;
+ let Some(partition) = planes
+ .1
+ .0
+ .get_mut_by_ns(&IggyNamespace::from_raw(namespace))
+ else {
+ return;
+ };
+ let request = partition.transfer.as_ref().and_then(|session| {
+ if !session.target_accepted {
+ return None;
+ }
+ let (index, offset, len) =
+ consensus::next_pending_chunk(&session.artifacts, chunk_len_max)?;
+ Some((session.nonce, session.peer, index, offset, len))
+ });
+ let consensus_ids = {
+ let consensus = partition.consensus();
+ (consensus.cluster(), consensus.replica())
+ };
+ if let Some((nonce, peer, artifact, offset, len)) = request {
+ self.send_request_state_chunk(
+ consensus_ids.0,
+ consensus_ids.1,
+ peer,
+ nonce,
+ namespace,
+ artifact,
+ offset,
+ len,
+ )
+ .await;
+ }
+ }
+
/// Drop serving-side state-transfer offers that stopped being pulled.
///
- /// Each offer owns a whole snapshot plus the encoded client table, and the
- /// protocol has no completion frame (a receiver installs and goes quiet), so
- /// without this a primary that ever served a transfer pins that memory for
- /// the rest of the process. Generous relative to the chunk cadence: a live
- /// puller resets the counter on every chunk it fetches, so only an abandoned
- /// or finished transfer ages out.
+ /// An offer pins its plane's payload for as long as it lives -- the metadata
+ /// snapshot plus the encoded client table, or a partition manifest and the
+ /// resident segment payloads behind it -- and the protocol has no completion
+ /// frame (a receiver installs and goes quiet), so without this a primary
+ /// that ever served a transfer holds that memory for the rest of the
+ /// process. Generous relative to the chunk cadence: a live puller resets the
+ /// counter on every chunk it fetches, so only an abandoned or finished
+ /// transfer ages out.
fn expire_idle_state_transfer_offers(&self) {
+ // Same clock the offers below age on: `retry_ticks * MULTIPLE` ticks,
+ // and this sweep runs once per tick.
+ let payload_idle_sweeps = u64::from(self.repair_retry_ticks.get().max(1))
+ * u64::from(STATE_TRANSFER_OFFER_EXPIRY_MULTIPLE);
+ self.served_segment_cache
+ .borrow_mut()
+ .expire_idle(payload_idle_sweeps);
// `max(1)`: the retry interval is operator-configurable, and a zero would
// make the expiry zero, dropping every offer on the tick after it was
// 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| {
+ // A build slot is released by the round that completes the offer, so a
+ // requester that walked away mid-build would otherwise hold admission
+ // forever. Same idle window as an abandoned offer.
+ self.partition_offer_builds
+ .borrow_mut()
+ .retain(|namespace, idle_ticks| {
+ *idle_ticks += 1;
+ let live = *idle_ticks < idle_expiry_ticks;
+ if !live {
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace,
+ "dropping an abandoned partition offer build slot"
+ );
+ }
+ live
+ });
+ let mut offers = self.state_transfer_offers.borrow_mut();
+ let namespaces_before: Vec<u64> = offers.keys().map(|(namespace, _)| *namespace).collect();
+ offers.retain(|(namespace, requester), served| {
served.idle_ticks += 1;
// A fully-served offer only has to outlive a re-request of its last
// chunk, so it goes on the short clock; anything else is an
@@ -4866,6 +7225,7 @@
if !live {
tracing::debug!(
shard = self.id,
+ namespace_raw = namespace,
requester,
fully_served = served.fully_served,
"dropping a state-transfer offer"
@@ -4873,10 +7233,32 @@
}
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();
+ // Nobody is pulling from the metadata plane: release its cached
+ // snapshot copy too, rather than pinning it for the life of the
+ // process. Runs on every shard, but only shard 0 ever populates the
+ // metadata cache, so it is a no-op elsewhere.
+ let metadata = self.plane.metadata();
+ let metadata_served = metadata.consensus.as_ref().is_some_and(|consensus| {
+ offers
+ .keys()
+ .any(|(namespace, _)| *namespace == consensus.namespace())
+ });
+ if !metadata_served {
+ metadata.clear_state_transfer_offer_cache();
+ }
+ // Partition offer caches: release each namespace whose LAST offer
+ // just aged out, so a served-once partition does not pin its offer
+ // (manifest + offsets table) for the process lifetime.
+ let mut vanished = namespaces_before;
+ vanished.retain(|namespace| !offers.keys().any(|(live, _)| live == namespace));
+ vanished.sort_unstable();
+ vanished.dedup();
+ drop(offers);
+ let partitions = self.plane.partitions();
+ for namespace in vanished {
+ if let Some(partition) = partitions.get_by_ns(&IggyNamespace::from_raw(namespace)) {
+ partition.clear_state_transfer_offer_cache();
+ }
}
}
@@ -4906,8 +7288,10 @@
let actions = consensus.tick(PlaneKind::Metadata);
+ let (local_actions, wire_actions) = split_local_actions(actions);
+ dispatch_vsr_actions(consensus, metadata.journal.as_ref(), &local_actions).await;
if metadata.persist_superblock_if_needed(consensus).await {
- dispatch_vsr_actions(consensus, metadata.journal.as_ref(), &actions).await;
+ dispatch_vsr_actions(consensus, metadata.journal.as_ref(), &wire_actions).await;
}
// Repair a lost primary self-ack: `RetransmitPrepares` to self is a
@@ -4925,8 +7309,6 @@
// nothing is stranded.
metadata.resume_stranded_commits().await;
- self.expire_idle_state_transfer_offers();
-
// Stall retry for an in-flight state transfer: descriptor or chunk
// frames are fire-and-forget, so a lost one must not wedge the
// session (and the boot flow behind it) forever.
@@ -4955,8 +7337,7 @@
shard = self.id,
peer,
attempts,
- "metadata state transfer stalled past its retry budget; \
- abandoning and falling back to journal repair"
+ "metadata state transfer stalled past its retry budget; abandoning and falling back to journal repair"
);
*self.metadata_transfer.borrow_mut() = None;
if consensus.state_transfer_stage() != consensus::StateTransferStage::Idle {
@@ -5125,36 +7506,35 @@
// Centralized durable-before-send tripwire: a view-scoped message must never
// advertise a (view, log_view) the superblock has not recorded, or a crash could
// recover an older view than one a peer already saw, splitting the brain or
- // losing a commit. Every metadata caller persists first (the view-change dispatch
- // sites and the on_replicate / on_commit send gates), so this asserts they did
- // rather than letting a future bypass through silently. Metadata plane only:
- // partition consensus has no superblock to record a view in, so it is exempt and
- // the namespace test below is what does the work (its `needs_superblock_persist`
- // is not a stand-in: that predicate reads clean at view 0, which is where a
- // partition group spends most of its life). `RequestStartView` is exempt too,
- // being a probe that asks to LEARN the view rather than advertise it.
+ // losing a commit. Every caller on BOTH planes persists first (the view-change
+ // dispatch sites, the tick, and each plane's PrepareOk send gate), so this
+ // asserts they did rather than letting a future bypass through silently.
+ // `RequestStartView` is exempt, being a probe that asks to LEARN the view rather
+ // than advertise it. Partitions without an attached superblock (in-memory,
+ // simulated) pass vacuously: their persist gate records "durable = current"
+ // instead of writing, precisely so this assert stays meaningful for the groups
+ // that do have a store.
#[cfg(debug_assertions)]
- if consensus.namespace() == METADATA_CONSENSUS_NAMESPACE {
- for action in actions {
- let advertises_view = matches!(
- action,
- VsrAction::SendStartViewChange { .. }
- | VsrAction::SendDoViewChange { .. }
- | VsrAction::SendStartView { .. }
- | VsrAction::SendPrepareOk { .. }
- // A backup drops a Commit whose view differs from its own, and a
- // primary answers an older-view one with a StartView, so the
- // heartbeat advertises a view like the rest. Gated today only
- // because its sole emitter rides the tick, which persists first.
- | VsrAction::SendCommit { .. }
- );
- debug_assert!(
- !advertises_view || !consensus.needs_superblock_persist(),
- "durable-before-send violated: dispatching a view-scoped metadata action \
- while the superblock is behind the in-memory view {}",
- consensus.view(),
- );
- }
+ for action in actions {
+ let advertises_view = matches!(
+ action,
+ VsrAction::SendStartViewChange { .. }
+ | VsrAction::SendDoViewChange { .. }
+ | VsrAction::SendStartView { .. }
+ | VsrAction::SendPrepareOk { .. }
+ // A backup drops a Commit whose view differs from its own, and a
+ // primary answers an older-view one with a StartView, so the
+ // heartbeat advertises a view like the rest. Gated today only
+ // because its sole emitter rides the tick, which persists first.
+ | VsrAction::SendCommit { .. }
+ );
+ debug_assert!(
+ !advertises_view || !consensus.needs_superblock_persist(),
+ "durable-before-send violated: dispatching a view-scoped action for \
+ namespace {} while the superblock is behind the in-memory view {}",
+ consensus.namespace(),
+ consensus.view(),
+ );
}
for action in actions {
@@ -5379,9 +7759,9 @@
clippy::too_many_lines,
clippy::cast_possible_truncation
)]
-async fn dispatch_partition_journal_actions<B, P>(
+async fn dispatch_partition_journal_actions<B, P, SB>(
consensus: &VsrConsensus<B, P>,
- partition: &IggyPartition<B>,
+ partition: &IggyPartition<B, SB>,
actions: &[VsrAction],
) where
B: MessageBus,
@@ -5400,6 +7780,22 @@
}
};
+ // Same durable-before-send tripwire as `dispatch_vsr_actions`: this
+ // dispatcher emits view-scoped `SendPrepareOk` too, and all callers are
+ // persist-gated today -- assert it so a future bypass cannot slip
+ // through the partition plane's own dispatcher silently.
+ #[cfg(debug_assertions)]
+ for action in actions {
+ debug_assert!(
+ !matches!(action, VsrAction::SendPrepareOk { .. })
+ || !consensus.needs_superblock_persist(),
+ "durable-before-send violated: dispatching a view-scoped action for \
+ namespace {} while the superblock is behind the in-memory view {}",
+ consensus.namespace(),
+ consensus.view(),
+ );
+ }
+
for action in actions {
match action {
VsrAction::SendPrepareOk {
@@ -5512,3 +7908,58 @@
}
}
}
+
+#[cfg(test)]
+mod persist_gate_tests {
+ use super::*;
+
+ fn rebuild() -> VsrAction {
+ VsrAction::RebuildPipeline {
+ from_op: 3,
+ to_op: 9,
+ }
+ }
+
+ #[test]
+ fn given_view_change_actions_when_split_should_keep_locals_out_of_the_gate() {
+ // The exact action shape `complete_view_change_as_primary` emits
+ // after it already flipped status/log_view and cleared its pipeline.
+ // The regression: a failed superblock persist used to drop the whole
+ // vec, and losing `RebuildPipeline` leaves a primary that discards
+ // every backup PrepareOk for the orphaned window as UnknownPrepare.
+ let actions = vec![
+ VsrAction::SendStartView {
+ view: 4,
+ op: 9,
+ commit: 3,
+ incarnation: 0,
+ target: None,
+ namespace: 7,
+ },
+ VsrAction::CommitJournal,
+ rebuild(),
+ ];
+ let (local, wire) = split_local_actions(actions);
+ assert!(
+ local.iter().all(|action| matches!(
+ action,
+ VsrAction::CommitJournal | VsrAction::RebuildPipeline { .. }
+ )),
+ "locals must hold exactly the act-side actions"
+ );
+ assert_eq!(local.len(), 2, "both act-side actions survive the gate");
+ assert_eq!(wire.len(), 1, "only the send is fenced by the persist");
+ assert!(matches!(wire[0], VsrAction::SendStartView { .. }));
+ }
+
+ #[test]
+ fn given_send_only_actions_when_split_should_leave_locals_empty() {
+ let actions = vec![VsrAction::SendStartViewChange {
+ view: 2,
+ namespace: 7,
+ }];
+ let (local, wire) = split_local_actions(actions);
+ assert!(local.is_empty());
+ assert_eq!(wire.len(), 1);
+ }
+}
diff --git a/core/shard/src/metrics.rs b/core/shard/src/metrics.rs
index 5b8fa34..883eefe 100644
--- a/core/shard/src/metrics.rs
+++ b/core/shard/src/metrics.rs
@@ -186,6 +186,7 @@
partitions_materialised_total: Counter,
partitions_removed_total: Counter,
partitions_reconcile_failures_total: Counter,
+ partition_transfer_refusals_total: Counter,
partition_frames_rejected_stale_total: Counter,
partition_frames_rejected_ahead_total: Counter,
partition_requests_denied_transient_total: Counter,
@@ -218,6 +219,7 @@
partitions_materialised_total: Counter::default(),
partitions_removed_total: Counter::default(),
partitions_reconcile_failures_total: Counter::default(),
+ partition_transfer_refusals_total: Counter::default(),
partition_frames_rejected_stale_total: Counter::default(),
partition_frames_rejected_ahead_total: Counter::default(),
partition_requests_denied_transient_total: Counter::default(),
@@ -263,6 +265,26 @@
self.partitions_reconcile_failures_total.inc();
}
+ /// Bumped every time a serving peer refuses a partition state transfer.
+ ///
+ /// Transient refusals re-arm on a flat interval and charge no failure
+ /// count -- deliberately, since the alternative routes through a 1024x
+ /// backoff cap that pins a partition for ~17 minutes after the peer has
+ /// already caught up -- which also means a partition stuck rejoining for
+ /// hours produces no signal of its own. This counter plus the escalating
+ /// log level at the refusal site is that signal.
+ pub fn record_partition_transfer_refusal(&self) {
+ self.partition_transfer_refusals_total.inc();
+ }
+
+ /// Test-only read, mirroring the siblings; the production scrape goes
+ /// through the prometheus registry.
+ #[cfg(any(test, feature = "simulator"))]
+ #[must_use]
+ pub fn partition_transfer_refusals_value(&self) -> u64 {
+ self.partition_transfer_refusals_total.get()
+ }
+
/// Bumped when a parked partition frame is answered instead of served
/// because it was addressed to an incarnation this shard no longer holds
/// (delete + recreate recycled the namespace's slab keys). Serving it would
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index bc7370a..9553532 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -24,6 +24,7 @@
use crossfire::TrySendError;
use futures::FutureExt;
use iggy_binary_protocol::{ConsensusHeader, GenericHeader, Operation, PrepareHeader};
+use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use message_bus::{ConnectionInstaller, MessageBus, ReplicaHandshakeDoneFn};
use server_common::sharding::{IggyNamespace, METADATA_CONSENSUS_NAMESPACE};
@@ -112,10 +113,11 @@
/// through the channel into the target shard's message pump. This ensures
/// that every mutation on a shard is serialized through a single point (the
/// pump), preventing concurrent access from independent async tasks.
-impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
+impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
where
B: MessageBus + ConnectionInstaller + Clone + 'static,
T: ShardsTable,
+ SB: SuperblockStore,
{
/// Network-receive entry point. Classifies the raw
/// `Message<GenericHeader>` and routes it to the owning shard via
@@ -345,6 +347,11 @@
// partition-ref-across-`.await` UB this fold closed.
self.tick_metadata().await;
self.tick_partitions().await;
+ // Runs here, not inside `tick_metadata`: that early-returns
+ // on shards without metadata consensus, and partition-plane
+ // offers live on every shard that hosts a serving group --
+ // parked behind the shard-0 gate they would never expire.
+ self.expire_idle_state_transfer_offers();
// While a cooperative revocation is pending, wake the
// reconciler each tick so the handoff completes within ~one
// tick of the partition draining, not the periodic pass.
@@ -617,6 +624,14 @@
.clean_expired_segments(now, message_expiry, max_bytes)
.await;
if segments > 0 {
+ // Any unlink invalidates what this shard is SERVING:
+ // the offer names files that are gone and the payload
+ // cache can answer from RAM without touching disk, so a
+ // puller would install deleted messages. Neither cache
+ // can notice on its own -- one is keyed on the
+ // partition's commit_op, which retention does not move,
+ // the other on a checksum over the deleted bytes.
+ self.drop_partition_transfer_state(namespace, partition);
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
@@ -638,6 +653,11 @@
let (segments, messages) =
partition.remove_sealed_segments_up_to(up_to_offset).await;
if segments > 0 {
+ // See the cleaner arm: a truncate commits on the
+ // METADATA plane, so this partition's commit_op never
+ // moves and the cached offer stays a hit over unlinked
+ // files.
+ self.drop_partition_transfer_state(namespace, partition);
tracing::debug!(
shard = self.id,
namespace_raw = namespace.inner(),
@@ -662,19 +682,70 @@
&& partition.applied_purge_generation() < generation
{
match partition.purge(&config, generation).await {
- Ok(()) => tracing::debug!(
- shard = self.id,
- namespace_raw = namespace.inner(),
- generation,
- "purge-partition reset partition to empty"
- ),
- Err(error) => tracing::error!(
- shard = self.id,
- namespace_raw = namespace.inner(),
- generation,
- %error,
- "purge-partition failed to reset partition"
- ),
+ Ok(()) => {
+ // The purge unlinked the very bytes this shard is
+ // serving: the cached offer still advertises the
+ // pre-purge manifest and the payload cache can
+ // answer chunk requests for it without touching
+ // disk, so a puller would install purged data. Both
+ // are keyed on pre-purge content, so neither can
+ // notice on its own.
+ self.drop_partition_transfer_state(namespace, partition);
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ generation,
+ "purge-partition reset partition to empty"
+ );
+ }
+ Err(partitions::PurgeError::FrontierNotRecorded) => {
+ // NOT fenced: nothing was mutated, so the chain is
+ // whole and `applied_purge_generation` is unmoved,
+ // which means the reconciler's `committed > applied`
+ // gate still sees this purge as outstanding.
+ // Fencing here would quarantine live data, and the
+ // fence's own frontier write would first stamp the
+ // pre-purge counter the purge was about to reset.
+ //
+ // NOT woken: staging a purge counts as work in the
+ // pass, which keeps the fast-skip disarmed, so the
+ // ordinary periodic pass re-issues until one lands
+ // and stops once `applied` catches `committed`. An
+ // eager wake here closes a loop with no pacing in
+ // it at all -- pass, stage, defer, wake -- and on a
+ // disk that refuses instantly that is a full O(N)
+ // reconcile scan and a real `atomic_replace`
+ // attempt per turn, holding the partition write
+ // lock each time.
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ generation,
+ "purge-partition deferred: could not record the frontier reset; \
+ the reconciler re-issues it while the generation stays unapplied"
+ );
+ }
+ Err(error @ partitions::PurgeError::Unserviceable(_)) => {
+ // Past the drain, so this group has no serviceable
+ // chain and the next append panics on
+ // `active_segment()`. Fence it for rebuild, exactly
+ // as a failed state-transfer convergence does. The
+ // counters were already reset to 0 before the
+ // fallible plant, so the fence's advancing write
+ // records the post-purge frontier.
+ tracing::error!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ generation,
+ %error,
+ "purge-partition failed to reset partition; fencing it for rebuild"
+ );
+ // Fenced, but the caches still describe the
+ // pre-purge bytes until the rebuild lands.
+ self.drop_partition_transfer_state(namespace, partition);
+ self.fence_partition_for_rebuild(namespace, partition, None)
+ .await;
+ }
}
}
}
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index c682125..64aecc4 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -34,7 +34,6 @@
use executor::{DetExecutor, RunOutcome, TaskId};
use iggy_binary_protocol::{GenericHeader, ReplyHeader};
use iggy_common::IggyError;
-use journal::superblock::DynSuperblockStore;
use message_bus::installer::conn_info::{ClientConnMeta, ClientTransportKind};
use metadata::impls::metadata::StreamsFrontend;
use network::Network;
@@ -48,7 +47,8 @@
use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
use shard::CONSENSUS_TICK_INTERVAL;
use shard::shards_table::{ShardsTable, calculate_shard_assignment};
-use std::collections::HashSet;
+use std::cell::RefCell;
+use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::rc::Rc;
@@ -86,6 +86,14 @@
/// yet the run stays byte-identical on replay. See
/// `VsrConsensus::set_incarnation`.
pub metadata_incarnation: u128,
+ /// One durable superblock per partition group this replica has materialised,
+ /// harness-owned for the same reason as the metadata one: the bytes survive
+ /// the shards being dropped and rebuilt, so a re-materialised group recovers
+ /// the `(view, log_view)` it recorded instead of re-entering view 0. Without
+ /// a store the persist gate marks every view durable without writing, which
+ /// leaves the gate, its write-failure fence, and view recovery all
+ /// unexercised.
+ pub partition_superblocks: RefCell<HashMap<IggyNamespace, Rc<SimSuperblock>>>,
/// Keeps each pump's stop channel alive; dropping one would end that
/// pump gracefully, which is reserved for future shutdown/restart
/// tests (crash uses `DetExecutor::abort` instead).
@@ -280,9 +288,8 @@
.expect("mesh yields exactly one inbox per shard");
// Only shard 0 owns metadata consensus, so only it carries the
// superblock. Peer shards persist nothing.
- let shard_superblock: Option<Rc<dyn DynSuperblockStore>> = if shard_idx == 0 {
- let sb: Rc<dyn DynSuperblockStore> = superblock.clone();
- Some(sb)
+ let shard_superblock = if shard_idx == 0 {
+ Some(superblock.clone())
} else {
None
};
@@ -327,6 +334,7 @@
superblock,
metadata_journal,
metadata_incarnation,
+ partition_superblocks: RefCell::new(HashMap::new()),
_stop_txs: stop_txs,
pump_tasks,
});
@@ -360,29 +368,11 @@
/// mesh construction caps it at `u16`).
#[allow(clippy::cast_possible_truncation)]
pub fn init_partition(&mut self, namespace: IggyNamespace) {
- for (i, replica) in self.replicas.iter_mut().enumerate() {
+ for (i, replica) in self.replicas.iter().enumerate() {
if self.crashed.contains(&(i as u8)) {
continue;
}
- let shard_count = u32::try_from(replica.shards.len()).expect("shard count fits u32");
- let owner = calculate_shard_assignment(&namespace, shard_count);
- replica.shards[usize::from(owner)].init_partition(namespace);
- // Commit the namespace before stamping the rows: a partition the
- // metadata plane never heard of is a shape production cannot
- // produce, and the shard refuses to serve client traffic whose
- // routing-row epoch it cannot match against a committed
- // `created_revision`.
- let streams = replica.shards[0].plane.metadata().mux_stm.streams();
- streams.seed_namespace(namespace, namespace.inner());
- let epoch = streams
- .created_revision_for_namespace(namespace)
- .expect("namespace committed by the seed above");
- for shard in &replica.shards {
- shard.shards_table().insert(
- namespace,
- PartitionLocation::new(ShardId::new(owner), epoch),
- );
- }
+ materialise_partition(replica, namespace);
}
}
@@ -751,6 +741,11 @@
// incarnation and still in flight is ignored. Deterministic, so replay stays
// byte-identical.
let metadata_incarnation = self.replicas[idx].metadata_incarnation + 1;
+ // Partition superblocks carry forward too: a group re-materialised after
+ // the restart must recover its recorded view from the same store, exactly
+ // as a rebooted server-ng partition reads the record in its directory.
+ let partition_superblocks =
+ std::mem::take(&mut *self.replicas[idx].partition_superblocks.borrow_mut());
// Recover the durable VSR state from the retained superblock before the
// rebuild, as production reads it in restore_metadata_consensus.
@@ -771,9 +766,8 @@
let inbox = inboxes[usize::from(shard_idx)]
.take()
.expect("mesh yields exactly one inbox per shard");
- let shard_superblock: Option<Rc<dyn DynSuperblockStore>> = if shard_idx == 0 {
- let sb: Rc<dyn DynSuperblockStore> = superblock.clone();
- Some(sb)
+ let shard_superblock = if shard_idx == 0 {
+ Some(superblock.clone())
} else {
None
};
@@ -814,10 +808,30 @@
superblock,
metadata_journal,
metadata_incarnation,
+ partition_superblocks: RefCell::new(partition_superblocks),
_stop_txs: stop_txs,
pump_tasks,
};
+ // Re-materialise every group this replica had before the crash, as a
+ // rebooted server-ng re-opens every partition directory it owns. This
+ // is what makes the carried-forward superblock load-bearing: the group
+ // recovers the `(view, log_view)` it recorded instead of re-entering
+ // view 0.
+ // SORTED: `HashMap` iteration order is seeded per process, and
+ // materialisation order is observable (shard init order, routing-row
+ // stamps), so replay would stop being byte-identical.
+ let mut materialised: Vec<IggyNamespace> = self.replicas[idx]
+ .partition_superblocks
+ .borrow()
+ .keys()
+ .copied()
+ .collect();
+ materialised.sort_unstable_by_key(IggyNamespace::inner);
+ for namespace in materialised {
+ materialise_partition(&self.replicas[idx], namespace);
+ }
+
// Reconnect to the network and mark the replica live again.
self.network
.process_enable(ProcessId::Replica(replica_index));
@@ -927,6 +941,46 @@
}
}
+/// Materialises `namespace` on its hash-owning shard of one replica and stamps
+/// the routing row on every shard of that replica.
+///
+/// Shared by [`SimCluster::init_partition`] and the restart path: a rebooted
+/// server-ng re-opens every partition directory it owns, so the sim has to
+/// re-materialise too, otherwise the superblock a restart carries forward is
+/// never read back and the recovered-view branch is dead code.
+fn materialise_partition(replica: &SimReplica, namespace: IggyNamespace) {
+ let shard_count = u32::try_from(replica.shards.len()).expect("shard count fits u32");
+ let owner = calculate_shard_assignment(&namespace, shard_count);
+ // One store per group, minted on first materialisation and reused on every
+ // later one, so the recorded view survives a replica restart.
+ let superblock = Rc::clone(
+ replica
+ .partition_superblocks
+ .borrow_mut()
+ .entry(namespace)
+ .or_default(),
+ );
+ let recovered_state = superblock
+ .read_latest_sync()
+ .and_then(|bytes| VsrState::try_from(bytes.as_slice()).ok());
+ replica.shards[usize::from(owner)].init_partition(namespace, Some(superblock), recovered_state);
+ // Commit the namespace before stamping the rows: a partition the metadata
+ // plane never heard of is a shape production cannot produce, and the shard
+ // refuses to serve client traffic whose routing-row epoch it cannot match
+ // against a committed `created_revision`.
+ let streams = replica.shards[0].plane.metadata().mux_stm.streams();
+ streams.seed_namespace(namespace, namespace.inner());
+ let epoch = streams
+ .created_revision_for_namespace(namespace)
+ .expect("namespace committed by the seed above");
+ for shard in &replica.shards {
+ shard.shards_table().insert(
+ namespace,
+ PartitionLocation::new(ShardId::new(owner), epoch),
+ );
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/core/simulator/src/replica.rs b/core/simulator/src/replica.rs
index ace319b..9abf73c 100644
--- a/core/simulator/src/replica.rs
+++ b/core/simulator/src/replica.rs
@@ -16,13 +16,13 @@
// under the License.
use crate::bus::{SharedSimOutbox, SimOutbox};
+use crate::deps::SimSuperblock;
use crate::deps::{MemStorage, SimJournal, SimMuxStateMachine, SimSnapshot};
use configs::server::PersonalAccessTokenConfig;
use configs::server_ng::NgSystemConfig;
use consensus::{ConsensusClock, LocalPipeline, Sequencer, VsrConsensus, VsrState};
use iggy_common::IggyByteSize;
use iggy_common::variadic;
-use journal::superblock::DynSuperblockStore;
use metadata::stm::mux::WithFactory;
use metadata::stm::stream::{Streams, StreamsInner};
use metadata::stm::user::{Users, UsersInner};
@@ -63,6 +63,7 @@
SimSnapshot,
SimMuxStateMachine,
PapayaShardsTable,
+ SimSuperblock,
>;
/// Read-side handoff bundle for the metadata STM.
@@ -120,7 +121,7 @@
clock: ConsensusClock,
shell: bool,
reader_bundle: Option<SimMetadataBundle>,
- superblock: Option<Rc<dyn DynSuperblockStore>>,
+ superblock: Option<Rc<SimSuperblock>>,
metadata_journal: Option<Rc<SimJournal<MemStorage>>>,
recovered_state: Option<VsrState>,
incarnation: u128,
@@ -295,8 +296,12 @@
// The deferred handlers upgrade this weak self-reference per frame; it
// stays `None` until the shard is built and downgraded into it below.
- let shard_handle: ShellShardHandle<SharedSimOutbox, Rc<SimJournal<MemStorage>>, SimSnapshot> =
- Rc::new(RefCell::new(None));
+ let shard_handle: ShellShardHandle<
+ SharedSimOutbox,
+ Rc<SimJournal<MemStorage>>,
+ SimSnapshot,
+ SimSuperblock,
+ > = Rc::new(RefCell::new(None));
let ShellHandlers {
on_replica_message,
on_client_request,
diff --git a/examples/node/package-lock.json b/examples/node/package-lock.json
index 4d9c99e..3fd722f 100644
--- a/examples/node/package-lock.json
+++ b/examples/node/package-lock.json
@@ -15,7 +15,7 @@
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/node": "^22.9.3",
- "eslint": "^10.7.0",
+ "eslint": "^10.8.0",
"jiti": "^2.7.0",
"tsx": "^4.23.1",
"typescript-eslint": "^8.47.0"
@@ -23,7 +23,7 @@
},
"../../foreign/node": {
"name": "apache-iggy",
- "version": "0.8.1-edge.2",
+ "version": "0.9.0-edge.1",
"license": "Apache-2.0",
"dependencies": {
"debug": "4.4.3",
@@ -33,11 +33,11 @@
"devDependencies": {
"@commitlint/cli": "21.2.1",
"@commitlint/config-conventional": "21.2.0",
- "@cucumber/cucumber": "13.0.0",
- "@swc-node/register": "1.11.1",
+ "@cucumber/cucumber": "13.2.0",
+ "@swc-node/register": "1.12.1",
"@types/debug": "4.1.13",
"@types/node": "26.1.1",
- "c8": "^11.0.0",
+ "c8": "^12.0.0",
"husky": "9.1.7",
"typescript": "6.0.3",
"typescript-eslint": "^8.47.0"
@@ -555,9 +555,9 @@
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
- "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
+ "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1096,9 +1096,9 @@
}
},
"node_modules/eslint": {
- "version": "10.7.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
- "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
+ "version": "10.8.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
+ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -1108,7 +1108,7 @@
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
- "@eslint/config-helpers": "^0.6.0",
+ "@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.2",
"@humanfs/node": "^0.16.6",
@@ -1132,7 +1132,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "minimatch": "^10.2.4",
+ "minimatch": "^10.2.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
diff --git a/examples/node/package.json b/examples/node/package.json
index eb1334b..42a20c2 100644
--- a/examples/node/package.json
+++ b/examples/node/package.json
@@ -39,7 +39,7 @@
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/node": "^22.9.3",
- "eslint": "^10.7.0",
+ "eslint": "^10.8.0",
"jiti": "^2.7.0",
"tsx": "^4.23.1",
"typescript-eslint": "^8.47.0"
diff --git a/foreign/cpp/MODULE.bazel b/foreign/cpp/MODULE.bazel
index 6724065..3142ca5 100644
--- a/foreign/cpp/MODULE.bazel
+++ b/foreign/cpp/MODULE.bazel
@@ -24,7 +24,7 @@
bazel_dep(name = "platforms", version = "1.1.0")
bazel_dep(name = "googletest", version = "1.17.0.bcr.2")
bazel_dep(name = "cucumber-cpp", version = "0.8.0.bcr.1")
-bazel_dep(name = "rules_rust", version = "0.71.3")
+bazel_dep(name = "rules_rust", version = "0.72.0")
rust_host_tools = use_extension("@rules_rust//rust:extensions.bzl", "rust_host_tools")
rust_host_tools.host_tools(
diff --git a/foreign/cpp/MODULE.bazel.lock b/foreign/cpp/MODULE.bazel.lock
index 0ad4eed..7061c20 100644
--- a/foreign/cpp/MODULE.bazel.lock
+++ b/foreign/cpp/MODULE.bazel.lock
@@ -188,8 +188,8 @@
"https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8",
"https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8",
"https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32",
- "https://bcr.bazel.build/modules/rules_rust/0.71.3/MODULE.bazel": "e2390c96f77d65f00c769bf665678c5424188e9c777239cfaae2a8d2dde7b981",
- "https://bcr.bazel.build/modules/rules_rust/0.71.3/source.json": "5eb5d8068571725bc893045f8137ed7937988f23d73c53ea443470e8047598ad",
+ "https://bcr.bazel.build/modules/rules_rust/0.72.0/MODULE.bazel": "e49a6d6525cf5a28d52afe17e4b12e62498652b40ab30cec26be39eb4f0303c7",
+ "https://bcr.bazel.build/modules/rules_rust/0.72.0/source.json": "a3871759cac97efb50ea066bcf6487ed639c181e71aa22d26aaa658890703bc2",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b",
diff --git a/foreign/node/package-lock.json b/foreign/node/package-lock.json
index 7cf29b8..11295a8 100644
--- a/foreign/node/package-lock.json
+++ b/foreign/node/package-lock.json
@@ -19,7 +19,7 @@
"@cucumber/cucumber": "13.2.0",
"@swc-node/register": "1.12.1",
"@types/debug": "4.1.13",
- "@types/node": "26.1.1",
+ "@types/node": "26.1.2",
"c8": "^12.0.0",
"husky": "9.1.7",
"typescript": "6.0.3",
@@ -1868,9 +1868,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.1.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
- "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "version": "26.1.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+ "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/foreign/node/package.json b/foreign/node/package.json
index 62e4712..7f4bcb6 100644
--- a/foreign/node/package.json
+++ b/foreign/node/package.json
@@ -61,7 +61,7 @@
"@cucumber/cucumber": "13.2.0",
"@swc-node/register": "1.12.1",
"@types/debug": "4.1.13",
- "@types/node": "26.1.1",
+ "@types/node": "26.1.2",
"c8": "^12.0.0",
"husky": "9.1.7",
"typescript": "6.0.3",
diff --git a/web/package-lock.json b/web/package-lock.json
index 5279865..45768cf 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -25,11 +25,11 @@
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/adapter-static": "^3.0.10",
- "@sveltejs/kit": "^2.70.1",
+ "@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/d3-interpolate": "^3.0.4",
"@types/json-bigint": "^1.0.4",
- "@types/node": "^26.1.1",
+ "@types/node": "^26.1.2",
"autoprefixer": "^10.5.4",
"esbuild": "^0.28.1",
"eslint": "^10.8.0",
@@ -37,18 +37,18 @@
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.8.0",
"jwt-decode": "^4.0.0",
- "postcss": "^8.5.23",
+ "postcss": "^8.5.25",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.56.8",
- "svelte-check": "^4.7.3",
+ "svelte-check": "^4.7.4",
"sveltekit-superforms": "^2.30.2",
"tailwindcss": "^4.2.4",
"ts-toolbelt": "^9.6.0",
"tslib": "^2.8.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.65.0",
- "vite": "^8.1.5",
+ "vite": "^8.2.0",
"zod": "^4.4.3"
}
},
@@ -94,40 +94,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
- "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.2",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
- "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/wasi-threads": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
- "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -868,32 +834,10 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@napi-rs/wasm-runtime": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
- "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@tybys/wasm-util": "^0.10.3"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
- "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
- }
- },
"node_modules/@oxc-project/types": {
- "version": "0.139.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
- "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
+ "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -927,9 +871,9 @@
"optional": true
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
- "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
+ "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
"cpu": [
"arm64"
],
@@ -944,9 +888,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
- "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
+ "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
"cpu": [
"arm64"
],
@@ -961,9 +905,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
- "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
+ "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
"cpu": [
"x64"
],
@@ -978,9 +922,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
- "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
+ "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
"cpu": [
"x64"
],
@@ -995,9 +939,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
- "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
+ "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
"cpu": [
"arm"
],
@@ -1012,9 +956,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
- "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
+ "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
"cpu": [
"arm64"
],
@@ -1032,9 +976,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
- "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
+ "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
"cpu": [
"arm64"
],
@@ -1052,9 +996,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
- "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
+ "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
"cpu": [
"ppc64"
],
@@ -1072,9 +1016,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
- "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
+ "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
"cpu": [
"s390x"
],
@@ -1092,9 +1036,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
- "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
+ "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
"cpu": [
"x64"
],
@@ -1112,9 +1056,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
- "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
+ "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
"cpu": [
"x64"
],
@@ -1132,9 +1076,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
- "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
+ "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
"cpu": [
"arm64"
],
@@ -1148,29 +1092,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
- "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
- "cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "1.11.1",
- "@emnapi/runtime": "1.11.1",
- "@napi-rs/wasm-runtime": "^1.1.6"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
- "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
+ "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
"cpu": [
"arm64"
],
@@ -1185,9 +1110,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
- "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
+ "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
"cpu": [
"x64"
],
@@ -1747,9 +1672,9 @@
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.70.1",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz",
- "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==",
+ "version": "2.70.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz",
+ "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1789,9 +1714,9 @@
}
},
"node_modules/@sveltejs/load-config": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz",
- "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==",
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz",
+ "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2146,17 +2071,6 @@
"tailwindcss": "4.3.3"
}
},
- "node_modules/@tybys/wasm-util": {
- "version": "0.10.3",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
- "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -2209,9 +2123,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.1.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
- "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "version": "26.1.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+ "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4219,9 +4133,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.23",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
- "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"funding": [
{
"type": "opencollective",
@@ -4473,13 +4387,13 @@
}
},
"node_modules/rolldown": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
- "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
+ "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.139.0",
+ "@oxc-project/types": "=0.143.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -4489,21 +4403,20 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.1.5",
- "@rolldown/binding-darwin-arm64": "1.1.5",
- "@rolldown/binding-darwin-x64": "1.1.5",
- "@rolldown/binding-freebsd-x64": "1.1.5",
- "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
- "@rolldown/binding-linux-arm64-gnu": "1.1.5",
- "@rolldown/binding-linux-arm64-musl": "1.1.5",
- "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
- "@rolldown/binding-linux-s390x-gnu": "1.1.5",
- "@rolldown/binding-linux-x64-gnu": "1.1.5",
- "@rolldown/binding-linux-x64-musl": "1.1.5",
- "@rolldown/binding-openharmony-arm64": "1.1.5",
- "@rolldown/binding-wasm32-wasi": "1.1.5",
- "@rolldown/binding-win32-arm64-msvc": "1.1.5",
- "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ "@rolldown/binding-android-arm64": "1.2.3",
+ "@rolldown/binding-darwin-arm64": "1.2.3",
+ "@rolldown/binding-darwin-x64": "1.2.3",
+ "@rolldown/binding-freebsd-x64": "1.2.3",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.3",
+ "@rolldown/binding-linux-arm64-musl": "1.2.3",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.3",
+ "@rolldown/binding-linux-x64-gnu": "1.2.3",
+ "@rolldown/binding-linux-x64-musl": "1.2.3",
+ "@rolldown/binding-openharmony-arm64": "1.2.3",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.3",
+ "@rolldown/binding-win32-x64-msvc": "1.2.3"
}
},
"node_modules/rollup": {
@@ -4683,14 +4596,14 @@
}
},
"node_modules/svelte-check": {
- "version": "4.7.3",
- "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz",
- "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==",
+ "version": "4.7.4",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.4.tgz",
+ "integrity": "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
- "@sveltejs/load-config": "^0.2.0",
+ "@sveltejs/load-config": "^0.2.1",
"chokidar": "^4.0.1",
"fdir": "^6.2.0",
"picocolors": "^1.0.0",
@@ -4704,7 +4617,7 @@
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0",
- "typescript": ">=5.0.0"
+ "typescript": "^5.0.0 || ^6.0.0"
}
},
"node_modules/svelte-eslint-parser": {
@@ -5206,16 +5119,16 @@
}
},
"node_modules/vite": {
- "version": "8.1.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
- "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "lightningcss": "^1.32.0",
+ "lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
- "postcss": "^8.5.17",
- "rolldown": "~1.1.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -5232,7 +5145,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.3.0",
+ "@vitejs/devtools": "^0.4.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -5283,6 +5196,279 @@
}
}
},
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
"node_modules/vitefu": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
diff --git a/web/package.json b/web/package.json
index 647031a..23b95a2 100644
--- a/web/package.json
+++ b/web/package.json
@@ -18,11 +18,11 @@
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/adapter-static": "^3.0.10",
- "@sveltejs/kit": "^2.70.1",
+ "@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@types/d3-interpolate": "^3.0.4",
"@types/json-bigint": "^1.0.4",
- "@types/node": "^26.1.1",
+ "@types/node": "^26.1.2",
"autoprefixer": "^10.5.4",
"esbuild": "^0.28.1",
"eslint": "^10.8.0",
@@ -30,18 +30,18 @@
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.8.0",
"jwt-decode": "^4.0.0",
- "postcss": "^8.5.23",
+ "postcss": "^8.5.25",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.56.8",
- "svelte-check": "^4.7.3",
+ "svelte-check": "^4.7.4",
"sveltekit-superforms": "^2.30.2",
"tailwindcss": "^4.2.4",
"ts-toolbelt": "^9.6.0",
"tslib": "^2.8.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.65.0",
- "vite": "^8.1.5",
+ "vite": "^8.2.0",
"zod": "^4.4.3"
},
"dependencies": {