This is an automated email from the ASF dual-hosted git repository.
numinnex pushed a commit to branch partition_superblock
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/partition_superblock by this
push:
new 78f6d9da9 addres review comments
78f6d9da9 is described below
commit 78f6d9da9b3634fb2622970ef9ec637f8a0e2fd3
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Thu Aug 6 07:26:23 2026 +0200
addres review comments
---
core/binary_protocol/src/consensus/header.rs | 19 +-
core/configs/src/server_ng_config/defaults.rs | 5 +
core/configs/src/server_ng_config/partition.rs | 29 +
core/consensus/src/impls.rs | 3 +
core/consensus/src/state_transfer.rs | 53 +-
core/consensus/src/vsr_state.rs | 24 +-
.../server/scenarios/purge_delete_scenario.rs | 18 +-
core/journal/src/local_gate.rs | 97 +++
core/metadata/src/impls/recovery.rs | 1 +
core/metadata/src/lib.rs | 4 +
core/partitions/src/iggy_partition.rs | 87 ++-
core/partitions/src/journal.rs | 36 +-
core/partitions/src/state_transfer.rs | 344 +++++++++--
core/server-ng/config.toml | 14 +
core/server-ng/src/bootstrap.rs | 57 +-
core/server-ng/src/partition_helpers.rs | 52 +-
core/server-ng/src/segment_recovery.rs | 71 ++-
core/server-ng/src/server_error.rs | 2 +-
core/shard/src/lib.rs | 662 ++++++++++++++-------
core/shard/src/metrics.rs | 14 +
core/shard/src/router.rs | 23 +-
21 files changed, 1276 insertions(+), 339 deletions(-)
diff --git a/core/binary_protocol/src/consensus/header.rs
b/core/binary_protocol/src/consensus/header.rs
index 12213676b..e71f1004c 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -1416,11 +1416,14 @@ pub struct StateTransferTargetHeader {
pub namespace: u64,
pub available: u8,
/// Set on an `available == 0` refusal that means "not right now" rather
than
- /// "this node is broken": the requester then re-arms on a flat interval
- /// instead of charging its consecutive-failure count, whose exponential
- /// backoff climbs to 1024x the retry interval and is reset only by a
- /// completed install. A serving primary momentarily behind its own
frontier
- /// is the common case under produce load.
+ /// "this node is broken".
+ ///
+ /// PARTITION arm only: it is the only side with a consecutive-failure
count
+ /// to charge. The requester then re-arms on a flat interval instead of
+ /// charging that count, whose exponential backoff climbs to 1024x the
retry
+ /// interval and is reset only by a completed install. A serving primary
+ /// momentarily behind its own frontier is the common case under produce
+ /// load.
///
/// This and `commit_max` below claim the HEAD of what used to be the
/// reserved tail, so every pre-existing field keeps its published offset:
@@ -1434,7 +1437,11 @@ pub struct StateTransferTargetHeader {
pub reserved_alignment: [u8; 6],
/// Serving replica's `commit_max` when the descriptor was built.
///
- /// A receiver refuses an offer from a replica that knows LESS than it
does:
+ /// Read by the PARTITION receiver only; the metadata arm branches on
+ /// `available` and falls back to journal repair without a refusal.
+ ///
+ /// A partition receiver refuses an offer from a replica that knows LESS
+ /// than it does:
/// without this the descriptor carried no proof of the sender's own
/// progress, and a phantom view-0 primary (a group whose directory
vanished
/// boots `init()` rather than `init_as_backup()`, comes up Normal at view
0,
diff --git a/core/configs/src/server_ng_config/defaults.rs
b/core/configs/src/server_ng_config/defaults.rs
index e68f0889c..40695a63e 100644
--- a/core/configs/src/server_ng_config/defaults.rs
+++ b/core/configs/src/server_ng_config/defaults.rs
@@ -176,6 +176,11 @@ impl Default for PartitionConfig {
prepare_queue_depth: partition.prepare_queue_depth as usize,
evicted_ring_capacity: partition.evicted_ring_capacity as usize,
evicted_ring_bytes_max:
partition.evicted_ring_bytes_max.parse().unwrap(),
+ transfer_served_cache_bytes_max: partition
+ .transfer_served_cache_bytes_max
+ .parse()
+ .unwrap(),
+ transfer_artifact_bytes_max:
partition.transfer_artifact_bytes_max.parse().unwrap(),
}
}
}
diff --git a/core/configs/src/server_ng_config/partition.rs
b/core/configs/src/server_ng_config/partition.rs
index 580668e4b..66d428fd7 100644
--- a/core/configs/src/server_ng_config/partition.rs
+++ b/core/configs/src/server_ng_config/partition.rs
@@ -95,6 +95,27 @@ pub struct PartitionConfig {
/// [`MAX_EVICTED_RING_BYTES`].
#[config_env(leaf)]
pub evicted_ring_bytes_max: IggyByteSize,
+
+ /// Byte budget for segment payloads a SERVING shard keeps resident to
+ /// answer state-transfer chunk requests, per shard (so the process-wide
+ /// bound is this times the shard count).
+ ///
+ /// Sized for concurrent pulls, not one: at exactly one maximum segment a
+ /// single receiver arming several transfers thrashes the cache by itself,
+ /// and every miss re-reads and re-hashes a whole segment to serve one
+ /// chunk. Must be > 0.
+ #[config_env(leaf)]
+ pub transfer_served_cache_bytes_max: IggyByteSize,
+
+ /// Alloc ceiling for ONE received state-transfer artifact, per shard.
+ ///
+ /// A receiver holds the whole artifact resident through verify, walk and
+ /// staging write, so the in-flight cap multiplies this. It must stay above
+ /// the largest legal segment (`segment.size` plus the one batch a segment
+ /// may overshoot it by) or legal segments are rejected deterministically.
+ /// Must be > 0.
+ #[config_env(leaf)]
+ pub transfer_artifact_bytes_max: IggyByteSize,
}
impl Validatable<ConfigurationError> for PartitionConfig {
@@ -121,6 +142,14 @@ impl Validatable<ConfigurationError> for PartitionConfig {
);
return Err(ConfigurationError::InvalidConfigurationValue);
}
+ if self.transfer_served_cache_bytes_max.as_bytes_u64() == 0 {
+ eprintln!("{COMPONENT_NG}
partition.transfer_served_cache_bytes_max must be > 0");
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
+ if self.transfer_artifact_bytes_max.as_bytes_u64() == 0 {
+ eprintln!("{COMPONENT_NG} partition.transfer_artifact_bytes_max
must be > 0");
+ return Err(ConfigurationError::InvalidConfigurationValue);
+ }
let ring_bytes = self.evicted_ring_bytes_max.as_bytes_u64();
if ring_bytes == 0 {
eprintln!("{COMPONENT_NG} partition.evicted_ring_bytes_max must be
> 0");
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index dda3fa9a8..d23378f15 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -1527,6 +1527,9 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>>
VsrConsensus<B, P> {
commit_max: self.commit_max.get(),
checkpoint_op,
checkpoint_checksum,
+ // Consensus mints no message offsets: the PARTITION plane stamps
+ // this in before it writes (`IggyPartition::write_superblock`).
+ offset_frontier: 0,
}
}
diff --git a/core/consensus/src/state_transfer.rs
b/core/consensus/src/state_transfer.rs
index 0a1ff3768..f5cf14e8e 100644
--- a/core/consensus/src/state_transfer.rs
+++ b/core/consensus/src/state_transfer.rs
@@ -39,7 +39,10 @@ pub const STATE_TRANSFER_MAX_STALL_RETRIES: u32 = 5;
/// Decode-failure rounds a receiver spends on ONE offered generation
/// before refusing to pull it again.
///
-/// METADATA plane only, keyed on `snapshot_seq`: a peer whose snapshot
+/// Read by the METADATA arm only -- it lives here because the chunk cursor it
+/// pairs with is plane-agnostic, not because both planes use it.
+///
+/// Keyed on `snapshot_seq`: a peer whose snapshot
/// generation advances resets the budget (new bytes are worth full
/// retries), while a generation this build cannot decode costs one refused
/// descriptor per repair round instead of a full pull. The partition plane
@@ -60,13 +63,6 @@ pub struct ArtifactProgress {
pub buf: Vec<u8>,
}
-impl ArtifactProgress {
- #[must_use]
- pub const fn complete(&self) -> bool {
- self.buf.len() as u64 == self.entry.len
- }
-}
-
/// What [`next_pending_chunk`] and [`append_chunk`] need from one slot.
///
/// Exists so a plane can track completion in richer shapes -- the
@@ -79,6 +75,11 @@ pub trait ChunkProgress {
/// Only called after the cursor checks `received + payload <= declared`,
/// so an impl whose slot cannot grow (already complete) never sees it.
fn extend_from_chunk(&mut self, payload: &[u8]);
+ /// Reserve room for the whole declared length, called once per artifact on
+ /// its FIRST chunk (see [`append_chunk`]). Default: nothing, for slots
that
+ /// do not accumulate in memory. Reserving at accept time instead would
+ /// commit address space for every manifest entry at once.
+ fn reserve_declared(&mut self) {}
fn complete(&self) -> bool {
self.received_len() == self.declared_len()
}
@@ -94,18 +95,16 @@ impl ChunkProgress for ArtifactProgress {
}
fn extend_from_chunk(&mut self, payload: &[u8]) {
- // Reserved on the FIRST chunk of this artifact, not when the manifest
is
- // accepted: reserving every artifact up front is an eager
address-space
- // commit of the whole manifest, and a receiver only ever pulls one
- // artifact at a time. Exact rather than geometric -- `entry.len`
already
- // passed the caller's per-kind caps, and doubling to gigabyte sizes
would
- // copy roughly twice the bytes at a ~1.5x transient peak.
- if self.buf.is_empty() {
- #[allow(clippy::cast_possible_truncation)]
- self.buf.reserve_exact(self.entry.len as usize);
- }
self.buf.extend_from_slice(payload);
}
+
+ fn reserve_declared(&mut self) {
+ // Exact rather than geometric: `entry.len` already passed the caller's
+ // per-kind caps, and doubling to gigabyte sizes copies roughly twice
the
+ // bytes at a ~1.5x transient peak.
+ #[allow(clippy::cast_possible_truncation)]
+ self.buf.reserve_exact(self.entry.len as usize);
+ }
}
/// Next `(artifact index, offset, len)` to request.
@@ -138,13 +137,12 @@ pub fn next_pending_chunk<T: ChunkProgress>(
/// lockstep, so anything else is a duplicate or reorder -- the stall retry
/// re-requests from the current frontier), an overrun past the declared
/// length, and a zero-byte payload. The first-incomplete restriction mirrors
-/// what [`next_pending_chunk`] would have requested: without it a peer that
-/// pushes one byte into EVERY manifest entry makes each artifact's
-/// first-chunk `reserve_exact` fire, committing address space for the sum of
-/// all declared lengths at once -- the whole-manifest reservation the
-/// receiver deliberately refuses to make up front, and a failed `Vec`
-/// reservation aborts the process rather than erroring. A zero-byte payload
-/// is not progress: it extends nothing and the same offset is re-requested
+/// what [`next_pending_chunk`] would have requested, and bounds the
+/// reservation below to ONE artifact at a time: without it a peer that pushes
+/// one byte into every manifest entry would make each slot reserve its whole
+/// declared length, committing address space for the sum of the manifest, and
+/// a failed `Vec` reservation aborts the process rather than erroring. A
+/// zero-byte payload is not progress: it extends nothing and the same offset
is re-requested
/// immediately, and resetting liveness counters on one is what turned a
/// short rebuilt offer into an unbounded empty-frame ping-pong on the
/// metadata plane. The serving side refuses to produce these now; the guard
@@ -177,6 +175,11 @@ pub fn append_chunk<T: ChunkProgress>(
if payload.is_empty() {
return false;
}
+ // First chunk of this artifact: give the slot its full declared length in
+ // one allocation, so a segment-sized artifact is not grown by doubling.
+ if artifact.received_len() == 0 {
+ artifact.reserve_declared();
+ }
artifact.extend_from_chunk(payload);
true
}
diff --git a/core/consensus/src/vsr_state.rs b/core/consensus/src/vsr_state.rs
index 6523f38dc..bdef87c32 100644
--- a/core/consensus/src/vsr_state.rs
+++ b/core/consensus/src/vsr_state.rs
@@ -31,8 +31,8 @@ use std::fmt;
/// Number of bytes [`VsrState::to_bytes`] produces and [`VsrState::try_from`]
/// expects: `cluster`(16) + `replica_id`(1) + `replica_count`(1) + `view`(4)
/// + `log_view`(4) + `commit_max`(8) + `checkpoint_op`(8)
-/// + `checkpoint_checksum`(16).
-pub const ENCODED_LEN: usize = 58;
+/// + `checkpoint_checksum`(16) + `offset_frontier`(8).
+pub const ENCODED_LEN: usize = 66;
/// The durable consensus state of one replica for one consensus group.
///
@@ -69,6 +69,22 @@ pub struct VsrState {
/// Integrity tag of the paired checkpoint, detecting a torn
/// snapshot/superblock pairing across a crash.
pub checkpoint_checksum: u128,
+ /// PARTITION plane: the next message offset this replica will mint, or `0`
+ /// for a group whose offset space is still empty.
+ ///
+ /// A durable LOWER BOUND, not a completeness claim: boot takes the max of
+ /// this and whatever the recovered segments prove. It exists because
+ /// nothing else durably names the frontier once the segments that carried
+ /// it are gone -- a state-transfer install of an all-GC'd origin, a crash
+ /// inside the install's swap window, and the fence-and-rebuild path all
+ /// leave a replica whose counter would otherwise restart at 0 while the
+ /// group is at N. That is not a lag: replicas re-stamp `base_offset` from
+ /// this counter and recompute `batch_checksum` over it, so the next
+ /// replicated prepare would persist different bytes here than on every
+ /// peer, silently.
+ ///
+ /// Always `0` on the metadata plane, which mints no message offsets.
+ pub offset_frontier: u64,
}
impl VsrState {
@@ -84,6 +100,7 @@ impl VsrState {
out[26..34].copy_from_slice(&self.commit_max.to_le_bytes());
out[34..42].copy_from_slice(&self.checkpoint_op.to_le_bytes());
out[42..58].copy_from_slice(&self.checkpoint_checksum.to_le_bytes());
+ out[58..66].copy_from_slice(&self.offset_frontier.to_le_bytes());
out
}
}
@@ -108,6 +125,7 @@ impl TryFrom<&[u8]> for VsrState {
commit_max: u64::from_le_bytes(field(bytes, 26)),
checkpoint_op: u64::from_le_bytes(field(bytes, 34)),
checkpoint_checksum: u128::from_le_bytes(field(bytes, 42)),
+ offset_frontier: u64::from_le_bytes(field(bytes, 58)),
};
// A record violating `log_view <= view` decodes into a replica that
looks
// healthy locally while `DoViewChangeHeader::validate` makes every
peer drop
@@ -178,6 +196,7 @@ mod tests {
commit_max: 6,
checkpoint_op: 7,
checkpoint_checksum: 8,
+ offset_frontier: 0,
};
let bytes = state.to_bytes();
assert_eq!(bytes.len(), ENCODED_LEN);
@@ -209,6 +228,7 @@ mod tests {
commit_max: 0,
checkpoint_op: 0,
checkpoint_checksum: 0,
+ offset_frontier: 0,
}
.to_bytes();
bytes[22] = 5; // log_view = 5, view stays 4
diff --git a/core/integration/tests/server/scenarios/purge_delete_scenario.rs
b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
index 97cf1c816..606002701 100644
--- a/core/integration/tests/server/scenarios/purge_delete_scenario.rs
+++ b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
@@ -994,12 +994,18 @@ pub async fn run_purge_topic(harness: &mut TestHarness,
restart_server: bool) {
// files in the SAME frame that plants the layout, so the instant assert is
// correct there and strictly stronger; a poll would hide a regression that
// clears them one frame late.
- let offsets_deadline = std::time::Instant::now()
- + if restart_server {
- std::time::Duration::from_secs(10)
- } else {
- std::time::Duration::ZERO
- };
+ // vsr-only, and only for the restart cells: the legacy flavor purges
+ // synchronously, and without a restart the pump clears offsets and files
in
+ // the SAME frame that plants the layout, so the instant assert is correct
+ // and strictly stronger there. Kept short -- a client-visible stale offset
+ // after purge-then-restart is a real (bounded) window, not something to
+ // paper over with a long tolerance.
+ let poll_window = if cfg!(feature = "vsr") && restart_server {
+ std::time::Duration::from_secs(2)
+ } else {
+ std::time::Duration::ZERO
+ };
+ let offsets_deadline = std::time::Instant::now() + poll_window;
loop {
let consumer_offset = client
.get_consumer_offset(&consumer, &stream_ident, &topic_ident,
Some(PARTITION_ID))
diff --git a/core/journal/src/local_gate.rs b/core/journal/src/local_gate.rs
index 983ac51bf..6d2b7b86e 100644
--- a/core/journal/src/local_gate.rs
+++ b/core/journal/src/local_gate.rs
@@ -102,3 +102,100 @@ impl Drop for LocalGateGuard<'_> {
}
}
}
+
+// NOT behind `cfg(debug_assertions)`: release is exactly the build where this
+// gate is the only enforcement of the superblock single-writer contract (the
+// `WritingGuard` tripwire is debug-only, `write` takes `&self`, and two
+// overlapping writers collide on one fixed `.tmp` path and tear a slot while
+// both return `Ok`).
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use futures::FutureExt;
+ use futures::future::join;
+ use std::rc::Rc;
+
+ /// Two writers queued on the same gate run one after the other, never
+ /// interleaved -- the property a torn superblock slot depends on.
+ #[compio::test]
+ async fn given_two_waiters_when_acquiring_should_serialize() {
+ let gate = LocalGate::new();
+ let gate = &gate;
+ let log: Rc<RefCell<Vec<&'static str>>> =
Rc::new(RefCell::new(Vec::new()));
+
+ let first = {
+ let log = Rc::clone(&log);
+ async move {
+ let guard = gate.acquire().await;
+ log.borrow_mut().push("first:enter");
+ // A yield inside the critical section: without exclusion the
+ // second writer would slot in right here.
+ compio::time::sleep(std::time::Duration::from_millis(1)).await;
+ log.borrow_mut().push("first:exit");
+ drop(guard);
+ }
+ };
+ let second = {
+ let log = Rc::clone(&log);
+ async move {
+ let guard = gate.acquire().await;
+ log.borrow_mut().push("second:enter");
+ compio::time::sleep(std::time::Duration::from_millis(1)).await;
+ log.borrow_mut().push("second:exit");
+ drop(guard);
+ }
+ };
+ join(first, second).await;
+
+ let log = log.borrow();
+ assert_eq!(log.len(), 4, "both sections ran: {log:?}");
+ let first_exit = log.iter().position(|entry| *entry ==
"first:exit").unwrap();
+ let second_enter = log
+ .iter()
+ .position(|entry| *entry == "second:enter")
+ .unwrap();
+ assert!(
+ first_exit < second_enter,
+ "sections must not interleave: {log:?}"
+ );
+ }
+
+ /// Dropping the guard wakes a queued waiter, so the gate does not wedge
+ /// once contended.
+ #[compio::test]
+ async fn given_queued_waiter_when_guard_drops_should_wake_it() {
+ let gate = LocalGate::new();
+ let held = gate.acquire().await;
+
+ let mut waiter = Box::pin(gate.acquire());
+ assert!(
+ waiter.as_mut().now_or_never().is_none(),
+ "the gate is held, so the waiter must park"
+ );
+
+ drop(held);
+ assert!(
+ waiter.now_or_never().is_some(),
+ "dropping the guard must wake the queued waiter"
+ );
+ }
+
+ /// A waiter that goes away leaves only a stale waker behind: the next
+ /// acquirer still gets the gate. Cancel safety is load-bearing here, since
+ /// every acquire site sits in a future a shard can drop.
+ #[compio::test]
+ async fn given_dropped_waiter_when_guard_releases_should_not_wedge() {
+ let gate = LocalGate::new();
+ let held = gate.acquire().await;
+
+ let mut abandoned = Box::pin(gate.acquire());
+ assert!(abandoned.as_mut().now_or_never().is_none());
+ drop(abandoned);
+
+ drop(held);
+ assert!(
+ gate.acquire().now_or_never().is_some(),
+ "a dropped waiter must not keep the gate busy"
+ );
+ }
+}
diff --git a/core/metadata/src/impls/recovery.rs
b/core/metadata/src/impls/recovery.rs
index 0ed4cfc19..286d286e2 100644
--- a/core/metadata/src/impls/recovery.rs
+++ b/core/metadata/src/impls/recovery.rs
@@ -781,6 +781,7 @@ mod tests {
commit_max: 100,
checkpoint_op,
checkpoint_checksum,
+ offset_frontier: 0,
}
}
diff --git a/core/metadata/src/lib.rs b/core/metadata/src/lib.rs
index 5c4ba390e..4d4d24003 100644
--- a/core/metadata/src/lib.rs
+++ b/core/metadata/src/lib.rs
@@ -27,5 +27,9 @@ pub use impls::metadata::{
apply_committed_prepare,
};
+// Recovery vocabulary other crates name in their own signatures and error
+// enums, so they do not have to spell the `impls::` path.
+pub use impls::recovery::{IdentityField, RecoveryError, ReplicaIdentity};
+
// Re-export MuxStateMachine for use in other modules
pub use stm::mux::MuxStateMachine;
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index 16899e94f..85ceae6de 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -200,6 +200,10 @@ where
/// at network round-trip rate. Reset only by
/// [`Self::note_transfer_installed`]; drives the re-arm backoff.
transfer_failures: u32,
+ /// CONSECUTIVE transient refusals (a peer that cannot serve right now).
+ /// Drives log escalation only -- never the backoff. See
+ /// [`Self::record_transfer_refusal`].
+ transfer_refusals: u32,
/// A scheduled transfer re-arm: try `peer` again once `after_ticks`
/// consensus ticks elapse. Owned by the shard tick sweep; while one is
/// pending, the repair-refusal trigger must not arm concurrently.
@@ -364,6 +368,7 @@ where
transfer: None,
transfer_attempts: 0,
transfer_failures: 0,
+ transfer_refusals: 0,
transfer_rearm: None,
segment_checksum_cache:
RefCell::new(std::collections::HashMap::new()),
reuse_scan_memo: RefCell::new(None),
@@ -476,7 +481,8 @@ where
if !self.consensus.needs_superblock_persist() {
return true;
}
- self.write_superblock(superblock.as_ref()).await
+ self.write_superblock(superblock.as_ref(), self.offset_frontier())
+ .await
}
/// Write the current VSR state under [`Self::superblock_lock`].
@@ -494,14 +500,23 @@ where
/// send for this group, goes quiet, and its peers elect around it. Only
/// THIS partition's group is fenced; the rest of the node keeps serving.
#[allow(clippy::future_not_send)]
- async fn write_superblock(&self, superblock: &SB) -> bool {
+ async fn write_superblock(&self, superblock: &SB, offset_frontier: u64) ->
bool {
// The pairing fields stay `(0, 0)` and `commit_max` is a dead write
// on this plane: nothing reads either back (`restore_partition_view`
// restores view/log_view only), because recovery re-derives the
// install floor from the installed segments at boot -- a crash after
// an install does not re-run the transfer. Written anyway so the
// record shape matches the metadata plane's.
- let state = self.consensus.vsr_state(0, 0);
+ //
+ // `offset_frontier` is NOT dead: it is the only durable carrier of the
+ // group's offset space once the segments that named it are gone. Every
+ // write stamps the current counter, so whichever write lands last (a
+ // view change, or the explicit persist an install issues) leaves a
+ // lower bound boot can re-seed from.
+ let mut state = self.consensus.vsr_state(0, 0);
+ // Never regresses: a caller recording an incoming frontier passes a
+ // larger value, and the ordinary gate passes the live counter.
+ state.offset_frontier = offset_frontier.max(self.offset_frontier());
match superblock.write(&state.to_bytes()).await {
Ok(()) => {
self.consensus
@@ -541,6 +556,53 @@ where
}
}
+ /// The next message offset this replica will mint, `0` while the offset
+ /// space is still empty. The value stamped into the durable record.
+ #[must_use]
+ pub fn offset_frontier(&self) -> u64 {
+ if self.should_increment_offset {
+ self.offset.load(Ordering::Acquire).saturating_add(1)
+ } else {
+ 0
+ }
+ }
+
+ /// Force the durable record to catch up with the current offset frontier,
+ /// outside the view-change gate.
+ ///
+ /// [`Self::persist_superblock_if_needed`] fires on `(view, log_view)`
+ /// changes only, which is the right trigger for the split-brain fence and
+ /// the wrong one for the frontier: an install can move the counter by
+ /// millions without touching the view. Called where the frontier changes
+ /// with nothing else durable naming it -- after a state-transfer install
+ /// and after the convergence that follows a failed one. Returns whether
the
+ /// record now holds it; a failure is logged by the writer and left to the
+ /// ordinary retry, since the install itself already succeeded.
+ #[allow(clippy::future_not_send)]
+ pub async fn persist_offset_frontier(&self) -> bool {
+ self.persist_offset_frontier_at(self.offset_frontier())
+ .await
+ }
+
+ /// [`Self::persist_offset_frontier`] for a frontier this replica has not
+ /// reached yet.
+ ///
+ /// Used to record an INCOMING frontier before a destructive swap: the
+ /// install unlinks the old chain and fsyncs that before the first staged
+ /// rename lands, and boot sweeps `.log.staging` unconditionally, so a
crash
+ /// in that window otherwise leaves no copy of the frontier anywhere.
Writing
+ /// the claim first makes it a durable lower bound the whole way through,
and
+ /// over-claiming is harmless: the convergence that follows a failed
install
+ /// seeds the counter from the same artifact frontier.
+ #[allow(clippy::future_not_send)]
+ pub async fn persist_offset_frontier_at(&self, frontier: u64) -> bool {
+ let Some(superblock) = self.superblock.as_ref().map(Rc::clone) else {
+ return true;
+ };
+ let _superblock_guard = self.superblock_lock.acquire().await;
+ self.write_superblock(superblock.as_ref(), frontier).await
+ }
+
/// Burn one transfer stall round; `true` once the budget is exhausted.
/// Lives on the partition, not the session, so a re-minted session
/// cannot reset it (see [`Self::transfer_attempts`]).
@@ -572,6 +634,19 @@ where
/// consecutive-failure count.
pub const fn note_transfer_installed(&mut self) {
self.transfer_failures = 0;
+ self.transfer_refusals = 0;
+ }
+
+ /// Charge one TRANSIENT refusal and return the consecutive count.
+ ///
+ /// Separate from [`Self::record_transfer_failure`] on purpose: a transient
+ /// refusal must not touch the exponential backoff (the flat retry interval
+ /// is the point), but a partition refused for hours still has to be
+ /// visible, so the count exists only to escalate logging and feed a
metric.
+ /// Reset by [`Self::note_transfer_installed`] alongside the failure count.
+ pub const fn record_transfer_refusal(&mut self) -> u32 {
+ self.transfer_refusals = self.transfer_refusals.saturating_add(1);
+ self.transfer_refusals
}
/// A fresh re-arm is scheduled: the stall budget starts over for it.
@@ -3432,6 +3507,12 @@ where
// Recreate a fresh empty segment at offset 0 with real writers.
let start_offset = 0u64;
self.install_empty_segment(config, start_offset).await?;
+ // Make the unlinks AND the replanted dirent durable together: without
+ // this a crash can resurrect pre-purge segments until the boot
re-purge
+ // fires. Bounded and self-healing, but the fsync is one call.
+ if let Some(partition_dir) = self.partition_dir.clone() {
+ let _ = crate::state_transfer::fsync_dir(&partition_dir).await;
+ }
// Reset the offset counters so new messages start at offset 0.
self.offset.store(start_offset, Ordering::Release);
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index 01487f66b..acc4f926a 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -24,7 +24,7 @@ use server_common::{
use std::io;
use std::{
cell::{Cell, UnsafeCell},
- collections::{BTreeMap, HashMap, HashSet, VecDeque},
+ collections::{BTreeMap, HashMap, VecDeque},
};
use tracing::warn;
@@ -682,13 +682,26 @@ where
pub fn repaired_window_shape(&self, floor: u64, to_op: u64) ->
RepairedWindowShape {
let headers = unsafe { &*self.headers.get() };
let expected = to_op.saturating_sub(floor);
- // At most one entry per in-window op can land, so the window bounds
the
- // hint: sizing it for every resident header allocated (and memset)
about
- // a megabyte of table per repair round on the floor-refusal path,
where
- // the header vec grows with the live tail.
+ // More in-window ops than resident headers can never be covered, and
+ // `expected` is unbounded here (`to_op` rides the local `commit_max`),
+ // so this is both the early answer and what keeps the bitset below
from
+ // being sized off an arbitrary number.
+ if expected > headers.len() as u64 {
+ return RepairedWindowShape {
+ complete: false,
+ holds_messages: headers.iter().any(|header| {
+ header.op > floor
+ && header.op <= to_op
+ && header.operation == Operation::SendMessages
+ }),
+ };
+ }
+ // Dense window, so a bitset beats a `HashSet`: no hashing per op and
one
+ // allocation of `expected / 8` bytes.
#[allow(clippy::cast_possible_truncation)]
- let capacity = expected.min(headers.len() as u64) as usize;
- let mut present: HashSet<u64> = HashSet::with_capacity(capacity);
+ let expected_len = expected as usize;
+ let mut present = vec![false; expected_len];
+ let mut covered = 0usize;
let mut holds_messages = false;
for header in headers
.iter()
@@ -697,11 +710,16 @@ where
if header.operation == Operation::SendMessages {
holds_messages = true;
}
- present.insert(header.op);
+ #[allow(clippy::cast_possible_truncation)]
+ let slot = (header.op - floor - 1) as usize;
+ if !present[slot] {
+ present[slot] = true;
+ covered += 1;
+ }
}
RepairedWindowShape {
// In-window ops only, deduplicated, so a count match IS coverage.
- complete: present.len() as u64 == expected,
+ complete: covered == expected_len,
holds_messages,
}
}
diff --git a/core/partitions/src/state_transfer.rs
b/core/partitions/src/state_transfer.rs
index ec1cbd03f..37ef73409 100644
--- a/core/partitions/src/state_transfer.rs
+++ b/core/partitions/src/state_transfer.rs
@@ -84,9 +84,10 @@ 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. Doubles
- /// as the decode-budget generation: segments are append-only, so a new
- /// commit frontier genuinely means new bytes.
+ /// 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
@@ -161,12 +162,22 @@ impl consensus::ChunkProgress for TransferArtifact {
fn extend_from_chunk(&mut self, payload: &[u8]) {
match self {
- Self::Pending(progress) => progress.buf.extend_from_slice(payload),
+ // 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:
@@ -206,7 +217,9 @@ pub struct StagedSegmentMeta {
/// bytes" hold.
pub(crate) struct SegmentChecksumMemo {
hashed_len: u64,
- checksum: 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: consensus::state_manifest::StateArtifactHasher,
}
@@ -214,7 +227,6 @@ impl SegmentChecksumMemo {
fn new() -> Self {
Self {
hashed_len: 0,
- checksum: 0,
hasher: consensus::state_manifest::StateArtifactHasher::new(),
}
}
@@ -234,6 +246,31 @@ pub(crate) struct ReuseScanMemo {
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`.
///
@@ -354,6 +391,15 @@ impl ConsumerOffsetsWire {
max: CONSUMER_OFFSETS_ENTRIES_MAX,
});
}
+ // The count is peer input and the reservation is 12 bytes per element
+ // after alignment, so it is checked against the bytes actually present
+ // before allocating: a ~30 byte artifact could otherwise ask for tens
of
+ // megabytes across the two sections. 12 is the wire stride below --
the
+ // groups call sees exactly `12 * count` bytes remaining, so a wider
+ // guard would reject every non-empty artifact.
+ if count as usize * (size_of::<u32>() + size_of::<u64>()) >
cursor.remaining().len() {
+ return Err(ConsumerOffsetsWireError::Truncated);
+ }
let mut entries = Vec::with_capacity(count as usize);
let mut previous: Option<u32> = None;
for _ in 0..count {
@@ -672,7 +718,7 @@ impl std::error::Error for SegmentWalkError {}
/// # Errors
/// [`SegmentWalkError`] on the first invalid byte; nothing is partially
/// trusted.
-pub(crate) fn walk_segment_payload(
+pub(crate) async fn walk_segment_payload(
base_offset: u64,
bytes: &[u8],
) -> Result<(SegmentWalkStats, Vec<u8>), SegmentWalkError> {
@@ -680,7 +726,18 @@ pub(crate) fn walk_segment_payload(
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 would hold the pump --
and
+ // with it consensus ticks and heartbeats for every group on this core
--
+ // for the whole pass. Yield on the same cadence the serving side's
+ // chunked hash uses.
+ 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,
@@ -720,10 +777,24 @@ pub(crate) fn walk_segment_payload(
// client-supplied and would give the installed replica a divergent
// timestamp column (polls and retention keyed differently per node).
let timestamp = header.base_timestamp;
- // One sparse-index entry per batch, pointing at the batch start.
- index_bytes.extend_from_slice(&header.base_offset.to_le_bytes());
- index_bytes.extend_from_slice(×tamp.to_le_bytes());
- index_bytes.extend_from_slice(&(position as u64).to_le_bytes());
+ // STRIDED, not one entry per batch: the origin writes one entry per
+ // flush chunk, and a per-batch index is dense enough that a
transferred
+ // segment never fits the sealed-index residency cap
+ // (`poll_plan::SEALED_INDEX_RESIDENT_MAX_BYTES`), so every sealed poll
+ // would fall back to binary-searching the file with single-entry
preads
+ // -- a slow path `poll_plan` reserves for a
+ // `messages_required_to_save = 1` misconfiguration, which a transfer
+ // would otherwise produce unconditionally. Both consumers do
lower-bound
+ // lookups and recovery walks forward from the last entry by design, so
+ // sparser is correct; the first batch always gets one.
+ let stride_reached = indexed_position
+ .is_none_or(|indexed| position.saturating_sub(indexed) >=
INDEX_STRIDE_BYTES);
+ if stride_reached {
+ indexed_position = Some(position);
+ index_bytes.extend_from_slice(&header.base_offset.to_le_bytes());
+ index_bytes.extend_from_slice(×tamp.to_le_bytes());
+ index_bytes.extend_from_slice(&(position as u64).to_le_bytes());
+ }
stats = Some(stats.map_or(
SegmentWalkStats {
end_offset: batch_end,
@@ -748,6 +819,7 @@ pub(crate) fn walk_segment_payload(
// 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))
@@ -1061,8 +1133,10 @@ fn staging_paths(partition_dir: &str, start_offset: u64)
-> (PathBuf, PathBuf) {
/// subdirectory is not one).
///
/// # Errors
-/// The underlying `std::io::Error`; the caller logs and lets the rebuild
-/// re-hydrate whatever remains in place.
+/// 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> {
let mut target = None;
for attempt in 0..1000 {
@@ -1079,6 +1153,11 @@ pub async fn quarantine_segment_files(partition_dir:
&str) -> std::io::Result<St
));
};
compio::fs::create_dir_all(&target).await?;
+ // 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.
let entries = std::fs::read_dir(partition_dir)?;
for entry in entries.flatten() {
let path = entry.path();
@@ -1115,7 +1194,17 @@ pub async fn quarantine_segment_files(partition_dir:
&str) -> std::io::Result<St
/// failing either caller for. The CONVERGE sweep is deliberately not this
/// function -- it deletes the live chain as well and must propagate its
/// errors.
+/// Do NOT widen this predicate to the quarantine's three-suffix list if the
two
+/// are ever unified: the keep-lists callers pass hold staging paths only
(purge
+/// passes none), so a wider filter would unlink every live `.log` and `.index`
+/// on the partition -- worst at the reuse scan, which runs at
descriptor-accept
+/// on a serving partition.
pub(crate) async fn sweep_staging_except(partition_dir: &str, keep: &[&Path]) {
+ // 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.
let Ok(entries) = std::fs::read_dir(partition_dir) else {
return;
};
@@ -1362,7 +1451,7 @@ where
.remove(&start_offset);
let mut memo = match memo {
Some(memo) if memo.hashed_len == size => {
- let checksum = memo.checksum;
+ let checksum = memo.hasher.finish();
self.segment_checksum_cache
.borrow_mut()
.insert(start_offset, memo);
@@ -1389,8 +1478,7 @@ where
source,
})?;
memo.hashed_len = size;
- memo.checksum = memo.hasher.finish();
- let checksum = memo.checksum;
+ let checksum = memo.hasher.finish();
self.segment_checksum_cache
.borrow_mut()
.insert(start_offset, memo);
@@ -1403,8 +1491,8 @@ where
/// lives on the free function. `None` for an in-memory partition.
///
/// # Errors
- /// The underlying `std::io::Error`; the caller logs and lets the rebuild
- /// re-hydrate whatever remains in place.
+ /// 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);
@@ -1493,13 +1581,14 @@ where
// 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 !consensus::verify_state_artifact(entry, &bytes) {
+ 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).map_err(SpillError::Walk)?;
+ 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
@@ -1522,17 +1611,13 @@ where
path: PathBuf::from(&partition_dir),
source,
})?;
- Ok(StagedSegmentMeta {
- start_offset: entry.frontier,
- end_offset: stats.end_offset,
- size: entry.len,
+ Ok(StagedSegmentMeta::from_walk(
+ entry,
+ stats,
index_size,
- start_timestamp: stats.start_timestamp,
- end_timestamp: stats.end_timestamp,
- max_timestamp: stats.max_timestamp,
log_staging,
index_staging,
- })
+ ))
}
/// Adopt an already-verified staged log without rewriting it: walk the
@@ -1554,8 +1639,9 @@ where
let Some(partition_dir) = self.partition_dir.clone() else {
return Err(SpillError::NoPartitionDir);
};
- let (stats, index_bytes) =
- walk_segment_payload(entry.frontier,
bytes).map_err(SpillError::Walk)?;
+ 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)
@@ -1564,17 +1650,13 @@ where
path: index_staging.clone(),
source,
})?;
- Ok(StagedSegmentMeta {
- start_offset: entry.frontier,
- end_offset: stats.end_offset,
- size: entry.len,
+ Ok(StagedSegmentMeta::from_walk(
+ entry,
+ stats,
index_size,
- start_timestamp: stats.start_timestamp,
- end_timestamp: stats.end_timestamp,
- max_timestamp: stats.max_timestamp,
log_staging,
index_staging,
- })
+ ))
}
/// Scan the partition directory for staging files left by an earlier
@@ -1641,7 +1723,7 @@ where
let Ok(bytes) = compio::fs::read(&log_staging).await else {
continue;
};
- if !consensus::verify_state_artifact(entry, &bytes) {
+ if !verify_state_artifact_yielding(entry, &bytes).await {
continue;
}
if let Ok(meta) = self.adopt_staged_segment(entry, &bytes).await {
@@ -1752,6 +1834,13 @@ where
}
// ---- 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.
+ self.persist_offset_frontier_at(offsets_wire.next_offset)
+ .await;
// 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
@@ -1784,6 +1873,11 @@ where
.await
.map_err(|source| PartitionInstallError::ConvergeFailed { source
})?;
}
+ // 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.
+ self.persist_offset_frontier().await;
outcome
}
@@ -1969,6 +2063,18 @@ where
path: partition_dir.to_owned(),
source,
})?;
+ // The per-log fsync below lives inside the staged loop, which is
+ // empty on this path, and `install_empty_segment` opens with
+ // `file_exists = false` (no writer-creation fsyncs), so without
+ // this the frontier-bearing dirent is page-cache only: a crash
+ // right after the install boots an empty directory and re-derives
+ // the counter at 0.
+ fsync_dir(partition_dir)
+ .await
+ .map_err(|source| PartitionInstallError::SwapIo {
+ path: partition_dir.to_owned(),
+ source,
+ })?;
} else {
let last = self.log.segments().len() - 1;
let storage = self.log.storages()[last].clone();
@@ -2062,7 +2168,22 @@ where
paths
};
for path in old_consumer_paths.into_iter().chain(old_group_paths) {
- let _ = delete_persisted_offset(&path).await;
+ if let Err(error) = delete_persisted_offset(&path).await {
+ // Not fatal, but not silent either: a stranded file is an id
+ // absent from the NEW table (matching ids get overwritten at
+ // the same path), and boot resurrects it. Sharpest after a
+ // purged origin ships `next_offset = 0`, where the clamp drops
+ // every incoming entry and the whole old table survives while
+ // the install still reports success.
+ tracing::warn!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ namespace_raw = self.consensus().namespace(),
+ path = %path,
+ %error,
+ "failed to unlink a superseded consumer-offset file during
install"
+ );
+ }
}
self.persisted_offsets.borrow_mut().clear();
self.pending_consumer_offset_commits.clear();
@@ -2212,9 +2333,9 @@ where
// 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 and repair will not arm (the install leaves
- // `commit_min == commit_max`), so a DVC from here would advertise an
op
- // this replica cannot walk. The pipeline is cleared in the same
breath:
+ // 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
@@ -2270,6 +2391,11 @@ where
// in-memory vectors were already drained, so only the directory
// itself knows what needs unlinking.
if let Some(partition_dir) = self.partition_dir.clone() {
+ // 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.
let swept: Vec<PathBuf> = match std::fs::read_dir(&partition_dir) {
Ok(entries) => entries
.flatten()
@@ -2376,6 +2502,27 @@ impl fmt::Display for SpillError {
impl std::error::Error for SpillError {}
+/// Payload bytes between rebuilt sparse-index entries.
+///
+/// Targets the ORIGIN's density (one entry per flush chunk), not maximum
+/// sparseness: at this stride a maximum-size 1 GiB segment rebuilds ~16k
+/// entries (~384 KiB), inside `poll_plan::SEALED_INDEX_RESIDENT_MAX_BYTES`, so
+/// a transferred segment caches its index like any other instead of taking the
+/// per-poll binary-search fallback.
+const INDEX_STRIDE_BYTES: usize = 64 * 1024;
+
+/// Hand the core back to the reactor mid-CPU-pass.
+///
+/// A zero-duration timer, NOT a bare self-waking yield: this runtime does not
+/// reliably re-poll a task that woke itself from inside its own poll, and a
+/// pump that suspends that way stops driving consensus entirely (the frame
+/// handler never resumes, ticks stop, the node goes quiet until something else
+/// wakes it). Registering with the reactor is what every other yield on these
+/// paths does -- the serving side yields through real file reads.
+async fn yield_to_reactor() {
+ compio::time::sleep(std::time::Duration::ZERO).await;
+}
+
/// Chunk size for the offer build's streaming checksum pass. Large enough
/// that per-chunk overhead is noise, small enough that the pump yields to
/// the reactor many times per segment.
@@ -2437,24 +2584,119 @@ async fn hash_segment_range(
/// The serving side runs this on the pump to answer a single chunk request, so
/// it must not hold the core for a whole-file read plus a non-yielding hash
over
/// up to 2 GiB -- long enough to miss heartbeat and view-change deadlines on
-/// every group this shard owns. `None` means the file is shorter than the
entry
-/// or no longer hashes to it (GC unlinked and recreated it, or the bytes
rotted):
-/// the caller evicts the offer and has the requester restart, which is the
only
-/// way the load-time re-verification this exists for can be acted on.
+/// every group this shard owns.
///
/// 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,
-) -> Option<Vec<u8>> {
+) -> Result<Vec<u8>, SegmentLoadError> {
let mut hasher = consensus::state_manifest::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
- .ok()?;
- (hasher.finish() == entry.checksum).then_some(bytes)
+ .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 {
+ // Only kinds the OS actually named earn the hard verdict:
+ // `hash_segment_range` wraps read failures in `Error::other`, which
+ // erases the kind, and a short read past EOF is the ordinary racing-GC
+ // shape. Everything unclassified is therefore stale (retryable).
+ 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 = consensus::state_manifest::StateArtifactHasher::new();
+ for chunk in bytes.chunks(OFFER_HASH_CHUNK_LEN) {
+ hasher.update(chunk);
+ yield_to_reactor().await;
+ }
+ hasher.finish() == entry.checksum
+}
+
+/// The purge generation an encoded consumer-offsets artifact carries, or `0`
+/// when it cannot be decoded.
+///
+/// Lets the shard refuse an offer built BEFORE a committed purge without
+/// duplicating the wire codec: the install's own generation handling only ever
+/// widens permission, so a stale offer would resurrect purged data with the
+/// local applied generation left at the newer value, which the reconciler's
+/// re-wipe gate then reads as "already applied".
+#[must_use]
+pub fn offered_purge_generation(offsets_bytes: &[u8]) -> u64 {
+ ConsumerOffsetsWire::decode(offsets_bytes)
+ .map(|wire| wire.purge_generation)
+ .unwrap_or_default()
}
/// Stamp over every `SEGMENT_LOG` entry of a manifest, keying
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index cbcd6e9df..fd90b696d 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -955,12 +955,26 @@ evicted_ring_capacity = 4096
# large batches can pin. Must be > 0 and <= "256 MiB".
evicted_ring_bytes_max = "16 MiB"
+# Byte budget for segment payloads a SERVING shard keeps resident to answer
+# state-transfer chunk requests, per shard (process-wide is this times the
shard
+# count). Sized for concurrent pulls: at exactly one maximum segment a single
+# rejoining node thrashes the cache by itself, and every miss re-reads and
+# re-hashes a whole segment to serve one 256 KiB chunk. Must be > 0.
+transfer_served_cache_bytes_max = "4 GB"
+
+# Alloc ceiling for ONE received state-transfer artifact, per shard. The
+# receiver holds it resident through verify, walk and staging write, and up to
+# four transfers run at once. Keep it above the largest legal segment
+# (segment.size plus one max batch) or legal segments are rejected. Must be >
0.
+transfer_artifact_bytes_max = "1088 MB"
+
# Message bus configuration.
# Tunables for the inter-shard / inter-replica internal bus that ships
# consensus traffic between replicas and SDK-client traffic between
# shards. These knobs are consensus-liveness-critical (max_batch gates
# throughput under backpressure). Defaults match
# core::message_bus::config::MessageBusConfig::default().
+
[message_bus]
# Maximum number of BusMessage entries coalesced into a single writev(2)
# call. Hard upper bound: IOV_MAX/2 = 512 on Linux.
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index af72c7d8e..f85d2cfaa 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -26,7 +26,8 @@ use crate::dispatch::{
use crate::http;
use crate::partition_helpers::{
build_partition_fresh, configure_consumer_offsets, ensure_initial_segment,
- open_partition_superblock, restore_partition_view,
validate_namespace_bounds,
+ open_partition_superblock, restore_offset_frontier, restore_partition_view,
+ validate_namespace_bounds,
};
use crate::segment_recovery::{RecoveredSegment, load_persisted_segments};
use crate::server_error::{ServerNgError, ShardJoinFailure,
ShardJoinFailureKind};
@@ -72,8 +73,9 @@ use message_bus::{
};
use metadata::IggyMetadata;
use metadata::MuxStateMachine;
+use metadata::ReplicaIdentity;
use metadata::impls::metadata::{IggySnapshot, StreamsFrontend};
-use metadata::impls::recovery::{ReplicaIdentity, recover};
+use metadata::impls::recovery::recover;
use metadata::stm::mux::WithFactory;
use metadata::stm::snapshot::Snapshot;
use metadata::stm::stream::{Partition, Streams};
@@ -1766,15 +1768,32 @@ async fn build_shard_for_thread(
fenced_dir,
"quarantined the refused segment files; they are kept
for inspection"
),
- Err(error) => error!(
- stream_id,
- topic_id,
- partition_id = partition_metadata.id,
- partition_dir,
- %error,
- "failed to quarantine the refused segment files; the
rebuild will \
- re-read them and refuse again"
- ),
+ Err(error) => {
+ // NOT rebuilt: `build_partition_fresh` reaches
+ // `ensure_initial_segment`, which opens segment 0 with
+ // `file_exists = false` and TRUNCATES whatever the
+ // failed quarantine left behind. The likeliest
failures
+ // (suffix cap exhausted, `create_dir_all`) move zero
+ // files, so rebuilding would destroy the oldest
segment
+ // on the first attempt while the higher-offset
survivors
+ // keep refusing every boot -- a loop that never
+ // terminates and eats the chain one segment at a time.
+ // Tombstone instead: the namespace stays
unmaterialised
+ // and unrouted, the reconciler backs off, and an
+ // operator still has every byte.
+ error!(
+ stream_id,
+ topic_id,
+ partition_id = partition_metadata.id,
+ partition_dir,
+ %error,
+ "failed to quarantine the refused segment files;
leaving this \
+ partition tombstoned rather than rebuilding over
them"
+ );
+ partition_stats.zero_out_all();
+ partitions.tombstone(namespace);
+ continue;
+ }
}
// The refused load already folded its segment counts in.
partition_stats.zero_out_all();
@@ -1852,6 +1871,15 @@ async fn build_shard_for_thread(
// Repair pacing is shared by both planes' repair loops, so it is a
// per-shard tunable set once here rather than per consensus group.
shard.set_repair_retry_ticks(repair_retry_ticks(config));
+ shard.set_served_segment_cache_bytes_max(
+ config
+ .partition
+ .transfer_served_cache_bytes_max
+ .as_bytes_u64(),
+ );
+ shard.set_partition_artifact_len_max(
+ config.partition.transfer_artifact_bytes_max.as_bytes_u64(),
+ );
shard.set_repair_chunk_max(config.cluster.repair_chunk_max as u64);
// Bounds a served state-transfer chunk. A frame above the bus ceiling is
// rejected by the RECEIVING transport, which tears the replica connection
@@ -2346,7 +2374,12 @@ async fn load_partition(
partition.dirty_offset.store(counter, Ordering::Relaxed);
partition.should_increment_offset = current_offset.is_some();
partition.stats.set_current_offset(counter);
- let current_offset = counter;
+ // The durable frontier is a LOWER BOUND on top of what the segments
proved:
+ // it is the only carrier left when the segments that named the frontier
are
+ // gone (an all-GC'd origin's install, a crash inside the swap window), and
+ // taking the max means real recovered data always wins.
+ restore_offset_frontier(&mut partition, recovered_state.as_ref());
+ let current_offset = partition.offset.load(Ordering::Acquire);
configure_consumer_offsets(&mut partition, config, namespace,
current_offset)?;
ensure_initial_segment(&mut partition, config, stream_id, topic_id,
partition_id).await?;
diff --git a/core/server-ng/src/partition_helpers.rs
b/core/server-ng/src/partition_helpers.rs
index d722d637e..278a5184d 100644
--- a/core/server-ng/src/partition_helpers.rs
+++ b/core/server-ng/src/partition_helpers.rs
@@ -34,7 +34,7 @@ use iggy_common::{
};
use journal::superblock::{PingPongSuperblock, SuperblockContents};
use message_bus::IggyMessageBus;
-use metadata::impls::recovery::{IdentityField, ReplicaIdentity};
+use metadata::{IdentityField, ReplicaIdentity};
use partitions::{IggyIndexWriter, IggyPartition, MessagesWriter, Segment};
use server_common::SegmentStorage;
use server_common::fs_utils::remove_dir_all;
@@ -537,6 +537,46 @@ pub(crate) fn restore_partition_view(
consensus.mark_superblock_durable(state.view, state.log_view);
}
+/// Re-seed a partition's offset counter from the durable frontier, taking the
+/// MAX of what the record holds and what the recovered segments already
proved.
+///
+/// The record is a lower bound, never a completeness claim: it exists because
+/// three paths leave a replica whose counter would otherwise restart at 0
while
+/// the group is at N (a transfer install of an all-GC'd origin, a crash inside
+/// the install's swap window, and the fence-and-rebuild path, which needs no
+/// crash at all). Restarting the counter is not a lag -- replicas re-stamp
+/// `base_offset` from it and recompute `batch_checksum` over the result, so
the
+/// next replicated prepare would persist different bytes here than on every
+/// peer, silently.
+pub(crate) fn restore_offset_frontier(
+ partition: &mut IggyPartition<Rc<IggyMessageBus>>,
+ recovered: Option<&VsrState>,
+) {
+ let Some(frontier) = recovered
+ .map(|state| state.offset_frontier)
+ .filter(|&f| f > 0)
+ else {
+ return;
+ };
+ let recovered_end = frontier - 1;
+ if partition.should_increment_offset
+ && partition.offset.load(Ordering::Acquire) >= recovered_end
+ {
+ return;
+ }
+ info!(
+ namespace_raw = partition.consensus().namespace(),
+ offset_frontier = frontier,
+ "restored partition offset frontier from its superblock"
+ );
+ partition.offset.store(recovered_end, Ordering::Release);
+ partition
+ .dirty_offset
+ .store(recovered_end, Ordering::Relaxed);
+ partition.should_increment_offset = true;
+ partition.stats.set_current_offset(recovered_end);
+}
+
/// Materialise a brand-new [`IggyPartition`] for a namespace that has no
on-disk state yet.
///
/// Counterpart to bootstrap's `load_partition`, which hydrates from
@@ -675,7 +715,14 @@ pub async fn build_partition_fresh(
"fresh partition must not carry recovered segments"
);
- configure_consumer_offsets(&mut partition, config, namespace, 0)?;
+ // A "fresh" build is also how a FENCED partition comes back (the shard
+ // tombstones it and the reconciler rebuilds through here), and the fence
+ // deliberately leaves the superblock in place, so the recorded frontier is
+ // what stops the rebuild from re-minting offsets the group already used.
+ restore_offset_frontier(&mut partition, recovered_state.as_ref());
+ let current_offset = partition.offset.load(Ordering::Acquire);
+
+ configure_consumer_offsets(&mut partition, config, namespace,
current_offset)?;
ensure_initial_segment(&mut partition, config, stream_id, topic_id,
partition_id).await?;
Ok(partition)
@@ -756,6 +803,7 @@ mod tests {
commit_max: 42,
checkpoint_op: 0,
checkpoint_checksum: 0,
+ offset_frontier: 0,
}
}
diff --git a/core/server-ng/src/segment_recovery.rs
b/core/server-ng/src/segment_recovery.rs
index 297b92afa..edd3d76b1 100644
--- a/core/server-ng/src/segment_recovery.rs
+++ b/core/server-ng/src/segment_recovery.rs
@@ -102,13 +102,15 @@ pub async fn load_persisted_segments(
)
.await?;
- // An index without a single whole entry means any log bytes were torn
- // off mid-write (the crash landed between the message write and the
- // index write). Recover the segment as EMPTY: counting the bytes with
- // `end_offset == start_offset` would fabricate one phantom message for
- // the bootstrap non-empty filters, and appending after the torn bytes
- // would strand undecodable garbage inside the readable range. Zeroed
- // sizes make the next append overwrite the torn bytes instead.
+ // `bounds == None` now means the log holds no whole BATCH either (the
+ // index-less path above already tried walking the log), so there is
+ // nothing to recover: zeroed sizes make the next append overwrite the
+ // torn bytes, where counting them with `end_offset == start_offset`
+ // would fabricate one phantom message for the bootstrap non-empty
+ // filters and strand undecodable garbage inside the readable range.
+ // Note this is NOT tail-only -- a torn index is reachable mid-chain on
+ // the shipped `enforce_fsync = false`, which is why the walk above
+ // exists rather than refusing the partition.
let (start_timestamp, end_timestamp, end_offset,
effective_messages_size) =
if let Some((start_timestamp, end_timestamp, end_offset,
walked_size)) = bounds {
(start_timestamp, end_timestamp, end_offset, walked_size)
@@ -345,6 +347,7 @@ fn file_len(path: &str) -> u64 {
/// `enforce_fsync` there is no ordering barrier between the message write and
/// the index write, and a tail torn mid-flush would otherwise pass while
/// `end_offset` claims offsets whose bytes are incomplete.
+#[allow(clippy::too_many_lines)]
async fn recover_segment_bounds(
index_path: &str,
messages_path: &str,
@@ -432,6 +435,60 @@ async fn recover_segment_bounds(
}
Ok(Some((first.timestamp, end_timestamp, end_offset, position)))
}
+ // No whole index entry, but the log holds bytes: recover the bounds by
+ // WALKING the log from byte 0 instead of declaring the segment empty.
+ //
+ // The index is not the only self-describing copy -- batch headers
carry
+ // their own offsets, timestamps and lengths -- and with the shipped
+ // `enforce_fsync = false` there is no write ordering between a log and
+ // its index, so a torn index is reachable on default config for a
+ // MID-CHAIN segment too, not just the tail. Recovering that as empty
+ // then trips the contiguity guard and refuses the whole partition:
+ // total serve loss (and offset reuse from 0) for a chain whose bytes
+ // are all present. The walk stops at the first header that does not
+ // decode or does not fit, which keeps the torn-tail truncation the
+ // indexed path performs.
+ _ if messages_size > 0 => {
+ let mut position = 0u64;
+ let mut start_timestamp = None;
+ let mut end_offset = start_offset;
+ let mut end_timestamp = 0;
+ while position < messages_size {
+ let Some(header) = read_batch_header(messages_path, position,
messages_size) else {
+ break;
+ };
+ let extent = position.saturating_add(header.total_size() as
u64);
+ if extent > messages_size {
+ break;
+ }
+ if header.message_count > 0 {
+ end_offset = header
+ .base_offset
+ .saturating_add(u64::from(header.message_count) - 1);
+ end_timestamp = header.base_timestamp;
+ start_timestamp.get_or_insert(header.base_timestamp);
+ }
+ position = extent;
+ }
+ let Some(start_timestamp) = start_timestamp else {
+ // Not one whole batch either: the bytes really are unusable,
so
+ // the caller's empty recovery is right after all.
+ return Ok(None);
+ };
+ warn!(
+ stream_id,
+ topic_id,
+ partition_id,
+ start_offset,
+ messages_size,
+ walked_size = position,
+ "sparse index holds no whole entry; recovered segment bounds
by \
+ walking the log instead of discarding it (the index
repopulates \
+ on the next flush, and polls take the index-less fallback
until \
+ then)"
+ );
+ Ok(Some((start_timestamp, end_timestamp, end_offset, position)))
+ }
_ => Ok(None),
}
}
diff --git a/core/server-ng/src/server_error.rs
b/core/server-ng/src/server_error.rs
index 317596069..4345e8f83 100644
--- a/core/server-ng/src/server_error.rs
+++ b/core/server-ng/src/server_error.rs
@@ -136,7 +136,7 @@ pub enum ServerNgError {
)]
PartitionSuperblockIdentityMismatch {
dir: PathBuf,
- field: metadata::impls::recovery::IdentityField,
+ field: metadata::IdentityField,
expected: u128,
found: u128,
},
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 428d145ca..ab6129528 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -820,6 +820,15 @@ struct MetadataTransferSession {
peer: u8,
/// Serving peer's applied frontier from the accepted descriptor.
commit_op: u64,
+ /// Snapshot generation of the ACCEPTED descriptor, the key the decode
+ /// budget is charged against.
+ ///
+ /// Recorded at accept because the install-time scan can fail to find it --
+ /// a manifest whose snapshot entry is absent, or a checksum mismatch on an
+ /// earlier artifact aborting the scan -- and an uncharged failure re-armed
+ /// the same peer forever: an unbounded full-manifest re-pull loop. The
+ /// descriptor cannot be accepted without one, so it is always present
here.
+ generation: u64,
/// Empty until the `StateTransferTarget` manifest is accepted, then one
/// entry per offered artifact, pulled in manifest order.
artifacts: Vec<consensus::ArtifactProgress>,
@@ -901,7 +910,8 @@ impl ServedSegmentCache {
/// whole segment to serve one 256 KiB chunk. That is the 4096:1 read
/// amplification this cache exists to prevent, plus an offer eviction per
/// failed re-verify feeding the hard-failure backoff.
- const RESIDENT_BYTES_MAX: u64 = SEGMENT_SIZE_CEILING_BYTES *
Self::CONCURRENT_SERVED_SEGMENTS;
+ const RESIDENT_BYTES_DEFAULT: u64 =
+ SEGMENT_SIZE_CEILING_BYTES * Self::CONCURRENT_SERVED_SEGMENTS;
/// Distinct max-size segments the budget holds at once. Matches the
/// receiver-side in-flight cap, since that is how many distinct segments
one
@@ -940,6 +950,29 @@ impl ServedSegmentCache {
}
}
+ /// Drop every payload cached for `namespace`, crediting their bytes back.
+ ///
+ /// A purge unlinks the segments these payloads copy, and the cache key is
+ /// the manifest checksum over the PRE-purge bytes, so nothing about a hit
+ /// can notice: the serve path answers from the resident copy without
+ /// touching disk, and every served chunk resets the expiry clock, so an
+ /// active puller keeps purged data alive indefinitely.
+ fn evict_namespace(&mut self, namespace: u64) {
+ let stale: Vec<(u64, u64)> = self
+ .entries
+ .keys()
+ .filter(|(entry_namespace, _)| *entry_namespace == namespace)
+ .copied()
+ .collect();
+ for key in stale {
+ if let Some(evicted) = self.entries.remove(&key) {
+ self.resident_bytes = self
+ .resident_bytes
+ .saturating_sub(evicted.payload.len() as u64);
+ }
+ }
+ }
+
fn get(&mut self, namespace: u64, checksum: u64) -> Option<Rc<Vec<u8>>> {
self.use_seq += 1;
let use_seq = self.use_seq;
@@ -950,7 +983,7 @@ impl ServedSegmentCache {
Some(Rc::clone(&cached.payload))
}
- fn insert(&mut self, namespace: u64, checksum: u64, payload: Rc<Vec<u8>>) {
+ fn insert(&mut self, namespace: u64, checksum: u64, payload: Rc<Vec<u8>>,
budget: u64) {
let incoming = payload.len() as u64;
// Credited BEFORE the eviction scan: re-inserting an existing key
frees
// its own slot, and charging that only afterwards evicted neighbours
to
@@ -960,9 +993,7 @@ impl ServedSegmentCache {
.resident_bytes
.saturating_sub(replaced.payload.len() as u64);
}
- while self.resident_bytes.saturating_add(incoming) >
Self::RESIDENT_BYTES_MAX
- && !self.entries.is_empty()
- {
+ while self.resident_bytes.saturating_add(incoming) > budget &&
!self.entries.is_empty() {
let Some((&key, _)) = self
.entries
.iter()
@@ -1057,9 +1088,14 @@ impl<'a> TransferDescriptor<'a> {
/// sends run after the borrow drops.
enum ChunkReply {
Chunk(Message<StateChunkHeader>),
- /// Offer evicted (e.g. the serving process restarted): the requester
- /// gets an unavailable descriptor and restarts its session.
- UnknownOffer,
+ /// Offer evicted (e.g. the serving process restarted, or the segment it
+ /// named can no longer be served): the requester gets an unavailable
+ /// descriptor and restarts its session. `transient` carries whether the
+ /// cause was this node's fault, which is what decides if the requester
+ /// charges a failure.
+ Unavailable {
+ transient: bool,
+ },
}
pub struct IggyShard<B, MJ, S, M, T = (), SB = PingPongSuperblock>
@@ -1220,6 +1256,17 @@ where
/// `[cluster] repair_retry_interval` at bootstrap.
repair_retry_ticks: Cell<u32>,
+ /// Live `[partition] transfer_served_cache_bytes_max`: the byte budget for
+ /// segment payloads this shard keeps resident to serve chunk requests.
+ /// Defaults to [`ServedSegmentCache::RESIDENT_BYTES_DEFAULT`]; server-ng
+ /// overrides it at bootstrap.
+ served_segment_cache_bytes_max: Cell<u64>,
+
+ /// Live `[partition] transfer_artifact_bytes_max`: the alloc ceiling for
one
+ /// RECEIVED artifact. Defaults to `PARTITION_ARTIFACT_LEN_DEFAULT`;
+ /// server-ng overrides it at bootstrap.
+ partition_artifact_len_max: Cell<u64>,
+
/// Live `[message_bus] max_message_size`. Bounds a served state chunk: a
/// frame above this is rejected by the RECEIVING transport, which tears
/// down the whole replica connection. Defaults to a value that leaves
@@ -1346,6 +1393,8 @@ where
metadata_transfer: RefCell::new(None),
state_transfer_offers: RefCell::new(HashMap::new()),
served_segment_cache: RefCell::new(ServedSegmentCache::default()),
+ served_segment_cache_bytes_max:
Cell::new(ServedSegmentCache::RESIDENT_BYTES_DEFAULT),
+ partition_artifact_len_max:
Cell::new(Self::PARTITION_ARTIFACT_LEN_DEFAULT),
repair_chunk_max: Cell::new(REPAIR_CHUNK_MAX),
repair_retry_ticks: Cell::new(partitions::REPAIR_RETRY_TICKS),
bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
@@ -1361,6 +1410,18 @@ where
self.repair_retry_ticks.set(ticks);
}
+ /// Override the serving-side resident payload budget from configuration.
+ /// Called once per shard at bootstrap.
+ pub fn set_served_segment_cache_bytes_max(&self, bytes: u64) {
+ self.served_segment_cache_bytes_max.set(bytes);
+ }
+
+ /// Override the per-artifact receive ceiling from configuration. Called
once
+ /// per shard at bootstrap.
+ pub fn set_partition_artifact_len_max(&self, bytes: u64) {
+ self.partition_artifact_len_max.set(bytes);
+ }
+
/// Override the per-round repair-serving chunk ceiling from configuration.
/// Called once per shard at bootstrap; the simulator and tests keep the
/// compile-time [`REPAIR_CHUNK_MAX`] default.
@@ -1585,6 +1646,8 @@ where
metadata_transfer: RefCell::new(None),
state_transfer_offers: RefCell::new(HashMap::new()),
served_segment_cache: RefCell::new(ServedSegmentCache::default()),
+ served_segment_cache_bytes_max:
Cell::new(ServedSegmentCache::RESIDENT_BYTES_DEFAULT),
+ partition_artifact_len_max:
Cell::new(Self::PARTITION_ARTIFACT_LEN_DEFAULT),
repair_chunk_max: Cell::new(REPAIR_CHUNK_MAX),
repair_retry_ticks: Cell::new(partitions::REPAIR_RETRY_TICKS),
bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
@@ -1903,6 +1966,11 @@ const fn next_transfer_peer(self_id: u8, failed_peer:
u8, replica_count: u8, pri
}
}
+/// Consecutive transient refusals before the re-arm starts logging at `error`,
+/// and the interval it re-logs at afterwards. Sized so a peer that is briefly
+/// behind stays quiet while a partition that never rejoins becomes loud.
+const TRANSFER_REFUSALS_BEFORE_ESCALATION: u32 = 10;
+
/// Exponential re-arm backoff, scaled by the consecutive-failure count and
/// capped at 1024x the base so a long outage settles into a slow poll
/// instead of climbing forever.
@@ -3335,7 +3403,19 @@ where
let config = planes.1.0.config();
// Counted BEFORE the `&mut partition` below exists: the scan takes
// shared borrows of every partition (see `arm_partition_transfer`).
- let transfers_inflight = self.partition_transfers_inflight();
+ // Gated on the arm actually being possible, so a stale or misdirected
+ // frame -- and every StartView for a group that is not awaiting a
+ // transfer, which is all of them during an ordinary view change --
does
+ // not pay a node-wide scan. (A shard-level counter would remove the
scan
+ // entirely, but `IggyPartition::transfer` is `pub` and cleared inside
the
+ // partitions crate, so an externally maintained count would drift;
that
+ // refactor is a prerequisite, not a detail.)
+ let transfers_inflight = if
Self::may_arm_partition_transfer(&planes.1.0, header.namespace)
+ {
+ self.partition_transfers_inflight()
+ } else {
+ 0
+ };
let Some(partition) = self.resolve_partition_target(
&planes.1.0,
header.namespace,
@@ -3680,11 +3760,25 @@ where
let cluster = partition.consensus().cluster();
let self_id = partition.consensus().replica();
let to_op = header.to_op.min(partition.consensus().commit_max());
- let retained_from =
partition.log.journal().inner.repair_retained_from();
+ // `None` means the journal holds NOTHING, not "nothing was evicted":
+ // the partition journal is memory-only and `clear_all` wipes the
+ // evicted ring with it, so a freshly installed or freshly restarted
+ // peer answers `None` for every op it once had. Reading that as "no
+ // eviction" served a bare `RepairDone`, left the requester's floor at
+ // `None`, and `FloorRefused` -- the ONLY route that arms a partition
+ // state transfer -- never fired: a lagging replica on an idle
+ // partition spun repair forever against a peer-sticky retry. An empty
+ // journal instead reports eviction from the commit frontier, which
+ // refuses the floor into a transfer (the empty window passes the
+ // completeness check) and heals in one round.
+ let retained_from = partition
+ .log
+ .journal()
+ .inner
+ .repair_retained_from()
+ .unwrap_or_else(||
partition.consensus().commit_min().saturating_add(1));
let mut from_op = header.from_op;
- if let Some(retained_from) = retained_from
- && retained_from > from_op
- {
+ if retained_from > from_op {
self.send_repair_range_reply(
cluster,
self_id,
@@ -3956,9 +4050,15 @@ where
}
return;
}
- // Counted BEFORE the `&mut partition` below exists: the scan takes
- // shared borrows of every partition (see `arm_partition_transfer`).
- let transfers_inflight = self.partition_transfers_inflight();
+ // Counted BEFORE the `&mut partition` below exists, and only when an
arm
+ // is possible at all: see the StartView site for why the scan is gated
+ // rather than replaced with a counter.
+ let transfers_inflight = if
Self::may_arm_partition_transfer(&planes.1.0, header.namespace)
+ {
+ self.partition_transfers_inflight()
+ } else {
+ 0
+ };
let config = planes.1.0.config().clone();
let Some(partition) = planes
.1
@@ -4713,6 +4813,9 @@ where
nonce,
peer,
commit_op: 0,
+ // Set when a descriptor is accepted; a session with no accepted
+ // descriptor never reaches the install path that reads it.
+ generation: 0,
artifacts: Vec::new(),
target_accepted: false,
idle_ticks: 0,
@@ -4820,59 +4923,62 @@ where
let served = offers
.get_mut(&(header.namespace, header.replica))
.filter(|served| served.nonce == header.nonce);
- served.map_or(Some(ChunkReply::UnknownOffer), |served| {
- let ServedOffer::Metadata(offer) = &served.offer else {
- return Some(ChunkReply::UnknownOffer);
- };
- // Manifest-index addressing: an index past the offer is a
- // requester bug (or a stale frame) and is dropped below.
- let last_artifact = offer.len().saturating_sub(1);
- let artifact_bytes = offer.payload(header.artifact as usize)?;
- let start = header.offset as usize;
- // A request AT the end of an artifact has nothing left to
serve.
- // Answering it with `Some(&[])` -- which `get(len..len)`
happily
- // returns -- would extend nothing on the receiver, reset both
- // sides' idle counters, and be re-requested at the same offset
- // forever: an unbounded empty-frame ping-pong with the
rejoining
- // replica withholding `PrepareOk` for the life of the process.
- // Reachable when a rebuilt offer is SHORTER than the manifest
the
- // receiver accepted (a client logged out between the two
builds).
- if start >= artifact_bytes.len() {
- return None;
- }
- let end = start
- .saturating_add((header.len as usize).min(chunk_len_max))
- .min(artifact_bytes.len());
- let payload = artifact_bytes.get(start..end)?;
- // Only now that bytes are actually going out: an
out-of-bounds or
- // stale frame must not flip a live offer onto the short
expiry.
- // Tail of the final artifact means the receiver holds
everything
- // the manifest promised, so the offer only has to outlive a
- // possible re-request of this very chunk.
- if header.artifact as usize == last_artifact && end >=
artifact_bytes.len() {
- served.fully_served = true;
- }
- // Serving a chunk is the only liveness signal the offer gets;
- // the expiry sweep drops it once these stop arriving. Set here
- // rather than on entry so a request that serves NOTHING cannot
- // keep an abandoned offer alive.
- served.idle_ticks = 0;
- let total_size = size_of::<StateChunkHeader>() + payload.len();
- let mut chunk = Message::<StateChunkHeader>::new(total_size);
-
chunk.as_mut_slice()[size_of::<StateChunkHeader>()..].copy_from_slice(payload);
- Some(ChunkReply::Chunk(chunk.transmute_header(
- |_, h: &mut StateChunkHeader| {
- h.command = Command2::StateChunk;
- h.cluster = cluster;
- h.replica = self_id;
- h.nonce = header.nonce;
- h.namespace = header.namespace;
- h.artifact = header.artifact;
- h.offset = header.offset;
- h.size = total_size as u32;
- },
- )))
- })
+ served.map_or(
+ Some(ChunkReply::Unavailable { transient: true }),
+ |served| {
+ let ServedOffer::Metadata(offer) = &served.offer else {
+ return Some(ChunkReply::Unavailable { transient: true
});
+ };
+ // Manifest-index addressing: an index past the offer is a
+ // requester bug (or a stale frame) and is dropped below.
+ let last_artifact = offer.len().saturating_sub(1);
+ let artifact_bytes = offer.payload(header.artifact as
usize)?;
+ let start = header.offset as usize;
+ // A request AT the end of an artifact has nothing left to
serve.
+ // Answering it with `Some(&[])` -- which `get(len..len)`
happily
+ // returns -- would extend nothing on the receiver, reset
both
+ // sides' idle counters, and be re-requested at the same
offset
+ // forever: an unbounded empty-frame ping-pong with the
rejoining
+ // replica withholding `PrepareOk` for the life of the
process.
+ // Reachable when a rebuilt offer is SHORTER than the
manifest the
+ // receiver accepted (a client logged out between the two
builds).
+ if start >= artifact_bytes.len() {
+ return None;
+ }
+ let end = start
+ .saturating_add((header.len as
usize).min(chunk_len_max))
+ .min(artifact_bytes.len());
+ let payload = artifact_bytes.get(start..end)?;
+ // Only now that bytes are actually going out: an
out-of-bounds or
+ // stale frame must not flip a live offer onto the short
expiry.
+ // Tail of the final artifact means the receiver holds
everything
+ // the manifest promised, so the offer only has to outlive
a
+ // possible re-request of this very chunk.
+ if header.artifact as usize == last_artifact && end >=
artifact_bytes.len() {
+ served.fully_served = true;
+ }
+ // Serving a chunk is the only liveness signal the offer
gets;
+ // the expiry sweep drops it once these stop arriving. Set
here
+ // rather than on entry so a request that serves NOTHING
cannot
+ // keep an abandoned offer alive.
+ served.idle_ticks = 0;
+ let total_size = size_of::<StateChunkHeader>() +
payload.len();
+ let mut chunk =
Message::<StateChunkHeader>::new(total_size);
+
chunk.as_mut_slice()[size_of::<StateChunkHeader>()..].copy_from_slice(payload);
+ Some(ChunkReply::Chunk(chunk.transmute_header(
+ |_, h: &mut StateChunkHeader| {
+ h.command = Command2::StateChunk;
+ h.cluster = cluster;
+ h.replica = self_id;
+ h.nonce = header.nonce;
+ h.namespace = header.namespace;
+ h.artifact = header.artifact;
+ h.offset = header.offset;
+ h.size = total_size as u32;
+ },
+ )))
+ },
+ )
};
match reply {
Some(ChunkReply::Chunk(chunk)) => {
@@ -4881,10 +4987,11 @@ where
.send_to_replica(header.replica,
chunk.into_generic().into_frozen())
.await;
}
- Some(ChunkReply::UnknownOffer) => {
+ Some(ChunkReply::Unavailable { transient }) => {
tracing::info!(
shard = self.id,
requester = header.replica,
+ transient,
"state chunk request for an unknown offer; telling
requester to restart"
);
self.send_state_transfer_target(
@@ -4893,10 +5000,11 @@ where
header.replica,
header.nonce,
header.namespace,
- // Transient for the same reason as the partition arm: an
- // offer that aged out between chunks is not a peer
failure,
- // and the restarted session converges.
- TransferDescriptor::unavailable(true, consensus.view(),
consensus.commit_max()),
+ TransferDescriptor::unavailable(
+ transient,
+ consensus.view(),
+ consensus.commit_max(),
+ ),
)
.await;
}
@@ -5030,6 +5138,12 @@ where
.expect("session checked above");
let peer = session.peer;
let commit_op = session.commit_op;
+ // From the ACCEPTED descriptor, not re-derived from the artifacts: a
+ // scan that aborts before the snapshot entry (unknown kind first, or a
+ // checksum mismatch ahead of it) would leave nothing to charge, and an
+ // uncharged decode failure re-arms the same peer for the same manifest
+ // forever.
+ let generation = session.generation;
// Per-artifact integrity, then pick the pieces this plane installs.
// Unknown kinds are refused rather than skipped: an artifact the
@@ -5037,14 +5151,8 @@ where
// install would otherwise be silently dropped.
let mut snapshot: Option<Vec<u8>> = None;
let mut table: Option<(Vec<u8>, u64)> = None;
- // Captured before the integrity checks so a damaged pull still knows
- // which generation to charge the decode budget against.
- let mut generation: Option<u64> = None;
let mut damaged = false;
for (index, artifact) in session.artifacts.into_iter().enumerate() {
- if artifact.entry.kind ==
consensus::artifact_kind::METADATA_SNAPSHOT {
- generation = Some(artifact.entry.frontier);
- }
let actual = consensus::state_artifact_checksum(&artifact.buf);
if actual != artifact.entry.checksum {
tracing::error!(
@@ -5113,9 +5221,7 @@ where
// failures are charged per snapshot generation instead: a
// generation past its budget is refused at descriptor time until
// the peer checkpoints a new one.
- let exhausted =
- generation.is_some_and(|generation|
self.burn_decode_failure(generation));
- if exhausted {
+ if self.burn_decode_failure(generation) {
tracing::warn!(
shard = self.id,
peer,
@@ -5459,14 +5565,6 @@ where
}
}
- /// Drop serving-side state-transfer offers that stopped being pulled.
- ///
- /// Each offer owns a whole snapshot plus the encoded client table, and the
- /// protocol has no completion frame (a receiver installs and goes quiet),
so
- /// without this a primary that ever served a transfer pins that memory for
- /// the rest of the process. Generous relative to the chunk cadence: a live
- /// puller resets the counter on every chunk it fetches, so only an
abandoned
- /// or finished transfer ages out.
/// Serve one partition `RequestStateTransfer`: build (or re-serve) this
/// group's offer and answer with the descriptor.
#[allow(clippy::future_not_send, clippy::too_many_lines)]
@@ -5509,94 +5607,91 @@ where
served.idle_ticks = 0;
Some(Rc::clone(offer))
});
- if let Some(offer) = cached {
- tracing::debug!(
- shard = self.id,
- namespace_raw = header.namespace,
- requester = header.replica,
- "re-answering a partition state transfer request from the
offer already served"
- );
- let (serving_view, serving_commit_max) =
serving_progress(partition);
- self.send_state_transfer_target(
- cluster,
- self_id,
- header.replica,
- header.nonce,
- header.namespace,
- TransferDescriptor::available(
- &offer.manifest(),
- offer.commit_op,
- serving_view,
- serving_commit_max,
- ),
- )
- .await;
- return;
- }
- match partition.state_transfer_offer(&config).await {
- Ok(offer) => {
- tracing::info!(
- shard = self.id,
- namespace_raw = header.namespace,
- requester = header.replica,
- commit_op = offer.commit_op,
- artifacts = offer.artifact_count(),
- total_len = offer.total_len(),
- "serving partition state transfer"
- );
- let (serving_view, serving_commit_max) =
serving_progress(partition);
- self.send_state_transfer_target(
- cluster,
- self_id,
- header.replica,
- header.nonce,
- header.namespace,
- TransferDescriptor::available(
- &offer.manifest(),
- offer.commit_op,
- serving_view,
- serving_commit_max,
- ),
- )
- .await;
- self.state_transfer_offers.borrow_mut().insert(
- (header.namespace, header.replica),
- ServedStateTransfer {
- nonce: header.nonce,
- offer: ServedOffer::Partition(offer),
- idle_ticks: 0,
- fully_served: false,
- },
- );
- }
- Err(reason) => {
- // The ACTUAL reason: "not the caught-up primary" is routine
- // (the requester re-targets), an unreadable segment is an
- // operator-visible fault on THIS node. The requester cannot
see
- // the reason, only whether it was transient, which is what
keeps
- // a routine refusal from charging its failure count.
- let transient = reason.transient();
- tracing::info!(
+ // One resolve, one send: the three outcomes differ only in the
+ // descriptor they produce, and duplicating the send made it possible
for
+ // them to drift on the progress they advertise.
+ let offer = match cached {
+ Some(offer) => {
+ tracing::debug!(
shard = self.id,
namespace_raw = header.namespace,
requester = header.replica,
- transient,
- %reason,
- "cannot serve partition state transfer; requester falls
back"
+ "re-answering a partition state transfer request from the
offer \
+ already served"
);
- let (serving_view, serving_commit_max) =
serving_progress(partition);
- self.send_state_transfer_target(
- cluster,
- self_id,
- header.replica,
- header.nonce,
- header.namespace,
- TransferDescriptor::unavailable(transient, serving_view,
serving_commit_max),
- )
- .await;
+ Some(offer)
}
- }
+ None => match partition.state_transfer_offer(&config).await {
+ Ok(offer) => {
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ commit_op = offer.commit_op,
+ artifacts = offer.artifact_count(),
+ total_len = offer.total_len(),
+ "serving partition state transfer"
+ );
+ self.state_transfer_offers.borrow_mut().insert(
+ (header.namespace, header.replica),
+ ServedStateTransfer {
+ nonce: header.nonce,
+ offer: ServedOffer::Partition(Rc::clone(&offer)),
+ idle_ticks: 0,
+ fully_served: false,
+ },
+ );
+ Some(offer)
+ }
+ Err(reason) => {
+ // The ACTUAL reason: "not the caught-up primary" is
routine
+ // (the requester re-targets), an unreadable segment is an
+ // operator-visible fault on THIS node. The requester
cannot
+ // see the reason, only whether it was transient, which is
+ // what keeps a routine refusal from charging its failure
+ // count.
+ let transient = reason.transient();
+ tracing::info!(
+ shard = self.id,
+ namespace_raw = header.namespace,
+ requester = header.replica,
+ transient,
+ %reason,
+ "cannot serve partition state transfer; requester
falls back"
+ );
+ let (view, commit_max) = serving_progress(partition);
+ self.send_state_transfer_target(
+ cluster,
+ self_id,
+ header.replica,
+ header.nonce,
+ header.namespace,
+ TransferDescriptor::unavailable(transient, view,
commit_max),
+ )
+ .await;
+ return;
+ }
+ },
+ };
+ let Some(offer) = offer else {
+ return;
+ };
+ // Sampled AFTER any build: that build force-flushes and hashes every
+ // un-memoized segment (seconds on a first multi-GiB serve) while
reading
+ // `commit_op` post-flush, so a pre-build sample could advertise a
+ // `commit_max` below the descriptor's own `commit_op` -- which only
+ // makes the receiver's gate refuse, and refusals feed a backoff.
+ let (view, commit_max) = serving_progress(partition);
+ self.send_state_transfer_target(
+ cluster,
+ self_id,
+ header.replica,
+ header.nonce,
+ header.namespace,
+ TransferDescriptor::available(&offer.manifest(), offer.commit_op,
view, commit_max),
+ )
+ .await;
}
/// Serve one partition chunk. Segment payloads are loaded from disk on
@@ -5651,10 +5746,14 @@ where
.get_mut(&(header.namespace, header.replica))
.filter(|served| served.nonce == header.nonce);
let Some(served) = served else {
- break 'attempt
ChunkAttempt::Reply(Some(ChunkReply::UnknownOffer));
+ break 'attempt
ChunkAttempt::Reply(Some(ChunkReply::Unavailable {
+ transient: true,
+ }));
};
let ServedOffer::Partition(offer) = &served.offer else {
- break 'attempt
ChunkAttempt::Reply(Some(ChunkReply::UnknownOffer));
+ break 'attempt
ChunkAttempt::Reply(Some(ChunkReply::Unavailable {
+ transient: true,
+ }));
};
let last_artifact = offer.artifact_count().saturating_sub(1);
let artifact = header.artifact as usize;
@@ -5735,25 +5834,35 @@ where
// plus a non-yielding hash over up to 2 GiB holds the core
// long enough to miss the heartbeat and view-change
// deadlines of every group it owns.
- if let Some(bytes) =
partitions::state_transfer::load_verified_segment_artifact(
+ let loaded =
partitions::state_transfer::load_verified_segment_artifact(
&log_path, &entry,
)
- .await
- {
- self.served_segment_cache.borrow_mut().insert(
- header.namespace,
- entry.checksum,
- Rc::new(bytes),
- );
- continue;
- }
+ .await;
+ let reason = match loaded {
+ Ok(bytes) => {
+ self.served_segment_cache.borrow_mut().insert(
+ header.namespace,
+ entry.checksum,
+ Rc::new(bytes),
+ self.served_segment_cache_bytes_max.get(),
+ );
+ continue;
+ }
+ Err(reason) => reason,
+ };
+ // The CAUSE decides what the requester is told: a racing
GC
+ // or a stale offer is transient and costs it nothing,
while
+ // an unreadable device is this node's fault and must
charge,
+ // or a dying disk reads as a momentary blip forever.
+ let transient = reason.transient();
tracing::warn!(
shard = self.id,
namespace_raw = header.namespace,
artifact = header.artifact,
path = %log_path,
- "served segment no longer matches its manifest entry; \
- evicting the offer"
+ transient,
+ %reason,
+ "cannot serve the requested segment; evicting the
offer"
);
self.state_transfer_offers
.borrow_mut()
@@ -5763,7 +5872,7 @@ where
// requester would otherwise be handed the same offer with
// the same dead path, forever.
partition.clear_state_transfer_offer_cache();
- break Some(ChunkReply::UnknownOffer);
+ break Some(ChunkReply::Unavailable { transient });
}
}
};
@@ -5774,11 +5883,12 @@ where
.send_to_replica(header.replica,
chunk.into_generic().into_frozen())
.await;
}
- Some(ChunkReply::UnknownOffer) => {
+ Some(ChunkReply::Unavailable { transient }) => {
tracing::info!(
shard = self.id,
namespace_raw = header.namespace,
requester = header.replica,
+ transient,
"partition chunk request for an unknown offer; telling
requester to restart"
);
self.send_state_transfer_target(
@@ -5787,14 +5897,12 @@ where
header.replica,
header.nonce,
header.namespace,
- // TRANSIENT: routine on a busy primary -- retention GC'd a
- // served segment, or the offer simply aged out of
- // `state_transfer_offers` between two chunks. The retry
- // converges either way (the restarted session reflects the
- // current segment set), so charging the
consecutive-failure
- // count would double the backoff up to 1024x for an event
- // that is not a failure.
- TransferDescriptor::unavailable(true, serving_view,
serving_commit_max),
+ // Usually TRANSIENT -- retention GC'd a served segment, or
+ // the offer aged out between two chunks, and the restarted
+ // session converges -- but a load that failed on a local
+ // fault says so, or a dying disk would read as a momentary
+ // blip forever.
+ TransferDescriptor::unavailable(transient, serving_view,
serving_commit_max),
)
.await;
}
@@ -5819,7 +5927,7 @@ where
/// `mem::take` moves the buffer out of the session, not out of memory, so
it
/// stays resident through verify + walk + staging write, times the
in-flight
/// cap, times the shard count.
- const PARTITION_ARTIFACT_LEN_MAX: u64 =
+ const PARTITION_ARTIFACT_LEN_DEFAULT: u64 =
SEGMENT_SIZE_CEILING_BYTES + SEGMENT_SIZE_OVERSHOOT_BYTES;
/// Sanity cap across a partition manifest. Segment artifacts spill to
@@ -5838,6 +5946,29 @@ where
/// size. Capped-out arms retry via the scheduled re-arm sweep.
const PARTITION_TRANSFERS_INFLIGHT_MAX: usize = 4;
+ /// Whether arming a transfer for `namespace` is even possible right now.
+ ///
+ /// Takes a SHARED borrow and drops it before returning, so a caller may
form
+ /// its `&mut partition` afterwards. The point is to keep the in-flight
scan
+ /// -- which borrows every partition on the shard -- off frames that cannot
+ /// arm anything: a namespace this shard does not own, and the ordinary
case
+ /// of a group that is neither awaiting a transfer nor idle-with-no-re-arm.
+ fn may_arm_partition_transfer(partitions: &IggyPartitions<B, SB>,
namespace_raw: u64) -> bool
+ where
+ B: MessageBus,
+ {
+ partitions
+ .get_by_ns(&IggyNamespace::from_raw(namespace_raw))
+ .is_some_and(|partition| {
+ partition.transfer.is_none()
+ && matches!(
+ partition.consensus().state_transfer_stage(),
+ consensus::StateTransferStage::AwaitingTarget
+ | consensus::StateTransferStage::Idle
+ )
+ })
+ }
+
/// Receiving-side transfers currently in flight on this shard.
///
/// One scan per call, so callers hoist it: with per-partition groups a
@@ -5858,6 +5989,23 @@ where
.count()
}
+ /// Drop every serving-side artifact this shard holds for `namespace`: the
+ /// cached offers and the resident segment payloads behind them.
+ ///
+ /// Called where the partition's bytes stop being the bytes the offers
+ /// describe (a purge). Neither cache can detect that on its own -- offers
+ /// are keyed by `commit_op`, payloads by the manifest checksum over the
old
+ /// bytes -- so a puller mid-transfer would keep receiving purged data and
+ /// keep both expiry clocks reset while doing it.
+ fn drop_served_state_for(&self, namespace: u64) {
+ self.state_transfer_offers
+ .borrow_mut()
+ .retain(|(served_namespace, _), _| *served_namespace != namespace);
+ self.served_segment_cache
+ .borrow_mut()
+ .evict_namespace(namespace);
+ }
+
/// Whether a peer-supplied source replica id names a replica of this
/// cluster.
///
@@ -5919,7 +6067,8 @@ where
namespace_raw = namespace.inner(),
%error,
"failed to quarantine the fenced partition's segment files;
the rebuild \
- re-hydrates whatever they hold and the boot guard refuses a
holed chain"
+ does NOT re-read them -- `build_partition_fresh` plants
segment 0 with \
+ `file_exists = false` and truncates whatever remains"
),
}
self.plane.partitions().tombstone(namespace);
@@ -6042,6 +6191,7 @@ where
where
B: MessageBus + 'static,
T: ShardsTable,
+ M: StreamsFrontend,
{
let header = *msg.header();
// The peer id reaches `next_transfer_peer`'s ring arithmetic through
the
@@ -6138,7 +6288,9 @@ where
// segment cap. An unknown kind is refused here rather than pulled:
// the install cannot represent it anyway.
let kind_capped = entries.iter().all(|entry| match entry.kind {
- consensus::artifact_kind::SEGMENT_LOG => entry.len <=
Self::PARTITION_ARTIFACT_LEN_MAX,
+ consensus::artifact_kind::SEGMENT_LOG => {
+ entry.len <= self.partition_artifact_len_max.get()
+ }
consensus::artifact_kind::CONSUMER_OFFSETS => {
entry.len <= Self::CONSUMER_OFFSETS_ARTIFACT_LEN_MAX
}
@@ -6194,8 +6346,9 @@ where
// session's own promise to bound receiver memory to ONE in-flight
// artifact. Artifacts adopted by the reuse scan below would also be
// reserved and then overwritten with `Staged`, making the retry path's
- // reservation pure waste. `ArtifactProgress::extend_from_chunk`
reserves
- // on the first chunk of its own artifact instead.
+ // reservation pure waste. `append_chunk` reserves the declared length
on
+ // an artifact's FIRST chunk instead, and only ever for the artifact
the
+ // cursor is actually pulling.
session.artifacts = entries
.iter()
.map(|&entry| {
@@ -6223,6 +6376,7 @@ where
where
B: MessageBus + 'static,
T: ShardsTable,
+ M: StreamsFrontend,
{
let header = *msg.header();
let planes = self.plane.inner();
@@ -6263,14 +6417,12 @@ where
where
B: MessageBus + 'static,
T: ShardsTable,
+ M: StreamsFrontend,
{
let planes = self.plane.inner();
let config = planes.1.0.config().clone();
- let Some(partition) = planes
- .1
- .0
- .get_mut_by_ns(&IggyNamespace::from_raw(namespace))
- else {
+ let target_namespace = IggyNamespace::from_raw(namespace);
+ let Some(partition) = planes.1.0.get_mut_by_ns(&target_namespace) else
{
return;
};
// Stage/session desync bail: the probe-exhausted election fallback in
@@ -6365,9 +6517,12 @@ where
let Some(session) = partition.transfer.take() else {
return;
};
- let generation = session.commit_op;
+ // `commit_op`, NOT a "generation": in this file that word means the
+ // committed PURGE generation, and the callee's parameter is
`commit_op`.
+ let commit_op = session.commit_op;
let peer = session.peer;
let mut offsets_bytes: Option<Vec<u8>> = None;
+ let mut offsets_frontier: Option<u64> = None;
let mut damaged = false;
let mut staged = Vec::new();
for artifact in session.artifacts {
@@ -6387,6 +6542,7 @@ where
if offsets_bytes.is_some() {
damaged = true;
} else {
+ offsets_frontier = Some(progress.entry.frontier);
offsets_bytes = Some(progress.buf);
}
}
@@ -6395,6 +6551,23 @@ where
_ => damaged = true,
}
}
+ // Free self-consistency check on a durable input: the builder sets the
+ // descriptor's `commit_op` and the offsets artifact's frontier from
ONE
+ // binding, and `commit_op` goes on to drive `set_commit_floor`,
+ // `set_sequence`, `advance_commit_max` and the reported
+ // `applied_frontier`, while nothing else ever reads that frontier
back.
+ if let Some(frontier) = offsets_frontier
+ && frontier != commit_op
+ {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace,
+ commit_op,
+ offsets_frontier = frontier,
+ "descriptor commit_op disagrees with its offsets artifact
frontier; refusing the install"
+ );
+ damaged = true;
+ }
let Some(offsets_bytes) = offsets_bytes.filter(|_| !damaged) else {
tracing::warn!(
shard = self.id,
@@ -6405,11 +6578,45 @@ where
.await;
return;
};
+ // A peer that has NOT yet applied a committed purge offers pre-purge
+ // segments under the stale generation. The install's own generation
+ // handling only widens permission (`max`), so it would resurrect the
+ // purged data durably: the local applied value stays at the newer
+ // generation, and the reconciler's `committed > applied` gate never
+ // re-fires. Compared against the METADATA plane's committed value, not
+ // this partition's applied one -- the latter is memory-only and reads 0
+ // after every restart. Routed through the ordinary failure arm, which
+ // rotates the peer; worst case is one wasted pull.
+ let committed_purge_generation = self
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .partition_purge_generation(
+ target_namespace.stream_id(),
+ target_namespace.topic_id(),
+ target_namespace.partition_id(),
+ );
+ let offered_purge_generation =
+
partitions::state_transfer::offered_purge_generation(&offsets_bytes);
+ if offered_purge_generation < committed_purge_generation {
+ tracing::warn!(
+ shard = self.id,
+ namespace_raw = namespace,
+ peer,
+ offered_purge_generation,
+ committed_purge_generation,
+ "refusing a partition transfer offer built before a committed
purge; installing it would resurrect purged data"
+ );
+ self.abandon_or_rearm_partition_transfer(partition, peer)
+ .await;
+ return;
+ }
partition
.consensus()
.set_state_transfer_stage(consensus::StateTransferStage::Installing);
let outcome = partition
- .install_state_transfer(&config, generation, staged,
&offsets_bytes)
+ .install_state_transfer(&config, commit_op, staged, &offsets_bytes)
.await;
partition
.consensus()
@@ -6517,6 +6724,23 @@ where
// that spends a minute catching up costs a minute of retries rather
than
// a climb to the 1024x ceiling.
let after_ticks = self.repair_retry_ticks.get();
+ // The flat interval means a partition can sit here for hours without
+ // charging anything, so the ONLY operator signal is this count: it
+ // escalates the log level and feeds a metric, and it never touches the
+ // backoff.
+ let refusals = partition.record_transfer_refusal();
+ self.metrics.record_partition_transfer_refusal();
+ if refusals >= TRANSFER_REFUSALS_BEFORE_ESCALATION
+ && refusals.is_multiple_of(TRANSFER_REFUSALS_BEFORE_ESCALATION)
+ {
+ tracing::error!(
+ shard = self.id,
+ namespace_raw = partition.consensus().namespace(),
+ peer,
+ refusals,
+ "partition state transfer has been refused {refusals} times in
a row; this partition is not rejoining"
+ );
+ }
self.schedule_partition_transfer_rearm(partition, peer, 0, after_ticks)
.await;
}
@@ -6568,6 +6792,16 @@ where
/// Ask for the next missing partition chunk (first unspilled, incomplete
/// artifact in manifest order).
+ ///
+ /// LOCKSTEP by design: one chunk in flight, re-driven per reply, so
transfer
+ /// throughput is `state_chunk_len_max / RTT` -- roughly 26 MB/s at a 10 ms
+ /// link, about 41 s for a 1 GiB segment. `state_chunk_len_max` only clamps
+ /// downward, so no operator knob raises that ceiling; it is worth knowing
+ /// when sizing `segment.size` and retention, since rejoin time scales with
+ /// retained bytes per partition. A small in-flight window would lift it,
but
+ /// it has to grow `[partition] transfer_served_cache_bytes_max` in step
-- that
+ /// budget is sized for exactly the concurrent lockstep pulls the in-flight
+ /// cap allows.
#[allow(clippy::future_not_send)]
async fn request_pending_partition_chunk(&self, namespace: u64)
where
@@ -6609,6 +6843,16 @@ where
}
}
+ /// Drop serving-side state-transfer offers that stopped being pulled.
+ ///
+ /// An offer pins its plane's payload for as long as it lives -- the
metadata
+ /// snapshot plus the encoded client table, or a partition manifest and the
+ /// resident segment payloads behind it -- and the protocol has no
completion
+ /// frame (a receiver installs and goes quiet), so without this a primary
+ /// that ever served a transfer holds that memory for the rest of the
+ /// process. Generous relative to the chunk cadence: a live puller resets
the
+ /// counter on every chunk it fetches, so only an abandoned or finished
+ /// transfer ages out.
fn expire_idle_state_transfer_offers(&self) {
self.served_segment_cache.borrow_mut().expire_idle();
// `max(1)`: the retry interval is operator-configurable, and a zero
would
diff --git a/core/shard/src/metrics.rs b/core/shard/src/metrics.rs
index 5b8fa3438..12c11c3bf 100644
--- a/core/shard/src/metrics.rs
+++ b/core/shard/src/metrics.rs
@@ -186,6 +186,7 @@ pub struct ShardMetrics {
partitions_materialised_total: Counter,
partitions_removed_total: Counter,
partitions_reconcile_failures_total: Counter,
+ partition_transfer_refusals_total: Counter,
partition_frames_rejected_stale_total: Counter,
partition_frames_rejected_ahead_total: Counter,
partition_requests_denied_transient_total: Counter,
@@ -218,6 +219,7 @@ impl ShardMetrics {
partitions_materialised_total: Counter::default(),
partitions_removed_total: Counter::default(),
partitions_reconcile_failures_total: Counter::default(),
+ partition_transfer_refusals_total: Counter::default(),
partition_frames_rejected_stale_total: Counter::default(),
partition_frames_rejected_ahead_total: Counter::default(),
partition_requests_denied_transient_total: Counter::default(),
@@ -263,6 +265,18 @@ impl ShardMetrics {
self.partitions_reconcile_failures_total.inc();
}
+ /// Bumped every time a serving peer refuses a partition state transfer.
+ ///
+ /// Transient refusals re-arm on a flat interval and charge no failure
+ /// count -- deliberately, since the alternative routes through a 1024x
+ /// backoff cap that pins a partition for ~17 minutes after the peer has
+ /// already caught up -- which also means a partition stuck rejoining for
+ /// hours produces no signal of its own. This counter plus the escalating
+ /// log level at the refusal site is that signal.
+ pub fn record_partition_transfer_refusal(&self) {
+ self.partition_transfer_refusals_total.inc();
+ }
+
/// Bumped when a parked partition frame is answered instead of served
/// because it was addressed to an incarnation this shard no longer holds
/// (delete + recreate recycled the namespace's slab keys). Serving it
would
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index 91e9591fb..ea9c8d206 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -669,12 +669,23 @@ where
&& partition.applied_purge_generation() < generation
{
match partition.purge(&config, generation).await {
- Ok(()) => tracing::debug!(
- shard = self.id,
- namespace_raw = namespace.inner(),
- generation,
- "purge-partition reset partition to empty"
- ),
+ Ok(()) => {
+ // The purge unlinked the very bytes this shard is
+ // serving: the cached offer still advertises the
+ // pre-purge manifest and the payload cache can
+ // answer chunk requests for it without touching
+ // disk, so a puller would install purged data.
Both
+ // are keyed on pre-purge content, so neither can
+ // notice on its own.
+ partition.clear_state_transfer_offer_cache();
+ self.drop_served_state_for(namespace.inner());
+ tracing::debug!(
+ shard = self.id,
+ namespace_raw = namespace.inner(),
+ generation,
+ "purge-partition reset partition to empty"
+ );
+ }
Err(error) => {
// The purge drained every segment before its first
// fallible step, so a failure leaves the partition