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 1a2d35c63 address review comments
1a2d35c63 is described below
commit 1a2d35c637d498b300b9af860d33d7e836ef252e
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Wed Aug 5 19:55:28 2026 +0200
address review comments
---
core/binary_protocol/src/consensus/header.rs | 39 ++-
core/binary_protocol/src/consensus/operation.rs | 15 ++
core/consensus/src/impls.rs | 6 +-
core/consensus/src/state_transfer.rs | 30 ++-
core/integration/src/bench_utils.rs | 38 +--
.../tests/server/partition_view_durability_vsr.rs | 13 +
.../server/scenarios/purge_delete_scenario.rs | 24 +-
core/metadata/src/impls/metadata.rs | 3 +-
core/partitions/src/iggy_partition.rs | 164 ++++++++++++
core/partitions/src/journal.rs | 8 +-
core/partitions/src/state_transfer.rs | 276 ++++++++++++---------
core/server-ng/src/bootstrap.rs | 14 +-
core/shard/src/lib.rs | 259 ++++++++++++-------
13 files changed, 624 insertions(+), 265 deletions(-)
diff --git a/core/binary_protocol/src/consensus/header.rs
b/core/binary_protocol/src/consensus/header.rs
index 6dd003ddb..12213676b 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -1413,15 +1413,6 @@ pub struct StateTransferTargetHeader {
/// Serving primary's applied frontier (`commit_min`) when the descriptor
/// was built. The receiver's tail repair targets past this.
pub commit_op: u64,
- /// Serving replica's `commit_max` when the descriptor was built.
- ///
- /// A receiver refuses an offer from a replica that knows LESS than it
does:
- /// without this the descriptor carried no proof of the sender's own
- /// progress, and a phantom view-0 primary (a group whose directory
vanished
- /// boots `init()` rather than `init_as_backup()`, comes up Normal at view
0,
- /// and an empty log is trivially caught up) could hand a data-holding
- /// rejoiner an empty offer that unlinks its chain.
- pub commit_max: u64,
pub namespace: u64,
pub available: u8,
/// Set on an `available == 0` refusal that means "not right now" rather
than
@@ -1430,8 +1421,27 @@ pub struct StateTransferTargetHeader {
/// 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:
+ /// this header ships in the `iggy_binary_protocol` crate, the size assert
+ /// cannot catch an equal-size reshuffle, and nothing on the link carries a
+ /// version signal -- a mid-struct insertion is silent non-interop between
+ /// mixed builds.
pub unavailable_transient: u8,
- pub reserved: [u8; 86],
+ /// Explicit padding so `commit_max` sits 8-aligned without the implicit
+ /// padding `NoUninit` forbids.
+ pub reserved_alignment: [u8; 6],
+ /// Serving replica's `commit_max` when the descriptor was built.
+ ///
+ /// A receiver refuses an offer from a replica that knows LESS than it
does:
+ /// without this the descriptor carried no proof of the sender's own
+ /// progress, and a phantom view-0 primary (a group whose directory
vanished
+ /// boots `init()` rather than `init_as_backup()`, comes up Normal at view
0,
+ /// and an empty log is trivially caught up) could hand a data-holding
+ /// rejoiner an empty offer that unlinks its chain.
+ pub commit_max: u64,
+ pub reserved: [u8; 80],
}
const _: () = {
assert!(size_of::<StateTransferTargetHeader>() == HEADER_SIZE);
@@ -1439,7 +1449,14 @@ const _: () = {
offset_of!(StateTransferTargetHeader, nonce)
== offset_of!(StateTransferTargetHeader, reserved_frame) +
size_of::<[u8; 66]>()
);
- assert!(offset_of!(StateTransferTargetHeader, reserved) + size_of::<[u8;
86]>() == HEADER_SIZE);
+ // The pre-existing published offsets. New fields grow into the reserved
+ // tail only; a change that moves one of these is a wire break.
+ assert!(offset_of!(StateTransferTargetHeader, commit_op) == 144);
+ assert!(offset_of!(StateTransferTargetHeader, namespace) == 152);
+ assert!(offset_of!(StateTransferTargetHeader, available) == 160);
+ assert!(offset_of!(StateTransferTargetHeader, unavailable_transient) ==
161);
+ assert!(offset_of!(StateTransferTargetHeader, commit_max) == 168);
+ assert!(offset_of!(StateTransferTargetHeader, reserved) + size_of::<[u8;
80]>() == HEADER_SIZE);
};
impl ConsensusHeader for StateTransferTargetHeader {
diff --git a/core/binary_protocol/src/consensus/operation.rs
b/core/binary_protocol/src/consensus/operation.rs
index 11d15a106..026ff9bca 100644
--- a/core/binary_protocol/src/consensus/operation.rs
+++ b/core/binary_protocol/src/consensus/operation.rs
@@ -186,6 +186,21 @@ impl Operation {
(*self as u8) >= Self::PARTITION_START
}
+ /// Operations that replicate through the METADATA consensus group and live
+ /// in its WAL.
+ ///
+ /// Wider than [`Self::is_metadata`]: the session ops replicate on the
+ /// metadata plane without being metadata mutations. The single source of
+ /// truth for "does the metadata plane own this op", shared by the plane's
+ /// own applicability predicate and the repair router's legacy-stamp
+ /// acceptance -- the two drifting is how a metadata op ends up offered to
+ /// the partition arm.
+ #[must_use]
+ #[inline]
+ pub const fn is_metadata_plane(&self) -> bool {
+ self.is_metadata() || matches!(self, Self::Register | Self::Logout)
+ }
+
/// Operations clients are allowed to send directly.
#[must_use]
#[inline]
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 17c3c1fa2..dda3fa9a8 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -3046,7 +3046,11 @@ where
// never re-stamped (`restamp_prepare_view` patches only `view`), so
this
// survives view-change retransmits. The header `checksum` and its
`parent`
// chain stay `0`: activating them needs the retransmit path to
re-seal a
- // re-stamped header, a separate change.
+ // re-stamped header, a separate change. Whoever activates it must also
+ // audit every `set_last_prepare_checksum` caller for cross-plane
carry --
+ // the repair router in `shard` drops metadata-plane frames it cannot
+ // journal precisely so one cannot stamp a PARTITION consensus, which
is
+ // inert only while these values are structurally zero.
//
// Metadata plane only. A partition produce prepare already carries a
verified
// `batch_checksum` over the same bytes, so a second full-payload pass
is pure
diff --git a/core/consensus/src/state_transfer.rs
b/core/consensus/src/state_transfer.rs
index fc6c28645..0a1ff3768 100644
--- a/core/consensus/src/state_transfer.rs
+++ b/core/consensus/src/state_transfer.rs
@@ -133,16 +133,22 @@ pub fn next_pending_chunk<T: ChunkProgress>(
/// Append one received chunk; `true` only when bytes actually landed, which
/// is the caller's cue to reset its liveness counters and re-drive progress.
///
-/// Everything else is dropped without side effects: an out-of-range artifact
-/// index, a non-sequential offset (chunks are pulled lockstep, so anything
-/// else is a duplicate or reorder -- the stall retry re-requests from the
-/// current frontier), an overrun past the declared length, and a zero-byte
-/// payload. A zero-byte payload is not progress: it extends nothing and the
-/// same offset is re-requested immediately, and resetting liveness counters
-/// on one is what turned a short rebuilt offer into an unbounded empty-frame
-/// ping-pong on the metadata plane. The serving side refuses to produce
-/// these now; the guard stays because a peer running an older build still
-/// can.
+/// Everything else is dropped without side effects: an artifact that is not
+/// the FIRST incomplete one, a non-sequential offset (chunks are pulled
+/// lockstep, so anything else is a duplicate or reorder -- the stall retry
+/// re-requests from the current frontier), an overrun past the declared
+/// length, and a zero-byte payload. The first-incomplete restriction mirrors
+/// what [`next_pending_chunk`] would have requested: 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
+/// immediately, and resetting liveness counters on one is what turned a
+/// short rebuilt offer into an unbounded empty-frame ping-pong on the
+/// metadata plane. The serving side refuses to produce these now; the guard
+/// stays because a peer running an older build still can.
#[must_use]
pub fn append_chunk<T: ChunkProgress>(
artifacts: &mut [T],
@@ -150,6 +156,10 @@ pub fn append_chunk<T: ChunkProgress>(
offset: u64,
payload: &[u8],
) -> bool {
+ let first_incomplete = artifacts.iter().position(|artifact|
!artifact.complete());
+ if first_incomplete != Some(artifact_index as usize) {
+ return false;
+ }
let Some(artifact) = artifacts.get_mut(artifact_index as usize) else {
return false;
};
diff --git a/core/integration/src/bench_utils.rs
b/core/integration/src/bench_utils.rs
index 966d6a25b..74c2d56bd 100644
--- a/core/integration/src/bench_utils.rs
+++ b/core/integration/src/bench_utils.rs
@@ -20,8 +20,7 @@ use assert_cmd::prelude::CommandCargoExt;
use iggy::prelude::*;
use iggy_common::TransportProtocol;
use std::{
- fs::{self, File, OpenOptions},
- io::Write,
+ fs::{self, File},
process::{Command, Stdio},
thread::{self, panicking},
time::{Duration, Instant},
@@ -127,35 +126,12 @@ pub fn run_bench_and_wait_for_finish(
}
};
- // Only for a child that exited on its own: a killed-and-reaped one has no
- // output left to collect, and `wait_with_output` on it would just fail.
- if !timed_out {
- let output = child
- .wait_with_output()
- .expect("failed to get output from iggy-bench");
- let stderr = String::from_utf8_lossy(&output.stderr);
- let stdout = String::from_utf8_lossy(&output.stdout);
- if let Some(stderr_file_path) = &stderr_file_path {
- OpenOptions::new()
- .append(true)
- .create(true)
- .open(stderr_file_path)
- .unwrap()
- .write_all(stderr.as_bytes())
- .unwrap();
- }
-
- if let Some(stdout_file_path) = &stdout_file_path {
- OpenOptions::new()
- .append(true)
- .create(true)
- .open(stdout_file_path)
- .unwrap()
- .write_all(stdout.as_bytes())
- .unwrap();
- }
- }
-
+ // Nothing to drain, by construction: both branches above redirect the
+ // child's stdout and stderr -- to files, or inherited under
+ // `IGGY_TEST_VERBOSE` -- so no pipe exists for the poll loop to deadlock
+ // against. The old `wait_with_output` capture here could only ever return
+ // empty buffers for the same reason; the captures the failure path prints
+ // are the redirect FILES.
let failed = timed_out || status.is_none_or(|status| !status.success());
if failed || panicking() {
for (stream, path) in [("stdout", &stdout_file_path), ("stderr",
&stderr_file_path)] {
diff --git a/core/integration/tests/server/partition_view_durability_vsr.rs
b/core/integration/tests/server/partition_view_durability_vsr.rs
index 6e6e42a77..acbbb8171 100644
--- a/core/integration/tests/server/partition_view_durability_vsr.rs
+++ b/core/integration/tests/server/partition_view_durability_vsr.rs
@@ -165,6 +165,19 @@ async fn
given_advanced_partition_view_when_survivor_restarts_should_recover_vie
// fires, so re-reading it here would return the pre-restart record even if
// recovery were broken and node 2 came back at view 0. Node 2's boot line
// reports the view it actually restored, so it must name the recorded one.
+ //
+ // Skipped when the harness inherits the node's stdout instead of capturing
+ // it (`IGGY_TEST_VERBOSE`): the log file is then empty, and asserting on
it
+ // would fail spuriously in exactly the mode someone debugging this would
+ // use. The serve check above still ran.
+ let log_captured = !harness.node(2).stdout_plain().is_empty();
+ if !log_captured {
+ eprintln!(
+ "IGGY_TEST_VERBOSE inherits node stdout, so the restored-view
oracle is \
+ unavailable; skipping it"
+ );
+ return;
+ }
let restored = restored_partition_view(harness, 2)
.expect("node 2 must log the partition view it restored from its
superblock");
assert!(
diff --git a/core/integration/tests/server/scenarios/purge_delete_scenario.rs
b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
index 77c9c53e2..97cf1c816 100644
--- a/core/integration/tests/server/scenarios/purge_delete_scenario.rs
+++ b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
@@ -985,15 +985,21 @@ pub async fn run_purge_topic(harness: &mut TestHarness,
restart_server: bool) {
await_segment_layout(&partition_path, &[0]).await;
// --- Verify consumer offsets cleared (memory + disk) ---
- // POLLED, not asserted instantly: in the restart cells the kill can land
- // mid-purge, and boot then plants the [0] layout itself (fencing a torn
- // chain, or recovering an already-drained directory) with the offset files
- // still present -- the layout gate above is satisfied BEFORE the
- // reconciler's re-purge (the applied generation is not persisted, so a
- // restart re-purges) clears them. The purge contract is that the offsets
- // GO AWAY, not that they are gone in the same frame as a boot-planted
- // layout, so converge on the cleared state.
- let offsets_deadline = std::time::Instant::now() +
std::time::Duration::from_secs(10);
+ // Polled ONLY in the restart cells: there the kill can land mid-purge, and
+ // boot then plants the [0] layout itself (fencing a torn chain, or
+ // recovering an already-drained directory) with the offset files still
+ // present -- the layout gate above is satisfied BEFORE the reconciler's
+ // re-purge (the applied generation is not persisted, so a restart
+ // re-purges) clears them. Without a restart the pump clears offsets and
+ // 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
+ };
loop {
let consumer_offset = client
.get_consumer_offset(&consumer, &stream_ident, &topic_ident,
Some(PARTITION_ID))
diff --git a/core/metadata/src/impls/metadata.rs
b/core/metadata/src/impls/metadata.rs
index 01e0916a2..55d4fb042 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -1288,8 +1288,7 @@ where
message.header().command(),
Command2::Request | Command2::Prepare | Command2::PrepareOk
));
- let op = message.header().operation();
- op.is_metadata() || matches!(op, Operation::Register |
Operation::Logout)
+ message.header().operation().is_metadata_plane()
}
}
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index 5761aee4e..7cf5f6b14 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -3435,6 +3435,19 @@ where
for path in consumer_paths.into_iter().chain(group_paths) {
let _ = delete_persisted_offset(&path).await;
}
+ // Directory fsync so those unlinks stick, mirroring the install path:
a
+ // crash right after the purge otherwise resurrects the offset files at
+ // boot, and while recovery clamps a resurrected offset down to the
+ // rebuilt head, "consumed through 0" is not the intended "no entry at
+ // all" -- that consumer skips the first post-purge message.
+ for dir in self
+ .consumer_offsets_path
+ .clone()
+ .into_iter()
+ .chain(self.consumer_group_offsets_path.clone())
+ {
+ let _ = crate::state_transfer::fsync_dir(&dir).await;
+ }
// The persisted-offset tracker mirrors the files unlinked above; a
// stale entry would make a post-purge auto-commit skip its write and
// lose the offset on restart.
@@ -5361,6 +5374,157 @@ mod tests {
assert_eq!(partition.consensus().commit_min(), 0);
assert!(partition.repair.is_some());
}
+ /// Temp partition directory for the state-transfer fence specs below.
+ async fn transfer_fence_dir(label: &str) -> String {
+ let dir = std::env::temp_dir().join(format!(
+ "iggy-transfer-fence-{label}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_nanos(),
+ ));
+ compio::fs::create_dir_all(&dir)
+ .await
+ .expect("create temp partition dir");
+ dir.to_string_lossy().into_owned()
+ }
+
+ fn armed_transfer(peer: u8) ->
crate::state_transfer::PartitionTransferSession {
+ crate::state_transfer::PartitionTransferSession {
+ nonce: 7,
+ peer,
+ commit_op: 12,
+ artifacts: Vec::new(),
+ target_accepted: true,
+ idle_ticks: 0,
+ }
+ }
+
+ /// A purge must not leave a transfer running: its staged segments hold
+ /// PRE-purge data, and completing the install renames it back in durably
+ /// (the install takes `max(offer generation, applied)`, and this purge
+ /// already stamped the newer one, so the reconciler's purge gate never
+ /// re-fires).
+ #[compio::test]
+ async fn
given_armed_transfer_when_purged_should_abandon_session_and_rearm() {
+ let partition_dir = transfer_fence_dir("purge-abandons").await;
+ let mut partition = test_partition();
+ partition.set_partition_dir(partition_dir.clone());
+ partition.transfer = Some(armed_transfer(1));
+ partition.transfer_rearm =
Some(crate::state_transfer::PendingTransferRearm {
+ peer: 2,
+ after_ticks: 5,
+ });
+ partition.consensus().begin_state_transfer_await();
+
+ partition
+ .purge(&repair_config(), 3)
+ .await
+ .expect("purge partition");
+
+ assert!(
+ partition.transfer.is_none(),
+ "purge must drop the in-flight transfer session"
+ );
+ assert!(
+ partition.transfer_rearm.is_none(),
+ "purge must cancel the scheduled re-arm"
+ );
+ assert_eq!(
+ partition.consensus().state_transfer_stage(),
+ consensus::StateTransferStage::Idle,
+ "purge must release the transfer stage so a later trigger can arm"
+ );
+
+ let _ = std::fs::remove_dir_all(&partition_dir);
+ }
+
+ /// An offer whose frontier sits below this replica's own offset counter is
+ /// refused: installing it would rewind the counter, and the next
replicated
+ /// prepare is re-stamped from it, so this replica would persist different
+ /// bytes (and a different `batch_checksum`) than the rest of the group.
+ #[compio::test]
+ async fn
given_offer_below_local_counter_when_installed_should_refuse_rewind() {
+ let partition_dir = transfer_fence_dir("rewind-refused").await;
+ let mut partition = test_partition();
+ partition.set_partition_dir(partition_dir.clone());
+ partition.should_increment_offset = true;
+ partition.offset.store(99, Ordering::Release);
+
+ let behind = crate::state_transfer::ConsumerOffsetsWire {
+ purge_generation: 0,
+ next_offset: 50,
+ consumers: Vec::new(),
+ groups: Vec::new(),
+ };
+ let refused = partition
+ .install_state_transfer(&repair_config(), 12, Vec::new(),
&behind.encode())
+ .await;
+ assert!(
+ matches!(
+ refused,
+ Err(
+
crate::state_transfer::PartitionInstallError::OfferRewindsDurableData {
+ offer_next_offset: 50,
+ local_next_offset: 100,
+ }
+ )
+ ),
+ "expected a rewind refusal, got {refused:?}"
+ );
+
+ // A purge at the origin is the one legitimate rewind, and the artifact
+ // carries the generation that proves it: the same offer passes the
fence
+ // once its generation advances.
+ let purged = crate::state_transfer::ConsumerOffsetsWire {
+ purge_generation: 1,
+ next_offset: 0,
+ consumers: Vec::new(),
+ groups: Vec::new(),
+ };
+ let accepted = partition
+ .install_state_transfer(&repair_config(), 12, Vec::new(),
&purged.encode())
+ .await;
+ assert!(
+ !matches!(
+ accepted,
+
Err(crate::state_transfer::PartitionInstallError::OfferRewindsDurableData { ..
})
+ ),
+ "a purge-advancing offer must pass the rewind fence, got
{accepted:?}"
+ );
+
+ let _ = std::fs::remove_dir_all(&partition_dir);
+ }
+
+ /// Primary-by-index at view 0 with nothing committed refuses to serve: an
+ /// empty group is trivially "caught up", so this gate is the only thing
+ /// separating a real primary from a phantom whose directory vanished,
whose
+ /// zero-segment offer at frontier 0 would make a data-holding receiver
+ /// unlink its chain.
+ #[compio::test]
+ async fn given_nothing_committed_when_offer_requested_should_refuse() {
+ let partition_dir = transfer_fence_dir("nothing-committed").await;
+ let mut partition = test_partition();
+ partition.set_partition_dir(partition_dir.clone());
+ assert_eq!(partition.consensus().commit_max(), 0);
+
+ let refused = partition.state_transfer_offer(&repair_config()).await;
+ assert!(
+ matches!(
+ refused,
+
Err(crate::state_transfer::PartitionTransferUnavailable::NothingCommitted)
+ ),
+ "expected a NothingCommitted refusal, got {refused:?}"
+ );
+ assert!(
+ refused.is_err_and(|reason| reason.transient()),
+ "the refusal must be transient: the requester rotates rather than \
+ charging its failure count"
+ );
+
+ let _ = std::fs::remove_dir_all(&partition_dir);
+ }
}
#[cfg(test)]
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index a24dc6b79..01487f66b 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -682,7 +682,13 @@ 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);
- let mut present: HashSet<u64> = HashSet::with_capacity(headers.len());
+ // 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.
+ #[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 mut holds_messages = false;
for header in headers
.iter()
diff --git a/core/partitions/src/state_transfer.rs
b/core/partitions/src/state_transfer.rs
index d11ecd136..ec1cbd03f 100644
--- a/core/partitions/src/state_transfer.rs
+++ b/core/partitions/src/state_transfer.rs
@@ -140,14 +140,6 @@ impl TransferArtifact {
Self::Staged(_) => None,
}
}
-
- #[must_use]
- pub const fn complete(&self) -> bool {
- match self {
- Self::Pending(progress) => progress.complete(),
- Self::Staged(_) => true,
- }
- }
}
impl consensus::ChunkProgress for TransferArtifact {
@@ -184,6 +176,9 @@ impl consensus::ChunkProgress for TransferArtifact {
pub struct StagedSegmentMeta {
pub start_offset: u64,
pub end_offset: u64,
+ /// Byte length of the locally rebuilt sparse index sidecar, recorded at
+ /// the walk so the install does not re-stat the renamed file.
+ pub index_size: u64,
/// Payload byte length == the manifest entry's `len` == the final `.log`
/// file size.
pub size: u64,
@@ -681,9 +676,6 @@ pub(crate) fn walk_segment_payload(
base_offset: u64,
bytes: &[u8],
) -> Result<(SegmentWalkStats, Vec<u8>), SegmentWalkError> {
- if bytes.is_empty() {
- return Err(SegmentWalkError::Empty);
- }
let mut position = 0usize;
let mut next_offset = base_offset;
let mut stats: Option<SegmentWalkStats> = None;
@@ -940,12 +932,14 @@ pub enum PartitionInstallError {
commit_op: u64,
commit_min: u64,
},
- /// The offer's offset frontier is at or below the offsets this replica
- /// already holds durably, so installing it would unlink data the offer
- /// does not carry (and, at frontier 0, fork the offset space).
+ /// The offer's offset frontier is below this replica's own offset
+ /// counter, so installing it would rewind the offset space: the next
+ /// replicated prepare would be re-stamped from the rewound counter and
+ /// persist different bytes (and a different `batch_checksum`) here than
+ /// on the rest of the group.
OfferRewindsDurableData {
offer_next_offset: u64,
- durable_end: u64,
+ local_next_offset: u64,
},
Offsets(ConsumerOffsetsWireError),
/// Duplicate base offset in the staged set.
@@ -995,11 +989,11 @@ impl fmt::Display for PartitionInstallError {
),
Self::OfferRewindsDurableData {
offer_next_offset,
- durable_end,
+ local_next_offset,
} => write!(
f,
- "offer frontier {offer_next_offset} is at or below the
recovered durable end \
- {durable_end}; installing it would drop offsets this replica
already holds"
+ "offer frontier {offer_next_offset} is below this replica's
own next offset \
+ {local_next_offset}; installing it would rewind the offset
space"
),
Self::Offsets(source) => write!(f, "consumer-offsets artifact
rejected: {source}"),
Self::DuplicateSegment { start_offset } => {
@@ -1101,7 +1095,16 @@ pub async fn quarantine_segment_files(partition_dir:
&str) -> std::io::Result<St
};
compio::fs::rename(&path, &PathBuf::from(&target).join(name)).await?;
}
+ // All three touched directories: the target (its new dirents), the source
+ // (the removals), and the source's parent (the target directory itself is
a
+ // new dirent there). Without the target-side syncs a crash can leave the
+ // moved files linked in neither directory -- only forensics are at stake,
+ // but forensics are the whole point of the copies.
+ fsync_dir(&target).await?;
fsync_dir(partition_dir).await?;
+ if let Some(parent) =
Path::new(partition_dir).parent().and_then(Path::to_str) {
+ fsync_dir(parent).await?;
+ }
Ok(target)
}
@@ -1153,7 +1156,7 @@ struct PlannedOffsetWrite {
/// fsync the partition directory so a rename made durable stays durable.
/// Async so the wait parks the task instead of the whole shard reactor;
/// every other future on the pump keeps running through it.
-async fn fsync_dir(partition_dir: &str) -> std::io::Result<()> {
+pub(crate) async fn fsync_dir(partition_dir: &str) -> std::io::Result<()> {
compio::fs::File::open(partition_dir)
.await?
.sync_all()
@@ -1199,8 +1202,17 @@ where
// `consensus.init()`, comes up Normal at view 0, and an empty group is
// trivially "caught up". Its offer would be zero segments at frontier
// 0, which makes a receiver holding real data unlink its own chain.
- // Nothing committed also means nothing worth serving, so refuse and
- // let the requester rotate to a replica that has state.
+ //
+ // A RESTARTED replica holding a full chain matches this shape too
+ // (`commit_max == 0` because the partition journal is memory-only,
+ // `installed_frontier == None` for a recovered non-empty chain). That
+ // is the load-bearing reason this refusal is safe rather than a
+ // wedge: every transfer-arm site presupposes a peer that already
+ // reported commit > 0 (repair floor refusals, StartView adoption), so
+ // nobody ever asks a cluster where everything still reports 0.
+ // Extending the gate with `recovered_durable_offset.is_some()` would
+ // be WRONG: such an offer carries `commit_op = 0`, so the receiver's
+ // floor becomes a no-op while its counter jumps to the frontier.
if self.consensus().commit_max() == 0 &&
self.installed_frontier.is_none() {
return Err(PartitionTransferUnavailable::NothingCommitted);
}
@@ -1370,7 +1382,7 @@ where
SegmentChecksumMemo::new()
}
};
- hash_segment_range(log_path, memo.hashed_len, size, &mut memo.hasher)
+ hash_segment_range(log_path, memo.hashed_len, size, &mut memo.hasher,
None)
.await
.map_err(|source| PartitionTransferUnavailable::SegmentUnreadable {
start_offset,
@@ -1385,27 +1397,10 @@ where
Ok(checksum)
}
- /// Move this partition's SEGMENT files aside into `<dir>.fenced.<n>/`, for
- /// the shard's `ConvergeFailed` fence: the converge already failed to
sweep
- /// them, so whatever they hold (partial chains, undeletable strays) must
not
- /// feed the reconciler's rebuild or resurrect at boot. Picks the first
free
- /// suffix rather than a timestamp so repeated fences of the same group
- /// cannot collide, and returns the directory it used so the fence site can
- /// name it.
- ///
- /// The partition directory itself STAYS, and so do its two superblock
- /// slots: they hold this group's only durable `(view, log_view)`, and
moving
- /// them would make the rebuild read an empty directory -- no
- /// `restore_partition_view`, `consensus.init()` instead of
- /// `init_as_backup()`, no replica-identity guard -- so the group would
- /// re-enter view 0 after acting in view N and could answer a retransmitted
- /// DVC with `(0, 0)`, letting a quorum adopt a log shorter than the
- /// committed prefix.
- ///
- /// Nothing reclaims the fenced copies: they are evidence for an operator,
- /// bounded to 1000 per partition by the suffix search and never read again
- /// (boot recovery keys on `.log` files inside the partition directory, and
- /// the fenced subdirectory is not one).
+ /// [`quarantine_segment_files`] over this partition's directory, for the
+ /// shard's `ConvergeFailed` fence -- the safety argument (segment files
+ /// move, superblock slots STAY, copies are unreclaimed operator evidence)
+ /// lives on the free function. `None` for an in-memory partition.
///
/// # Errors
/// The underlying `std::io::Error`; the caller logs and lets the rebuild
@@ -1506,6 +1501,7 @@ where
let (stats, index_bytes) =
walk_segment_payload(entry.frontier,
&bytes).map_err(SpillError::Walk)?;
let (log_staging, index_staging) = staging_paths(&partition_dir,
entry.frontier);
+ let index_size = index_bytes.len() as u64;
// Two writes, not a loop: each moves its buffer into compio's
// owned-buffer API, so a segment-sized payload is never copied.
write_staging_file(&log_staging, bytes)
@@ -1530,6 +1526,7 @@ where
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,
@@ -1546,6 +1543,9 @@ where
/// time) is exactly the work reuse exists to skip. The index write
/// stays: the scan never checks `.index.staging`, and a missing sidecar
/// would hand the install a missing rename source.
+ ///
+ /// No directory fsync here: every sidecar lands in the same directory, so
+ /// the caller fsyncs ONCE after its loop instead of once per adoption.
async fn adopt_staged_segment(
&self,
entry: &consensus::StateArtifact,
@@ -1557,22 +1557,18 @@ where
let (stats, index_bytes) =
walk_segment_payload(entry.frontier,
bytes).map_err(SpillError::Walk)?;
let (log_staging, index_staging) = staging_paths(&partition_dir,
entry.frontier);
+ let index_size = index_bytes.len() as u64;
write_staging_file(&index_staging, index_bytes)
.await
.map_err(|source| SpillError::StagingIo {
path: index_staging.clone(),
source,
})?;
- fsync_dir(&partition_dir)
- .await
- .map_err(|source| SpillError::StagingIo {
- path: PathBuf::from(&partition_dir),
- source,
- })?;
Ok(StagedSegmentMeta {
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,
@@ -1655,6 +1651,15 @@ where
adopted.push((index as u32, meta));
}
}
+ // Every rebuilt sidecar landed in the same directory, so one fsync
+ // covers them all. Its failure discards EVERY adoption: the per-adopt
+ // filter above no longer sees a durability failure, and an undurable
+ // sidecar handed to the install is a missing rename source after a
+ // crash.
+ if !adopted.is_empty() && fsync_dir(&partition_dir).await.is_err() {
+ adopted.clear();
+ matched_paths.clear();
+ }
// Sweep strays: anything staged that no adopted meta claims.
let keep: Vec<&Path> =
matched_paths.iter().map(PathBuf::as_path).collect();
sweep_staging_except(&partition_dir, &keep).await;
@@ -1702,24 +1707,33 @@ where
});
}
let offsets_wire = ConsumerOffsetsWire::decode(offsets_bytes)?;
- // Anti-rewind against LOCAL DURABLE BYTES, not just the commit
+ // Anti-rewind against the LOCAL OFFSET COUNTER, not the commit
// frontier: the partition journal is memory-only and
// `restore_partition_view` restores view/log_view alone, so
`commit_min`
// is 0 after every restart however much data sits on disk -- the
// `StaleTransfer` refusal above is inert on exactly the canonical
- // rejoin. An offer whose frontier is at or below the recovered durable
- // end would unlink a chain that already covers those offsets, and at
- // frontier 0 restart the offset space, forking every future batch
stamp
- // from the rest of the group. A purge is the one legitimate rewind,
and
- // the artifact carries the generation that proves one happened.
+ // rejoin. The counter is the one signal that is `Some`-equivalent in
+ // EVERY state the offset space has advanced through (recovered bytes,
+ // an installed frontier, a converge after a failed install --
+ // `recovered_durable_offset` is `None` in the last two), and it is
+ // precisely what a rewind corrupts: received prepares are pre-stamp,
+ // `stamp_prepare_for_persistence` overwrites `base_offset` from this
+ // counter and recomputes `batch_checksum` over it, so a rewound
+ // counter persists different bytes and a different checksum on this
+ // replica than on the rest of the group. A purge is the one
+ // legitimate rewind, and the artifact carries the generation that
+ // proves one happened.
let purge_advances = offsets_wire.purge_generation >
self.applied_purge_generation;
- if !purge_advances
- && let Some(durable_end) = self.recovered_durable_offset
- && offsets_wire.next_offset <= durable_end
+ let local_next_offset = if self.should_increment_offset {
+ self.offset.load(Ordering::Acquire) + 1
+ } else {
+ 0
+ };
+ if !purge_advances && local_next_offset > 0 &&
offsets_wire.next_offset < local_next_offset
{
return Err(PartitionInstallError::OfferRewindsDurableData {
offer_next_offset: offsets_wire.next_offset,
- durable_end,
+ local_next_offset,
});
}
staged.sort_unstable_by_key(|meta| meta.start_offset);
@@ -1789,7 +1803,7 @@ where
// Sweep staging strays a dead earlier attempt left behind, keeping
// only what THIS install is about to rename. Bounded disk hygiene;
// the reuse-scan sweeps too, and boot sweeps ALL of `.staging`
- // (`segment_recovery::sweep_staging_files`), so a transfer abandoned
+ // (`segment_recovery::sweep_scratch_files`), so a transfer abandoned
// for good leaks at most until the next restart.
let keep: Vec<&Path> = staged
.iter()
@@ -1857,6 +1871,19 @@ where
// contiguous partition and re-triggers transfer for the rest. Not
// fewer fsyncs than this: one-per-pair would lean on intra-directory
// rename ordering POSIX does not grant.
+ //
+ // KNOWN WINDOW, above and here: the old chain's unlinks are already
+ // durable and no staged log has landed yet, and nothing durable names
+ // the offset frontier in between -- a crash there boots to zero
+ // segments and counter 0. Bounded, not silent: the gap check drops
+ // live prepares at sequencer 0 and the repair floor refuses a `None`
+ // stand-in against a nonzero first batch, so the replica takes a clean
+ // full re-transfer instead of serving a hole. One narrow door stays
+ // open until the frontier gets a durable home (the partition
+ // superblock already reserves a field for it):
+ // `repaired_window_is_offsets_only` can accept a complete
+ // offsets-only window with the counter still at 0, after which the
+ // next live append stamps `base_offset` 0 against the group's N.
for meta in &staged {
let (_, index_final) = final_paths(partition_dir,
meta.start_offset);
compio::fs::rename(&meta.index_staging, &index_final)
@@ -1894,23 +1921,32 @@ where
// hydrate pattern; earlier segments are sealed and never written).
for meta in &staged {
let (log_final, index_final) = final_paths(partition_dir,
meta.start_offset);
- let index_len = compio::fs::metadata(&index_final)
- .await
- .map_or(0, |metadata| metadata.len());
- let storage = SegmentStorage::new(
- &log_final,
- &index_final,
- meta.size,
- index_len,
- config.enforce_fsync,
- config.enforce_fsync,
- true,
- )
- .await
- .map_err(|source| PartitionInstallError::SegmentOpen {
- path: log_final.clone(),
- source,
- })?;
+ // Retried once: by this point every rename already landed, so a
+ // failed open converges away a chain that is COMPLETE AND DURABLE
+ // on disk and the re-pull transfers the whole thing again. The
+ // sweep itself is right (a chain the live state does not know
+ // about would resurrect at boot), so one retry against a
+ // transient open failure is the only cheap save available.
+ let open = || {
+ SegmentStorage::new(
+ &log_final,
+ &index_final,
+ meta.size,
+ meta.index_size,
+ config.enforce_fsync,
+ config.enforce_fsync,
+ true,
+ )
+ };
+ let storage = match open().await {
+ Ok(storage) => storage,
+ Err(_) => open()
+ .await
+ .map_err(|source| PartitionInstallError::SegmentOpen {
+ path: log_final.clone(),
+ source,
+ })?,
+ };
let mut segment = Segment::new(meta.start_offset,
config.segment_size);
segment.sealed = true;
segment.start_timestamp = meta.start_timestamp;
@@ -2032,7 +2068,12 @@ where
self.pending_consumer_offset_commits.clear();
self.last_polled_offsets.pin().clear();
- let clamp = |offset: u64| offset.min(next_offset.saturating_sub(1));
+ // `None` when the group's offset space is empty (`next_offset == 0`,
+ // a purged origin): clamping every transferred offset to 0 would tell
+ // each consumer it consumed offset 0 on a partition that never minted
+ // one, so a `Next` poll skips the first message. Dropping the entries
+ // is what "no offsets yet" means.
+ let clamp = |offset: u64| next_offset.checked_sub(1).map(|last|
offset.min(last));
if self.consumer_offsets_path.is_none() ||
self.consumer_group_offsets_path.is_none() {
// Nothing to write the transferred table into: unreachable via
// the server boot paths (they always configure storage), but if
@@ -2048,7 +2089,9 @@ where
Vec::with_capacity(offsets_wire.consumers.len() +
offsets_wire.groups.len());
if let Some(dir) = self.consumer_offsets_path.clone() {
for (id, offset) in &offsets_wire.consumers {
- let value = clamp(*offset);
+ let Some(value) = clamp(*offset) else {
+ continue;
+ };
let entry = ConsumerOffset::default_for_consumer(*id, &dir);
entry.offset.store(value, Ordering::Release);
let path = entry.path.clone();
@@ -2063,7 +2106,9 @@ where
}
if let Some(dir) = self.consumer_group_offsets_path.clone() {
for (id, offset) in &offsets_wire.groups {
- let value = clamp(*offset);
+ let Some(value) = clamp(*offset) else {
+ continue;
+ };
let group_id = ConsumerGroupId(*id as usize);
let entry =
ConsumerOffset::default_for_consumer_group(group_id, &dir);
entry.offset.store(value, Ordering::Release);
@@ -2131,7 +2176,13 @@ where
// durable bytes on disk. Deliberately NOT `recovered_durable_offset`:
// that field also gates repaired-batch persistence, and overstating
// it would silently drop the `(commit_op, commit_max]` replay window.
- self.installed_frontier = Some(next_offset);
+ // `Some(0)` is filtered out: it reads as a real claim in the
+ // `NothingCommitted` serve gate (`installed_frontier.is_none()`), so a
+ // replica holding zero bytes at frontier 0 would start serving empty
+ // offers -- the phantom shape that gate exists to stop. Behavior is
+ // otherwise unchanged: the repair floor stand-in already treats
+ // `Some(0)` and `None` identically.
+ self.installed_frontier = (next_offset > 0).then_some(next_offset);
self.stats.zero_out_all();
#[allow(clippy::cast_possible_truncation)]
self.stats
@@ -2156,9 +2207,21 @@ where
if commit_op > consensus.commit_min() {
consensus.set_commit_floor(commit_op);
}
- if commit_op > consensus.sequencer().current_sequence() {
- consensus.sequencer().set_sequence(commit_op);
- }
+ // The sequencer is SET, not raised: the install cleared the whole
+ // journal, so ops in `(commit_op, old_sequencer]` -- journaled and
+ // PrepareOk'd before the transfer armed, since a transferring replica
+ // withholds acks -- are ops consensus still claims and the journal can
+ // no longer serve. The primary's retransmit dies in the backup gap
+ // check 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:
+ // its entries are backed by the same erased journal, and
+ // `LocalPipeline::push` asserts op sequentiality in release, so a bare
+ // rewind would turn the silent desync into a shard panic on a replica
+ // promoted mid-transfer. (`last_prepare_checksum` needs nothing: it is
+ // only read as a `parent:` stamp when building a prepare.)
+ consensus.sequencer().set_sequence(commit_op);
+ consensus.pipeline().borrow_mut().clear();
consensus.advance_commit_max(commit_op);
self.observed_view = self.consensus().view();
self.repair = None;
@@ -2169,14 +2232,7 @@ where
offsets_written,
})
}
-}
-/// See `install_state_transfer`'s failure arm.
-impl<B, SB> IggyPartition<B, SB>
-where
- B: MessageBus,
- SB: SuperblockStore,
-{
/// Converge the live partition AND its directory to an empty,
/// honestly-lagging shape after a failed install: no segment files at
/// all (the failure can land anywhere from "old chain unlinked" to
@@ -2220,7 +2276,7 @@ where
.map(|entry| entry.path())
.filter(|path| {
path.to_str().is_some_and(|path| {
- [".log", ".index", ".staging"]
+ [".log", ".index", STAGING_SUFFIX]
.iter()
.any(|extension| path.ends_with(extension))
})
@@ -2275,7 +2331,8 @@ where
// segments staged and the install failed, this replica holds zero
bytes
// of a range it would otherwise declare whole, and `set_commit_floor`
// would lift `commit_min` over ops it cannot serve.
- self.installed_frontier =
staged_was_empty.then_some(minted_next_offset);
+ self.installed_frontier =
+ (staged_was_empty && minted_next_offset >
0).then_some(minted_next_offset);
self.stats.zero_out_all();
self.stats.increment_segments_count(1);
self.repair = None;
@@ -2325,14 +2382,19 @@ impl std::error::Error for SpillError {}
const OFFER_HASH_CHUNK_LEN: usize = 1 << 20;
/// Feed bytes `[from, to)` of `path` into `hasher`, read in
-/// [`OFFER_HASH_CHUNK_LEN`] chunks with one reactor yield per chunk. Errors
-/// on a file shorter than `to`: the segment accounts bytes the disk does
-/// not hold.
+/// [`OFFER_HASH_CHUNK_LEN`] chunks with one reactor yield per chunk, appending
+/// each chunk to `sink` when one is given.
+///
+/// The single chunked reader for both passes over a segment file: the offer
+/// build's checksum extension (no sink) and the serving side's load +
re-verify
+/// (sink collects the artifact). Errors on a file shorter than `to`: the
segment
+/// accounts bytes the disk does not hold.
async fn hash_segment_range(
path: &str,
from: u64,
to: u64,
hasher: &mut consensus::state_manifest::StateArtifactHasher,
+ mut sink: Option<&mut Vec<u8>>,
) -> std::io::Result<()> {
if from >= to {
return Ok(());
@@ -2360,13 +2422,16 @@ async fn hash_segment_range(
))
})?;
hasher.update(&buf);
+ if let Some(sink) = sink.as_deref_mut() {
+ sink.extend_from_slice(&buf);
+ }
position += want as u64;
}
Ok(())
}
/// Read the first `entry.len` bytes of a served segment file and re-verify
them
-/// against the manifest entry, in [`OFFER_HASH_CHUNK_LEN`] chunks with one
+/// against the manifest entry, chunked through [`hash_segment_range`] with one
/// reactor yield per chunk.
///
/// The serving side runs this on the pump to answer a single chunk request, so
@@ -2383,23 +2448,12 @@ pub async fn load_verified_segment_artifact(
log_path: &str,
entry: &consensus::StateArtifact,
) -> Option<Vec<u8>> {
- let file = compio::fs::File::open(log_path).await.ok()?;
- if file.metadata().await.ok()?.len() < entry.len {
- return None;
- }
let mut hasher = consensus::state_manifest::StateArtifactHasher::new();
#[allow(clippy::cast_possible_truncation)]
let mut bytes = Vec::with_capacity(entry.len as usize);
- let mut position = 0u64;
- while position < entry.len {
- #[allow(clippy::cast_possible_truncation)]
- let want = OFFER_HASH_CHUNK_LEN.min((entry.len - position) as usize);
- let compio::BufResult(read, chunk) = file.read_exact_at(vec![0u8;
want], position).await;
- read.ok()?;
- hasher.update(&chunk);
- bytes.extend_from_slice(&chunk);
- position += want as u64;
- }
+ hash_segment_range(log_path, 0, entry.len, &mut hasher, Some(&mut bytes))
+ .await
+ .ok()?;
(hasher.finish() == entry.checksum).then_some(bytes)
}
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index 8c2bd8861..af72c7d8e 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -2328,7 +2328,19 @@ async fn load_partition(
let current_offset = sized_end.or_else(|| empty_frontier.map(|start| start
- 1));
partition.created_at = partition_metadata.created_at;
partition.recovered_durable_offset = sized_end;
- partition.installed_frontier = empty_frontier;
+ // The OFFSET COUNTER is restored from that file name (above), but the
+ // `installed_frontier` CLAIM deliberately is not: the claim says
"everything
+ // below me is represented here", and
`converge_to_empty_after_failed_install`
+ // refuses to make it when staged segments were dropped -- yet a converge
+ // plants exactly the same empty `{frontier:020}.log` a legitimate empty
+ // install does, so boot provably cannot tell them apart. Re-deriving it
here
+ // would hand the refused claim back: the repair floor stand-in would
accept a
+ // commit floor over ops this replica holds zero bytes for, and the replica
+ // would pass the serve gate and offer that emptiness onward, making a peer
+ // unlink its own chain. Leaving it `None` costs one spurious full
+ // re-transfer on the legitimate empty-install restart; a false caught-up
+ // claim is not recoverable. A durable home for the frontier (the partition
+ // superblock already reserves a field) is what would settle it properly.
let counter = current_offset.unwrap_or(0);
partition.offset.store(counter, Ordering::Release);
partition.dirty_offset.store(counter, Ordering::Relaxed);
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 6eee83032..428d145ca 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -28,9 +28,10 @@ pub use router::CONSENSUS_TICK_INTERVAL;
#[cfg(any(test, feature = "simulator"))]
use consensus::LocalPipeline;
use consensus::{
- CommitOutcome, Consensus, ConsensusClock, MetadataHandle, MuxPlane,
PartitionsHandle, Pipeline,
- Plane, PlaneKind, STATE_TRANSFER_MAX_DECODE_RETRIES,
STATE_TRANSFER_MAX_STALL_RETRIES,
- Sequencer, VsrAction, VsrConsensus, build_deny_reply_from_request_header,
+ ChunkProgress, CommitOutcome, Consensus, ConsensusClock, MetadataHandle,
MuxPlane,
+ PartitionsHandle, Pipeline, Plane, PlaneKind,
STATE_TRANSFER_MAX_DECODE_RETRIES,
+ STATE_TRANSFER_MAX_STALL_RETRIES, Sequencer, VsrAction, VsrConsensus,
+ build_deny_reply_from_request_header,
};
#[cfg(any(test, feature = "simulator"))]
use crossfire::AsyncRxTrait;
@@ -846,6 +847,19 @@ enum ServedOffer {
Partition(Rc<partitions::state_transfer::PartitionStateTransferOffer>),
}
+/// Largest `segment.size` any configuration can set, mirroring
+/// `configs::server_config::validators::SEGMENT_MAX_SIZE_BYTES` (the `configs`
+/// crate is not a dependency here). Both the served-payload budget and the
+/// per-artifact alloc cap are derived from it rather than hand-tuned.
+const SEGMENT_SIZE_CEILING_BYTES: u64 = 1 << 30;
+
+/// The most one segment can overshoot its size cap: rotation checks the cap
+/// AFTER appending, so a segment closes at most one maximum-size batch past
it.
+/// A batch is bounded by the per-message payload ceiling plus its 256-byte
+/// command header; anything larger is refused at ingest.
+const SEGMENT_SIZE_OVERSHOOT_BYTES: u64 = iggy_common::MAX_PAYLOAD_SIZE as u64
+ + server_common::send_messages2::COMMAND_HEADER_SIZE as u64;
+
/// Shard-wide cache of segment payloads loaded to serve partition chunks,
/// content-addressed by `(namespace, manifest checksum)` so every requester
/// pulling the same offer generation shares ONE resident copy (per-requester
@@ -878,7 +892,21 @@ impl ServedSegmentCache {
/// the budget still loads (the serve could not proceed otherwise) and owns
/// the budget until it ages out. A config knob can follow if operators
need
/// to trade it against page cache.
- const RESIDENT_BYTES_MAX: u64 = 1 << 30;
+ ///
+ /// Sized for CONCURRENT pulls, not one: at exactly one max-size segment
+ /// (`segment.size` defaults to and is capped at 1 GiB) a single receiver
+ /// arming its `PARTITION_TRANSFERS_INFLIGHT_MAX` transfers thrashes the
+ /// cache by itself -- distinct partitions are distinct keys, so the pulls
+ /// evict each other on every chunk, and each miss re-reads and re-hashes a
+ /// whole segment to serve one 256 KiB chunk. That is the 4096:1 read
+ /// amplification this cache exists to prevent, plus an offer eviction per
+ /// failed re-verify feeding the hard-failure backoff.
+ const RESIDENT_BYTES_MAX: 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
+ /// requester can pull concurrently.
+ const CONCURRENT_SERVED_SEGMENTS: u64 = 4;
/// Sweeps a payload survives without serving a chunk.
///
@@ -924,6 +952,14 @@ impl ServedSegmentCache {
fn insert(&mut self, namespace: u64, checksum: u64, payload: Rc<Vec<u8>>) {
let incoming = payload.len() as u64;
+ // Credited BEFORE the eviction scan: re-inserting an existing key
frees
+ // its own slot, and charging that only afterwards evicted neighbours
to
+ // make room for bytes that were about to be released.
+ if let Some(replaced) = self.entries.remove(&(namespace, checksum)) {
+ self.resident_bytes = self
+ .resident_bytes
+ .saturating_sub(replaced.payload.len() as u64);
+ }
while self.resident_bytes.saturating_add(incoming) >
Self::RESIDENT_BYTES_MAX
&& !self.entries.is_empty()
{
@@ -941,18 +977,16 @@ impl ServedSegmentCache {
}
}
self.use_seq += 1;
- if let Some(replaced) = self.entries.insert(
+ // The key was removed above, so this never replaces an entry whose
bytes
+ // still need crediting back.
+ self.entries.insert(
(namespace, checksum),
CachedSegmentPayload {
payload,
last_use: self.use_seq,
last_use_sweep: self.sweeps,
},
- ) {
- self.resident_bytes = self
- .resident_bytes
- .saturating_sub(replaced.payload.len() as u64);
- }
+ );
self.resident_bytes = self.resident_bytes.saturating_add(incoming);
}
}
@@ -979,7 +1013,10 @@ struct ServedStateTransfer {
/// the serving replica knows about its own progress.
///
/// The progress fields ride along even on a refusal, so a receiver can tell a
-/// peer that is momentarily behind from one that knows less than it does.
+/// peer that is momentarily behind from one that knows less than it does. They
+/// are CONSTRUCTOR arguments rather than an optional builder step: as an
+/// optional step every one of the eight construction sites had to remember it,
+/// and two did not.
struct TransferDescriptor<'a> {
/// `Some((manifest, commit_op))` when the peer can serve.
offer: Option<(&'a [consensus::StateArtifact], u64)>,
@@ -992,29 +1029,28 @@ struct TransferDescriptor<'a> {
}
impl<'a> TransferDescriptor<'a> {
- const fn available(offer: &'a [consensus::StateArtifact], commit_op: u64)
-> Self {
+ const fn available(
+ offer: &'a [consensus::StateArtifact],
+ commit_op: u64,
+ view: u32,
+ commit_max: u64,
+ ) -> Self {
Self {
offer: Some((offer, commit_op)),
- view: 0,
- commit_max: 0,
+ view,
+ commit_max,
transient: false,
}
}
- const fn unavailable(transient: bool) -> Self {
+ const fn unavailable(transient: bool, view: u32, commit_max: u64) -> Self {
Self {
offer: None,
- view: 0,
- commit_max: 0,
+ view,
+ commit_max,
transient,
}
}
-
- const fn serving(mut self, view: u32, commit_max: u64) -> Self {
- self.view = view;
- self.commit_max = commit_max;
- self
- }
}
/// What `on_request_state_chunk` decided inside its offers borrow; the wire
@@ -1818,6 +1854,25 @@ where
}
}
+/// The serving replica's `(view, commit_max)` for a descriptor.
+///
+/// Sampled per branch, always AFTER any offer build: the build force-flushes
and
+/// hashes every un-memoized segment (seconds on a first multi-GiB serve) while
+/// reading its `commit_op` post-flush, so a pre-build sample could advertise a
+/// `commit_max` below the descriptor's own `commit_op`. Harmless on the
receiver
+/// (the values are only compared against its own locals) but it makes its gate
+/// refuse, and refusals feed a backoff.
+const fn serving_progress<B, SB>(partition: &IggyPartition<B, SB>) -> (u32,
u64)
+where
+ B: MessageBus,
+ SB: SuperblockStore,
+{
+ (
+ partition.consensus().view(),
+ partition.consensus().commit_max(),
+ )
+}
+
/// The next replica to try after a transfer against `failed_peer` failed.
///
/// Prefers the view's primary: it is the only replica that can pass the
serving
@@ -3342,36 +3397,11 @@ where
}
// Same gap-fill as the metadata arm: a journal-less rejoiner that
// adopted the new view still lacks the window's entries; repair from
- // the announcing primary, floor settled by its RangeEvicted.
- let consensus = partition.consensus();
- if consensus.is_normal()
- && consensus.commit_min() < consensus.commit_max()
- && partition.repair.is_none()
- {
- let nonce = iggy_common::random_id::get_uuid();
- let to_op = consensus.commit_max();
- let from_op = consensus.commit_min() + 1;
- let cluster = consensus.cluster();
- let self_id = consensus.replica();
- partition.repair = Some(partitions::RepairSession {
- nonce,
- to_op,
- floor: None,
- peer: header.replica,
- first_batch_offset: None,
- idle_ticks: 0,
- });
- self.send_request_prepares(
- cluster,
- self_id,
- header.replica,
- nonce,
- from_op,
- to_op,
- header.namespace,
- )
+ // the announcing primary, floor settled by its RangeEvicted. The
shared
+ // helper carries one guard more than this site needs
(`is_transferring`,
+ // already covered by the early return above) and logs the arm.
+ self.maybe_request_partition_repair(partition, header.replica)
.await;
- }
}
#[allow(clippy::future_not_send)]
@@ -3736,19 +3766,17 @@ where
// stored bytes verbatim, so without it a mixed-version metadata repair
// re-ships the same 0-stamped entries forever.
//
- // Fenced rather than left to operation classification: namespace 0 is
- // also `IggyNamespace::new(0, 0, 0)`, the first partition of a fresh
- // cluster, and `!is_partition()` is true of every operation code below
- // `SendMessages`, so the first sub-160 partition operation ever added
- // would route partition-0 repair frames into the METADATA journal. The
- // claim therefore also requires a metadata repair actually in flight
and
- // no partition materialised under that namespace on this shard; a
- // legacy frame arriving without both is dropped, which stalls visibly
- // instead of storing partition bytes as metadata.
- let legacy_metadata_claim = header.namespace == 0
- && !header.operation.is_partition()
- && self.metadata_repair.borrow().is_some()
- && !planes.1.0.contains(&IggyNamespace::from_raw(0));
+ // Keyed on the OPERATION, not on whether partition 0/0/0 exists: raw
+ // namespace 0 is `IggyNamespace::new(0, 0, 0)` and ids slab-allocate
+ // from 0, so 0/0/0 is the first partition every cluster creates -- on
a
+ // single-shard node a "no partition 0 materialised" conjunct goes
false
+ // the moment one topic exists and disables this migration exactly
where
+ // it is needed. `is_metadata_plane` is the plane's OWN applicability
+ // predicate (the session ops `Register`/`Logout` replicate here
without
+ // being metadata mutations, so `is_metadata` alone is too narrow),
which
+ // is why both sites share it rather than re-deriving the set.
+ let metadata_plane_op = header.operation.is_metadata_plane();
+ let legacy_metadata_claim = header.namespace == 0 && metadata_plane_op;
if let Some(ref consensus) = planes.0.consensus
&& (consensus.namespace() == header.namespace ||
legacy_metadata_claim)
{
@@ -3791,6 +3819,25 @@ where
consensus.set_last_prepare_checksum(header.checksum);
return;
}
+ // A metadata-plane op that did not match above (no metadata consensus
on
+ // this shard, or a namespace neither plane claims) is DROPPED, never
+ // offered to the partition arm. Falling through let a metadata prepare
+ // reach `apply_repaired_prepare`: it journals nothing, but it resets
the
+ // partition repair session's idle ticks (masking a genuine stall) and
+ // carries the metadata prepare's checksum into the partition consensus
+ // via `set_last_prepare_checksum` -- inert only while prepare
checksums
+ // are structurally zero, and a cross-plane parent stamp the moment the
+ // checksum chain is activated (see the note in `consensus::impls`).
+ if metadata_plane_op {
+ tracing::debug!(
+ shard = self.id,
+ op = header.op,
+ operation = ?header.operation,
+ namespace_raw = header.namespace,
+ "dropping a metadata-plane repair prepare this shard cannot
journal"
+ );
+ return;
+ }
let Some(partition) = planes
.1
.0
@@ -4291,7 +4338,7 @@ where
/// Serve one `RequestStateTransfer`: build a fresh offer (or refuse),
/// cache it for the chunk pulls, and answer with the descriptor.
- #[allow(clippy::future_not_send)]
+ #[allow(clippy::future_not_send, clippy::too_many_lines)]
async fn on_request_state_transfer(&self, msg:
&Message<RequestStateTransferHeader>)
where
B: MessageBus,
@@ -4354,8 +4401,12 @@ where
header.replica,
header.nonce,
header.namespace,
- TransferDescriptor::available(&offer.manifest(),
offer.commit_op)
- .serving(consensus.view(), consensus.commit_max()),
+ TransferDescriptor::available(
+ &offer.manifest(),
+ offer.commit_op,
+ consensus.view(),
+ consensus.commit_max(),
+ ),
)
.await;
return;
@@ -4378,8 +4429,12 @@ where
header.replica,
header.nonce,
header.namespace,
- TransferDescriptor::available(&offer.manifest(),
offer.commit_op)
- .serving(consensus.view(), consensus.commit_max()),
+ TransferDescriptor::available(
+ &offer.manifest(),
+ offer.commit_op,
+ consensus.view(),
+ consensus.commit_max(),
+ ),
)
.await;
self.state_transfer_offers.borrow_mut().insert(
@@ -4409,8 +4464,11 @@ where
header.replica,
header.nonce,
header.namespace,
- TransferDescriptor::unavailable(false)
- .serving(consensus.view(), consensus.commit_max()),
+ TransferDescriptor::unavailable(
+ false,
+ consensus.view(),
+ consensus.commit_max(),
+ ),
)
.await;
}
@@ -4835,7 +4893,10 @@ where
header.replica,
header.nonce,
header.namespace,
- TransferDescriptor::unavailable(false),
+ // 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()),
)
.await;
}
@@ -5431,9 +5492,6 @@ where
};
let cluster = partition.consensus().cluster();
let self_id = partition.consensus().replica();
- let serving_view = partition.consensus().view();
- let serving_commit_max = partition.consensus().commit_max();
-
// First-wins per (requester, nonce), exactly as the metadata arm: a
// stall retry reuses the nonce, and rebuilding under it could hand
// the receiver chunks from a different offer than the manifest it
@@ -5458,14 +5516,19 @@ where
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(serving_view, serving_commit_max),
+ TransferDescriptor::available(
+ &offer.manifest(),
+ offer.commit_op,
+ serving_view,
+ serving_commit_max,
+ ),
)
.await;
return;
@@ -5482,14 +5545,19 @@ where
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(serving_view, serving_commit_max),
+ TransferDescriptor::available(
+ &offer.manifest(),
+ offer.commit_op,
+ serving_view,
+ serving_commit_max,
+ ),
)
.await;
self.state_transfer_offers.borrow_mut().insert(
@@ -5517,14 +5585,14 @@ where
%reason,
"cannot serve partition state transfer; requester falls
back"
);
+ 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(serving_view, serving_commit_max),
+ TransferDescriptor::unavailable(transient, serving_view,
serving_commit_max),
)
.await;
}
@@ -5573,6 +5641,8 @@ where
};
let cluster = partition.consensus().cluster();
let self_id = partition.consensus().replica();
+ let serving_view = partition.consensus().view();
+ let serving_commit_max = partition.consensus().commit_max();
let chunk_len_max = self.state_chunk_len_max();
let reply = loop {
let attempt = 'attempt: {
@@ -5717,7 +5787,14 @@ where
header.replica,
header.nonce,
header.namespace,
- TransferDescriptor::unavailable(false),
+ // 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),
)
.await;
}
@@ -5734,10 +5811,16 @@ where
}
}
- /// Alloc cap per PARTITION artifact. Segments are hard-capped at 1 GiB
- /// with a soft-cap overshoot of one batch, so the metadata plane's 1 GiB
- /// cap would deterministically reject legal segments.
- const PARTITION_ARTIFACT_LEN_MAX: u64 = 2 << 30;
+ /// Alloc cap per PARTITION artifact: the configured segment ceiling plus
the
+ /// one maximum-size batch a segment may overshoot it by (rotation checks
the
+ /// cap after appending). The metadata plane's flat 1 GiB cap would
+ /// deterministically reject a legal overshooting segment, and the previous
+ /// 2 GiB left the receiver holding twice the largest legal artifact --
+ /// `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 =
+ SEGMENT_SIZE_CEILING_BYTES + SEGMENT_SIZE_OVERSHOOT_BYTES;
/// Sanity cap across a partition manifest. Segment artifacts spill to
/// disk as they complete, so this bounds corruption, not memory.
@@ -6272,7 +6355,7 @@ where
let Some(session) = partition.transfer.as_ref() else {
return;
};
- let all_done =
session.artifacts.iter().all(TransferArtifact::complete);
+ let all_done = session.artifacts.iter().all(ChunkProgress::complete);
if !all_done {
self.request_pending_partition_chunk(namespace).await;
return;