blob: 94a62b60e4ed8c18e1a89449963503f3b8865c9d [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! 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::{
PURGE_GENERATION_FILE, commit_offset_replacement, delete_persisted_offset,
discard_offset_replacement, offset_replacement_id, persist_purge_generation,
stage_offset_replacement,
};
use crate::segment::Segment;
use crate::segment_anchor::ANCHOR_SUFFIX;
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, DedupWatermark, Sequencer as _, StateArtifactHasher, state_artifact_checksum,
};
use iggy_common::{ConsumerGroupId, ConsumerKind, ConsumerOffset, IggyByteSize};
use journal::superblock::SuperblockStore;
use message_bus::MessageBus;
use server_common::send_messages::decode_batch_slice;
use server_common::{SegmentStorage, yield_to_reactor};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::future::Future;
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, "ICO2". Bumped with
/// the version when the dedup section was appended, so the magic alone tells
/// the two layouts apart.
pub(crate) const CONSUMER_OFFSETS_MAGIC: [u8; 4] = *b"ICO2";
/// 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 = 2;
/// The previous framing, "ICO1" at version 1: the same layout without the
/// dedup section. Still decoded so a rolling upgrade works in both orders --
/// an upgraded replica rejoining behind the repair floor of an un-upgraded
/// primary installs its artifact with an empty slice (dedup for that window
/// degrades to at-least-once, exactly the pre-dedup behaviour) instead of
/// refusing it and re-pulling forever.
pub(crate) const CONSUMER_OFFSETS_MAGIC_V1: [u8; 4] = *b"ICO1";
const CONSUMER_OFFSETS_VERSION_V1: 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 const CONSUMER_OFFSETS_ENTRIES_MAX: u32 = 1 << 20;
/// Wire stride of one dedup entry: client u128 + watermark u64 + commit u64 +
/// user u32 + committed window u128.
const DEDUP_ENTRY_LEN: usize = 2 * size_of::<u128>() + 2 * size_of::<u64>() + size_of::<u32>();
/// 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)>,
/// This group's dedup slice, ascending by client. Carried so a replica
/// rejoining behind the repair floor can absorb a replay of what the group
/// already committed instead of re-executing it.
pub dedup: Vec<DedupWatermark>,
}
impl ConsumerOffsetsWire {
/// Encode: `magic | version u8 | purge_generation u64 | next_offset u64 |
/// consumer_count u32 | group_count u32 | dedup_count u32 |
/// {id u32, offset u64}xN | {id u32, offset u64}xM |
/// {client u128, watermark u64, latest_commit u64, user_id u32,
/// committed_window u128}xD |
/// 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>()
+ 3 * size_of::<u32>()
+ (self.consumers.len() + self.groups.len()) * (size_of::<u32>() + size_of::<u64>())
+ self.dedup.len() * DEDUP_ENTRY_LEN
+ 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());
#[allow(clippy::cast_possible_truncation)]
out.extend_from_slice(&(self.dedup.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());
}
for entry in &self.dedup {
out.extend_from_slice(&entry.client.to_le_bytes());
out.extend_from_slice(&entry.watermark.to_le_bytes());
out.extend_from_slice(&entry.latest_commit.to_le_bytes());
out.extend_from_slice(&entry.user_id.to_le_bytes());
out.extend_from_slice(&entry.committed_window.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())?;
let version = cursor.u8()?;
let carries_dedup = if magic == CONSUMER_OFFSETS_MAGIC {
if version != CONSUMER_OFFSETS_VERSION {
return Err(ConsumerOffsetsWireError::UnsupportedVersion { version });
}
true
} else if magic == CONSUMER_OFFSETS_MAGIC_V1 {
if version != CONSUMER_OFFSETS_VERSION_V1 {
return Err(ConsumerOffsetsWireError::UnsupportedVersion { version });
}
false
} else {
return Err(ConsumerOffsetsWireError::BadMagic);
};
let purge_generation = cursor.u64()?;
let next_offset = cursor.u64()?;
let consumer_count = cursor.u32()?;
let group_count = cursor.u32()?;
let dedup_count = if carries_dedup { cursor.u32()? } else { 0 };
let consumers = Self::decode_section(&mut cursor, "consumers", consumer_count)?;
let groups = Self::decode_section(&mut cursor, "groups", group_count)?;
let dedup = if carries_dedup {
Self::decode_dedup_section(&mut cursor, dedup_count)?
} else {
Vec::new()
};
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,
dedup,
})
}
/// Same guards as [`Self::decode_section`] at the dedup stride: peer count
/// against the ceiling, then against the bytes actually present, then
/// ascending-strict client order so the encoding stays canonical. Client
/// zero is the reserved id no ingress admits, so an artifact carrying it is
/// a peer bug and fails closed rather than being silently dropped at
/// install.
fn decode_dedup_section(
cursor: &mut LeCursor<'_>,
count: u32,
) -> Result<Vec<DedupWatermark>, ConsumerOffsetsWireError> {
if count > CONSUMER_OFFSETS_ENTRIES_MAX {
return Err(ConsumerOffsetsWireError::TooManyEntries {
section: "dedup",
count,
max: CONSUMER_OFFSETS_ENTRIES_MAX,
});
}
if count as usize * DEDUP_ENTRY_LEN > cursor.remaining().len() {
return Err(ConsumerOffsetsWireError::Truncated);
}
let mut entries = Vec::with_capacity(count as usize);
let mut previous: Option<u128> = None;
for _ in 0..count {
let client = cursor.u128()?;
let watermark = cursor.u64()?;
let latest_commit = cursor.u64()?;
let user_id = cursor.u32()?;
let committed_window = cursor.u128()?;
if client == 0 {
return Err(ConsumerOffsetsWireError::ReservedClient);
}
if previous.is_some_and(|previous| client <= previous) {
return Err(ConsumerOffsetsWireError::NonAscendingClient { client });
}
previous = Some(client);
entries.push(DedupWatermark {
client,
user_id,
watermark,
latest_commit,
committed_window,
});
}
Ok(entries)
}
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, and
// the guard is a lower bound on purpose: what trails a section varies
// (groups trails consumers, the dedup section trails groups and is
// absent from a v1 artifact), so only "at least this many bytes
// present" holds for both calls. Demanding that `12 * count` be all
// that remains would reject valid input.
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,
},
/// Dedup clients are not strictly ascending. Same encoder bug as
/// [`Self::NonAscendingId`], on the u128-keyed section.
NonAscendingClient {
client: u128,
},
/// A dedup entry carries client id zero, which is reserved and refused at
/// every ingress: a peer encoder bug.
ReservedClient,
}
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)"
),
Self::NonAscendingClient { client } => write!(
f,
"consumer-offsets artifact dedup client {client} does not ascend \
(duplicate, or out of order)"
),
Self::ReservedClient => {
write!(
f,
"consumer-offsets artifact dedup entry carries reserved client 0"
)
}
}
}
}
impl std::error::Error for ConsumerOffsetsWireError {}
const fn validate_consumer_offset_transfer_count(
kind: ConsumerKind,
count: usize,
max: usize,
) -> Result<(), PartitionTransferUnavailable> {
if count <= max {
return Ok(());
}
Err(PartitionTransferUnavailable::ConsumerOffsetsTooLarge { kind, count, max })
}
#[cfg(test)]
mod tests {
use super::*;
#[compio::test]
async fn given_transient_offset_io_failure_when_retried_should_succeed_without_exhausting_budget()
{
let attempts = std::cell::Cell::new(0);
let result = retry_offset_mutation(|| {
attempts.set(attempts.get() + 1);
std::future::ready(if attempts.get() == 1 { Err(()) } else { Ok(7) })
})
.await;
assert_eq!(result, Ok(7));
assert_eq!(attempts.get(), 2);
}
#[compio::test]
async fn given_persistent_offset_io_failure_when_retried_should_stop_at_attempt_limit() {
let attempts = std::cell::Cell::new(0);
let result = retry_offset_mutation(|| {
attempts.set(attempts.get() + 1);
std::future::ready(Err::<(), _>(7))
})
.await;
assert_eq!(result, Err(7));
assert_eq!(attempts.get(), OFFSET_IO_ATTEMPTS);
}
fn table() -> ConsumerOffsetsWire {
ConsumerOffsetsWire {
purge_generation: 3,
next_offset: 43,
consumers: vec![(1, 10), (7, 42)],
groups: vec![(2, 5)],
dedup: vec![
dedup_entry(11, 4, 90),
dedup_entry(usize::MAX as u128 + 5, 9, 91),
],
}
}
fn dedup_entry(client: u128, watermark: u64, latest_commit: u64) -> DedupWatermark {
DedupWatermark {
client,
user_id: 1,
watermark,
latest_commit,
committed_window: 0b1011,
}
}
#[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(),
dedup: 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_unordered_dedup_clients_when_decoded_should_reject() {
let unordered = ConsumerOffsetsWire {
purge_generation: 0,
next_offset: 0,
consumers: Vec::new(),
groups: Vec::new(),
dedup: vec![dedup_entry(9, 1, 1), dedup_entry(4, 2, 2)],
};
assert_eq!(
ConsumerOffsetsWire::decode(&unordered.encode()),
Err(ConsumerOffsetsWireError::NonAscendingClient { client: 4 })
);
}
#[test]
fn given_reserved_client_in_dedup_when_decoded_should_reject() {
let reserved = ConsumerOffsetsWire {
purge_generation: 0,
next_offset: 0,
consumers: Vec::new(),
groups: Vec::new(),
dedup: vec![dedup_entry(0, 1, 1), dedup_entry(4, 2, 2)],
};
assert_eq!(
ConsumerOffsetsWire::decode(&reserved.encode()),
Err(ConsumerOffsetsWireError::ReservedClient)
);
}
#[test]
fn given_v1_artifact_when_decoded_should_install_empty_dedup() {
// An un-upgraded primary still ships "ICO1": same fields minus the
// dedup count and section. It must decode, with nothing to absorb.
let mut bytes = Vec::new();
bytes.extend_from_slice(&CONSUMER_OFFSETS_MAGIC_V1);
bytes.push(CONSUMER_OFFSETS_VERSION_V1);
bytes.extend_from_slice(&3u64.to_le_bytes());
bytes.extend_from_slice(&43u64.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.extend_from_slice(&1u32.to_le_bytes());
for (id, offset) in [(7u32, 42u64), (2, 5)] {
bytes.extend_from_slice(&id.to_le_bytes());
bytes.extend_from_slice(&offset.to_le_bytes());
}
let trailer = state_artifact_checksum(&bytes);
bytes.extend_from_slice(&trailer.to_le_bytes());
assert_eq!(
ConsumerOffsetsWire::decode(&bytes),
Ok(ConsumerOffsetsWire {
purge_generation: 3,
next_offset: 43,
consumers: vec![(7, 42)],
groups: vec![(2, 5)],
dedup: Vec::new(),
})
);
}
#[test]
fn given_v1_magic_with_wrong_version_when_decoded_should_reject() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&CONSUMER_OFFSETS_MAGIC_V1);
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(&0u32.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::UnsupportedVersion {
version: CONSUMER_OFFSETS_VERSION
})
);
}
#[test]
fn given_dedup_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(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&(CONSUMER_OFFSETS_ENTRIES_MAX + 1).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: "dedup",
count: CONSUMER_OFFSETS_ENTRIES_MAX + 1,
max: CONSUMER_OFFSETS_ENTRIES_MAX,
})
);
}
#[test]
fn given_dedup_count_exceeding_bytes_when_decoded_should_reject_as_truncated() {
// Under the ceiling but past the bytes present: the stride guard is
// what stops a ~30 byte artifact reserving megabytes.
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(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&1_000u32.to_le_bytes());
let trailer = state_artifact_checksum(&bytes);
bytes.extend_from_slice(&trailer.to_le_bytes());
assert_eq!(
ConsumerOffsetsWire::decode(&bytes),
Err(ConsumerOffsetsWireError::Truncated)
);
}
#[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());
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(),
dedup: 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)],
dedup: Vec::new(),
};
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"
);
}
#[test]
fn given_offset_count_above_transfer_ceiling_when_validated_should_reject() {
assert!(validate_consumer_offset_transfer_count(ConsumerKind::Consumer, 4, 4).is_ok());
assert!(matches!(
validate_consumer_offset_transfer_count(ConsumerKind::ConsumerGroup, 5, 4),
Err(PartitionTransferUnavailable::ConsumerOffsetsTooLarge {
kind: ConsumerKind::ConsumerGroup,
count: 5,
max: 4,
})
));
let error = validate_consumer_offset_transfer_count(ConsumerKind::Consumer, 5, 4)
.expect_err("count above ceiling");
assert!(
!error.transient(),
"an artifact that cannot fit the decoder will not heal by retrying"
);
}
}
/// 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(&timestamp.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,
ConsumerOffsetsTooLarge {
kind: ConsumerKind,
count: usize,
max: usize,
},
ConsumerOffsetStateInconsistent {
kind: ConsumerKind,
consumer_id: u32,
},
/// 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::ConsumerOffsetsTooLarge { .. }
| Self::ConsumerOffsetStateInconsistent { .. }
| 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::ConsumerOffsetsTooLarge { kind, count, max } => write!(
f,
"partition has {count} {kind:?} offset entries, past the {max} transfer ceiling"
),
Self::ConsumerOffsetStateInconsistent { kind, consumer_id } => write!(
f,
"durable {kind:?} offset {consumer_id} is missing from the live map"
),
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. Offset files must land before the installed
/// commit floor advances. A failed purge-generation record remains retryable.
#[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,
/// The offered purge generation was already recorded or persisted during
/// this install. False means a restart may repeat the purge and transfer.
pub purge_generation_recorded: bool,
}
/// Failure installing a transferred partition state.
///
/// Validation failures mutate nothing. Pre-swap offset failures can leave
/// ignored replacement siblings when best-effort cleanup also fails, but never
/// alter live files.
#[derive(Debug)]
pub enum PartitionInstallError {
NoPartitionDir,
NoOffsetDir {
kind: ConsumerKind,
},
/// `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,
},
/// Consumer-offset staging failed before the segment swap, or finalizing a
/// staged offset failed during it.
OffsetPersistence {
path: String,
source: iggy_common::IggyError,
},
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::NoOffsetDir { kind } => {
write!(f, "partition has no {kind:?} offset directory configured")
}
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::OffsetPersistence { path, source } => {
write!(f, "consumer offset persistence failed at {path}: {source}")
}
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, ANCHOR_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 four-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;
const OFFSET_IO_ATTEMPTS: usize = 3;
/// First retry delay of [`retry_offset_mutation`]; each further retry doubles it.
const OFFSET_IO_BACKOFF_BASE: std::time::Duration = std::time::Duration::from_millis(10);
async fn retry_offset_mutation<T, E: fmt::Debug, F: Future<Output = Result<T, E>>>(
mut operation: impl FnMut() -> F,
) -> Result<T, E> {
for attempt in 1..OFFSET_IO_ATTEMPTS {
match operation().await {
Ok(value) => return Ok(value),
Err(error) => {
tracing::debug!(
attempt,
?error,
"offset mutation failed, retrying after backoff"
);
compio::time::sleep(OFFSET_IO_BACKOFF_BASE * (1 << (attempt - 1))).await;
}
}
}
operation().await.inspect_err(|error| {
tracing::debug!(
attempt = OFFSET_IO_ATTEMPTS,
?error,
"offset mutation failed on the last attempt"
);
})
}
/// 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,
}
pub(crate) const fn consumer_kind_index(kind: ConsumerKind) -> usize {
match kind {
ConsumerKind::Consumer => 0,
ConsumerKind::ConsumerGroup => 1,
}
}
async fn stage_offset_writes(planned: &[PlannedOffsetWrite]) -> Result<(), PartitionInstallError> {
for batch in planned.chunks(OFFSET_PERSIST_CONCURRENCY) {
let writes = batch.iter().map(|write| async move {
(
&write.path,
retry_offset_mutation(|| stage_offset_replacement(&write.path, write.value)).await,
)
});
for (path, result) in futures::future::join_all(writes).await {
if let Err(source) = result {
discard_offset_writes(planned).await;
return Err(PartitionInstallError::OffsetPersistence {
path: path.clone(),
source,
});
}
}
}
Ok(())
}
async fn discard_offset_writes(planned: &[PlannedOffsetWrite]) {
for write in planned {
discard_offset_replacement(&write.path).await;
}
}
/// 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,
{
fn plan_transfer_offset_writes(
&self,
offsets_wire: &ConsumerOffsetsWire,
next_offset: u64,
) -> Result<Vec<PlannedOffsetWrite>, PartitionInstallError> {
let consumer_dir =
self.consumer_offsets_path
.as_deref()
.ok_or(PartitionInstallError::NoOffsetDir {
kind: ConsumerKind::Consumer,
})?;
let group_dir = self.consumer_group_offsets_path.as_deref().ok_or(
PartitionInstallError::NoOffsetDir {
kind: ConsumerKind::ConsumerGroup,
},
)?;
let clamp = |offset: u64| next_offset.checked_sub(1).map(|last| offset.min(last));
let mut planned =
Vec::with_capacity(offsets_wire.consumers.len() + offsets_wire.groups.len());
for (kind, dir, offsets) in [
(
ConsumerKind::Consumer,
consumer_dir,
&offsets_wire.consumers,
),
(ConsumerKind::ConsumerGroup, group_dir, &offsets_wire.groups),
] {
planned.extend(offsets.iter().filter_map(|(id, offset)| {
clamp(*offset).map(|value| PlannedOffsetWrite {
kind,
id: *id,
path: format!("{dir}/{id}"),
value,
})
}));
}
Ok(planned)
}
/// 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));
}
// An empty chain at frontier 0 tells the receiver to unlink its own, so
// serve it only when a recorded purge says the emptiness is the truth.
// `install_state_transfer`'s `purge_advances` check re-decides that
// against the metadata plane and refuses the rest.
let offsets_wire = self.offsets_wire_snapshot()?;
if segments.is_empty()
&& offsets_wire.next_offset == 0
&& offsets_wire.purge_generation == 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();
}
fn validate_consumer_offset_transfer_counts(&self) -> Result<(), PartitionTransferUnavailable> {
for kind in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup] {
let count = self.durable_consumer_offsets.count(kind);
if let Err(error) = validate_consumer_offset_transfer_count(
kind,
count,
CONSUMER_OFFSETS_ENTRIES_MAX as usize,
) {
tracing::error!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw = self.consensus().group(),
?kind,
count,
max = CONSUMER_OFFSETS_ENTRIES_MAX,
"consumer offset state exceeds the transfer ceiling"
);
return Err(error);
}
}
Ok(())
}
/// Snapshot committed durable offsets only. Eager auto-commit progress and
/// follower-local cursor entries stay in the live maps until a replicated
/// store commits them, so neither can be promoted by state transfer.
fn offsets_wire_snapshot(&self) -> Result<ConsumerOffsetsWire, PartitionTransferUnavailable> {
self.validate_consumer_offset_transfer_counts()?;
let consumer_map = self.consumer_offsets.pin();
let consumers = self.snapshot_offset_kind(ConsumerKind::Consumer, |id| {
consumer_map.contains_key(&(id as usize))
})?;
let group_map = self.consumer_group_offsets.pin();
let groups = self.snapshot_offset_kind(ConsumerKind::ConsumerGroup, |id| {
group_map.contains_key(&ConsumerGroupId(id as usize))
})?;
// 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();
let dedup = self.dedup().watermarks_sorted();
Ok(ConsumerOffsetsWire {
purge_generation: self.applied_purge_generation,
next_offset,
consumers,
groups,
dedup,
})
}
fn snapshot_offset_kind(
&self,
kind: ConsumerKind,
map_contains: impl Fn(u32) -> bool,
) -> Result<Vec<(u32, u64)>, PartitionTransferUnavailable> {
self.durable_consumer_offsets.with_entries(kind, |entries| {
let mut snapshot = Vec::with_capacity(entries.len());
for (&consumer_id, state) in entries {
if !map_contains(consumer_id) {
return Err(
PartitionTransferUnavailable::ConsumerOffsetStateInconsistent {
kind,
consumer_id,
},
);
}
snapshot.push((consumer_id, state.committed_offset));
}
snapshot.sort_unstable_by_key(|(id, _)| *id);
Ok(snapshot)
})
}
#[cfg(test)]
pub(crate) fn offsets_wire_snapshot_for_test(
&self,
) -> Result<Vec<(u32, u64)>, PartitionTransferUnavailable> {
self.offsets_wire_snapshot().map(|wire| wire.consumers)
}
/// 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.
///
/// Validation runs before live-state mutation. Offset replacement siblings
/// are then written and synced while the old partition remains intact. The
/// segment swap's crash windows recover as an honestly-shorter partition
/// (see the swap ordering comments); boot re-derives from surviving files.
///
/// # 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 live state. Staging
// writes only sibling files the install can abandon. ----
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 hydrates from `purge.gen`, which a kill before the purge's
// record step leaves absent or stale, so a post-restart rejoin of an
// ever-purged topic could see `offered > 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.
// Second disjunct: this replica has NOT applied the committed purge, so
// its frontier still measures the pre-purge offset space and cannot be
// compared against a post-purge offer. Restricted to `next_offset == 0`
// -- the state a purge leaves before anything is appended -- so an
// origin that merely lags within the same purge era still fails the
// fence rather than rewinding this replica's durable post-purge data.
let purge_advances = offsets_wire.purge_generation > committed_purge_generation
|| (self.applied_purge_generation < committed_purge_generation
&& offsets_wire.next_offset == 0);
// The COMMITTED frontier, which is what an offer is comparable against:
// `held_offset_frontier` reads 0 for a chain installed empty at frontier
// N (its disk arm filters empty segments and the install clears the
// journal), and a 0 skips the guard below entirely, letting a stale offer
// rewind the counter under data this replica already claimed. The append
// point is not usable either -- it can stand a lease block high -- but
// only on a solo group, which never receives an offer.
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,
});
}
}
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 planned_offsets = self.plan_transfer_offset_writes(&offsets_wire, next_offset)?;
// Write and data-sync every small offset record before the destructive
// segment swap. Only ignored, nonnumeric replacement siblings exist at
// this point, so a write fault leaves the old partition serviceable.
stage_offset_writes(&planned_offsets).await?;
// ---- 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.
//
// Identical to the advancing form on every group that can receive an
// offer today, since the reservation is solo-only and there equals the
// frontier. Spelled out because the shape is what makes it a reset, not
// the arithmetic that currently coincides.
let frontier_durable = if purge_advances {
self.reset_offset_frontier_at(offsets_wire.next_offset)
.await
} else {
self.install_offset_frontier_at(offsets_wire.next_offset)
.await
};
if !frontier_durable {
discard_offset_writes(&planned_offsets).await;
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,
&planned_offsets,
&partition_dir,
next_offset,
)
.await;
if outcome.is_err() {
discard_offset_writes(&planned_offsets).await;
// 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().group(),
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, clippy::too_many_arguments)]
async fn apply_checked_install(
&mut self,
config: &PartitionsConfig,
commit_op: u64,
staged: Vec<StagedSegmentMeta>,
offsets_wire: &ConsumerOffsetsWire,
planned_offsets: &[PlannedOffsetWrite],
partition_dir: &str,
next_offset: u64,
) -> 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().group();
while let Some((segment, mut storage)) = self.log.retire_front() {
let (messages_path, index_path) = storage.segment_and_index_paths();
let _ = storage.shutdown();
drop(storage);
// Anchors go with the chain they describe, as everywhere else.
// Unreachable for an install today, since anchors are planted only
// by the solo boot re-anchor, but a record outliving its segment is
// the one way a later gap gets admitted for free.
for path in messages_path
.into_iter()
.chain(index_path)
.chain(self.anchor_cleanup_path(segment.start_offset))
{
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, 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, self.effective_segment_size(config));
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 enforce_fsync = self.effective_enforce_fsync(config);
let segment_size = self.effective_segment_size(config);
let preallocate_segments = self.effective_preallocate_segments(config);
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(),
enforce_fsync,
true,
preallocate_segments.then_some(segment_size),
)
.await
.map_err(|source| PartitionInstallError::SegmentOpen {
path: messages_reader.path(),
source,
})?;
let index_writer = IggyIndexWriter::new(
&index_reader.path(),
index_w.size_counter(),
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 active and sealed read-state caches live under different
// rules (see `SegmentedLog::reset_read_state`), so the slot cannot
// carry its sealed identity into active use.
self.log.reset_read_state(last);
}
// 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);
// 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<(ConsumerKind, u32, String)> = {
let guard = self.consumer_offsets.pin();
let mut paths: Vec<(ConsumerKind, u32, String)> = 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)
.map(|path| (ConsumerKind::Consumer, id, path))
})
})
.collect();
guard.clear();
// The map is not the whole truth about what is on disk: a repaired
// pre-purge offset op persists a file this incarnation never held,
// and a purged origin offering `next_offset = 0` drops every
// incoming entry, so a map-only sweep leaves the old table for boot
// to resurrect.
paths.extend(
strayed_offset_files(self.consumer_offsets_path.as_deref())
.into_iter()
.filter_map(|path| {
numeric_offset_id(&path).map(|id| (ConsumerKind::Consumer, id, path))
}),
);
paths
};
let old_group_paths: Vec<(ConsumerKind, u32, String)> = {
let guard = self.consumer_group_offsets.pin();
let mut paths: Vec<(ConsumerKind, u32, String)> = 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)
.map(|path| (ConsumerKind::ConsumerGroup, id, path))
})
})
.collect();
guard.clear();
paths.extend(
strayed_offset_files(self.consumer_group_offsets_path.as_deref())
.into_iter()
.filter_map(|path| {
numeric_offset_id(&path).map(|id| (ConsumerKind::ConsumerGroup, id, path))
}),
);
paths
};
let mut offset_dirs_changed = [false; 2];
let planned_ids: HashSet<_> = planned_offsets
.iter()
.map(|write| (write.kind, write.id))
.collect();
// Replacements atomically overwrite their old files. Only keys absent
// from the clamped plan require unlinking, including every key when
// the incoming message frontier is empty.
let old_paths: HashMap<_, _> = old_consumer_paths
.into_iter()
.chain(old_group_paths)
.filter(|(kind, id, _)| !planned_ids.contains(&(*kind, *id)))
.map(|(kind, id, path)| ((kind, id), path))
.collect();
for ((kind, consumer_id), path) in old_paths {
// An obsolete authoritative file must not survive a successful
// install because boot would reload it outside the incoming table.
match delete_persisted_offset(&path).await {
Ok(removed) => {
if removed {
offset_dirs_changed[consumer_kind_index(kind)] = true;
}
self.consumer_offset_capacity_for(kind)
.clear_stranded(consumer_id);
}
Err(error) => {
self.consumer_offset_capacity_for(kind)
.record_stranded(consumer_id);
tracing::warn!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw = self.consensus().group(),
path,
consumer_id,
%error,
"install could not remove a superseded consumer offset file"
);
return Err(PartitionInstallError::OffsetPersistence {
path,
source: error,
});
}
}
}
self.durable_consumer_offsets.clear();
self.pending_consumer_offset_commits.clear();
self.queued_auto_commit_reservations.borrow_mut().clear();
self.consumer_offset_capacity
.rebuild(&self.durable_consumer_offsets, std::iter::empty());
self.consumer_group_offset_capacity
.rebuild(&self.durable_consumer_offsets, std::iter::empty());
self.last_polled_offsets.pin().clear();
// The replacement siblings were written and data-synced before any
// segment mutation. Finalize only their directory entries here, then
// publish the matching maps and durable membership.
self.dedup_mut()
.install_watermarks(offsets_wire.dedup.iter().copied());
for write in planned_offsets {
// A rename failure after the segment swap leaves an incomplete
// install. Propagate it to convergence rather than acknowledge
// mixed state or retry a standalone directory barrier.
commit_offset_replacement(&write.path)
.await
.map_err(|source| PartitionInstallError::OffsetPersistence {
path: write.path.clone(),
source,
})?;
offset_dirs_changed[consumer_kind_index(write.kind)] = true;
let entry = ConsumerOffset::new(write.kind, write.id, write.value, write.path.clone());
match write.kind {
ConsumerKind::Consumer => {
self.consumer_offsets.pin().insert(write.id as usize, entry);
}
ConsumerKind::ConsumerGroup => {
self.consumer_group_offsets
.pin()
.insert(ConsumerGroupId(write.id as usize), entry);
}
}
self.durable_consumer_offsets.record_explicit(
write.kind,
write.id,
write.value,
write.value,
);
self.consumer_offset_capacity_for(write.kind)
.clear_stranded(write.id);
}
// One observation per changed directory. Retrying with a newly opened
// handle can mask the writeback error that the first fsync consumed.
for (changed, dir) in offset_dirs_changed.into_iter().zip([
self.consumer_offsets_path.as_deref(),
self.consumer_group_offsets_path.as_deref(),
]) {
if changed {
let dir = dir.expect("planned offset directory was validated before mutation");
fsync_dir(dir)
.await
.map_err(|source| PartitionInstallError::SwapIo {
path: dir.to_owned(),
source,
})?;
}
}
// 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.set_offset_space_used(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. Recorded durably
// for the same reason the purge itself records it: the reconciler
// gate hydrates from `purge.gen` at boot, so a memory-only stamp
// would make a restart re-purge the just-installed data and pull it
// all over again. A write failure only re-opens that restart window
// (the wipe-then-retransfer is self-healing, peers keep the data),
// so it is reported separately from the mandatory offset writes.
let mut purge_generation_recorded = true;
if offsets_wire.purge_generation > self.applied_purge_generation
&& let Some(dir) = self.partition_dir.clone()
{
let path = format!("{dir}/{PURGE_GENERATION_FILE}");
if let Err(error) = persist_purge_generation(
&path,
offsets_wire.purge_generation,
self.created_revision,
)
.await
{
tracing::warn!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw = self.consensus().group(),
purge_generation = offsets_wire.purge_generation,
%error,
"state-transfer install could not record the offered purge \
generation; a restart before the next purge records it will \
re-purge and re-transfer this partition"
);
purge_generation_recorded = false;
}
}
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.clear_pipeline();
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,
purge_generation_recorded,
})
}
/// 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`]).
#[allow(clippy::too_many_lines)]
async fn converge_to_empty_after_failed_install(
&mut self,
config: &PartitionsConfig,
minted_next_offset: u64,
staged_was_empty: bool,
) -> Result<(), iggy_common::IggyError> {
// The empty plant below can land on a base offset this sweep unlinks,
// so an in-flight poll's cached read fd would keep serving the retired
// inodes as live data. Same hazard and same fix as `purge`.
self.log.invalidate_sealed_read_state();
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();
self.consumer_offsets.pin().clear();
self.consumer_group_offsets.pin().clear();
self.last_polled_offsets.pin().clear();
self.durable_consumer_offsets.clear();
self.pending_consumer_offset_commits.clear();
self.queued_auto_commit_reservations.borrow_mut().clear();
for kind in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup] {
self.consumer_offset_capacity_for(kind)
.rebuild(&self.durable_consumer_offsets, std::iter::empty());
}
for (kind, dir) in [
(ConsumerKind::Consumer, self.consumer_offsets_path.as_ref()),
(
ConsumerKind::ConsumerGroup,
self.consumer_group_offsets_path.as_ref(),
),
] {
let Some(dir) = dir else { continue };
for entry in offset_dir_entries(dir) {
match entry {
OffsetDirEntry::Replacement(path) => {
if let Err(error) = compio::fs::remove_file(&path).await {
tracing::warn!(
path,
%error,
"could not remove an abandoned offset replacement"
);
}
}
OffsetDirEntry::Offset { id, path } => {
// A remaining authoritative file prevents convergence.
match retry_offset_mutation(|| delete_persisted_offset(&path)).await {
Ok(_) => self.consumer_offset_capacity_for(kind).clear_stranded(id),
Err(error) => {
self.consumer_offset_capacity_for(kind).record_stranded(id);
tracing::warn!(
path,
consumer_id = id,
%error,
"converge could not remove a consumer offset file"
);
return Err(error);
}
}
}
}
}
// No `exists()` probe: that is a blocking stat on the pump. A
// directory the unlinks emptied and removed has nothing left to
// make durable.
match fsync_dir(dir).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return Err(iggy_common::IggyError::CannotSyncFile),
}
}
// 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();
// Degrade to at-least-once rather than keep watermarks that may now
// describe data this partition no longer holds: a stale entry would
// absorb a replay whose original was just unlinked.
self.dedup_mut().install_watermarks(std::iter::empty());
// 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, ANCHOR_SUFFIX]
.iter()
.any(|extension| path.ends_with(extension))
})
})
.collect(),
Err(error) => {
tracing::error!(
target: "iggy.partitions.diag",
plane = "partitions",
namespace_raw = self.consensus().group(),
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().group(),
&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.set_offset_space_used(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;
/// 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. Sized against the yield's real cost:
/// `yield_to_reactor` is ~12 us per call, so at 1 MiB (~21 us of hashing per
/// chunk) the yields would add over half the pass again; 4 MiB keeps the
/// un-yielded stretch a bounded ~80 us CPU pass at ~15% overhead, and
/// matches the recovery walk's `SCAN_WINDOW_CAPACITY`.
const OFFER_HASH_CHUNK_LEN: usize = 4 << 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()
}
/// One regular file of a consumer-offset directory, classified by name.
enum OffsetDirEntry {
/// A sibling an atomic replacement left behind.
Replacement(String),
/// A bare-u32 offset file and its id.
Offset { id: u32, path: String },
}
/// The offset files and abandoned replacements under `dir`, in one pass.
///
/// A file whose name is neither a bare u32 nor a replacement sibling is left
/// alone rather than guessed at: every offset file is named by its id, so
/// anything else is not ours.
fn offset_dir_entries(dir: &str) -> Vec<OffsetDirEntry> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.filter_map(|entry| {
if !entry.file_type().ok()?.is_file() {
return None;
}
let name = entry.file_name().into_string().ok()?;
let path = format!("{dir}/{name}");
if offset_replacement_id(&name).is_some() {
return Some(OffsetDirEntry::Replacement(path));
}
let id: u32 = name.parse().ok()?;
Some(OffsetDirEntry::Offset { id, path })
})
.collect()
}
/// Every offset file under `dir`.
///
/// The live map cannot name these: a pre-purge offset op replayed by journal
/// repair persists a file this incarnation never held. Left behind, boot
/// hydrates them back.
pub(crate) fn strayed_offset_files(dir: Option<&str>) -> Vec<String> {
dir.map(offset_dir_entries)
.unwrap_or_default()
.into_iter()
.filter_map(|entry| match entry {
OffsetDirEntry::Offset { path, .. } => Some(path),
OffsetDirEntry::Replacement(_) => None,
})
.collect()
}
pub(crate) fn numeric_offset_id(path: &str) -> Option<u32> {
Path::new(path).file_name()?.to_str()?.parse().ok()
}
/// 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"
);
}