This is an automated email from the ASF dual-hosted git repository.
numinnex pushed a commit to branch replicated_crash_contract
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/replicated_crash_contract by
this push:
new c1bf37f16 address review
c1bf37f16 is described below
commit c1bf37f1683d188739bad34ffcd55318d7d1102b
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Wed Nov 4 16:01:02 2026 +0100
address review
---
core/integration/src/harness/disk.rs | 24 +++++
core/integration/tests/cluster/crash_durability.rs | 104 ++++++++++++---------
core/journal/src/partition_journal.rs | 46 ++++++---
3 files changed, 115 insertions(+), 59 deletions(-)
diff --git a/core/integration/src/harness/disk.rs
b/core/integration/src/harness/disk.rs
index 4762fd771..c45a233b2 100644
--- a/core/integration/src/harness/disk.rs
+++ b/core/integration/src/harness/disk.rs
@@ -27,6 +27,8 @@ use std::time::Duration;
use consensus::VsrState;
use iggy::prelude::{ClusterClient, ClusterNodeRole, ConsumerKind};
+use journal::PartitionPrepareJournal;
+use journal::partition_journal::FRONTIER_FILE_NAME;
use journal::superblock::{SLOT_FILE_NAMES, SuperblockContents, decode_slots};
use tokio::time::sleep;
@@ -43,6 +45,9 @@ const CONVERGENCE_STABLE_POLLS: u32 = 3;
const OFFSETS_DIR: &str = "offsets";
const CONSUMER_OFFSETS_DIR: &str = "consumers";
const GROUP_OFFSETS_DIR: &str = "groups";
+/// Prefix of a partition's WAL directory, `prepares-{created_revision}`, as
+/// `partitions::iggy_partition` names it.
+const PREPARES_DIR_PREFIX: &str = "prepares-";
/// Numeric regular offset files in one partition's selected kind directory.
pub fn consumer_offset_file_ids(
@@ -576,6 +581,25 @@ pub fn read_partition_superblock_state(data_path: &Path)
-> Option<VsrState> {
read_superblock_state_in(&dir, "partition")
}
+/// The message offset the single partition's published WAL frontier ends at on
+/// a node: a restart keeps the segment messages below it and discards the
rest.
+/// `None` while no frontier owning segment bodies exists. Read it while the
+/// node is down, since a live node keeps publishing. Intended for layouts with
+/// exactly one partition.
+pub fn read_partition_wal_frontier(data_path: &Path) -> Option<u64> {
+ let path = walk(data_path, &mut |path| {
+ path.file_name()
+ .is_some_and(|name| name == FRONTIER_FILE_NAME)
+ && path
+ .parent()
+ .and_then(Path::file_name)
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with(PREPARES_DIR_PREFIX))
+ })?;
+ let frontier = fs::read(path).ok()?;
+ PartitionPrepareJournal::published_segment_tail(&frontier).map(|tail|
tail.next_offset)
+}
+
fn read_superblock_state_in(dir: &Path, plane: &str) -> Option<VsrState> {
let slot_a = fs::read(dir.join(SLOT_FILE_NAMES[0])).ok();
let slot_b = fs::read(dir.join(SLOT_FILE_NAMES[1])).ok();
diff --git a/core/integration/tests/cluster/crash_durability.rs
b/core/integration/tests/cluster/crash_durability.rs
index 7dacbb448..4698fcad4 100644
--- a/core/integration/tests/cluster/crash_durability.rs
+++ b/core/integration/tests/cluster/crash_durability.rs
@@ -62,28 +62,31 @@ const POLL_INTERVAL: Duration = Duration::from_millis(250);
/// primary dies, so the surviving quorum has a meaningful prefix to preserve.
const PRE_KILL_ACKS: usize = 50;
-async fn create_stream_and_topic(client: &IggyClient, eager_flush: bool) {
+async fn create_stream_and_topic(client: &IggyClient, eager_flush: bool,
durability: Durability) {
client
.create_stream(STREAM_NAME, &StreamCreateOptions::default())
.await
.expect("create stream");
- // `eager_flush` persists and fsyncs every committed batch on every
- // replica before anything else happens on the partition; it is a topic
- // creation option, so it travels with the topic to all nodes. The default
- // shape leaves the 1024-message / 1 MiB thresholds in place, so small
- // runs are acked from RAM only.
+ // `eager_flush` saves every committed batch to the segments on every
+ // replica before anything else happens on the partition. `persisted`
+ // durability also backs each ack with a stable-storage copy, where
+ // `replicated` acks on quorum commit alone. Both are topic creation
+ // options, so they travel with the topic to all nodes. The default shape
+ // leaves the 1024-message / 1 MiB thresholds in place, so small runs are
+ // acked from RAM only.
let options = if eager_flush {
TopicCreateOptions {
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
messages_required_to_save: Some(1),
- durability: iggy_common::Durability::Persisted,
+ durability,
..TopicCreateOptions::default()
}
} else {
TopicCreateOptions {
partitions_count: Some(1),
message_expiry: Some(IggyExpiry::NeverExpire),
+ durability,
..TopicCreateOptions::default()
}
};
@@ -173,8 +176,8 @@ async fn produce_acked_when_serving(client: &IggyClient,
budget: Duration) -> (u
}
}
-/// The payloads of up to `count` messages from offset 0.
-async fn poll_payloads_from_start(client: &IggyClient, count: u32) ->
Vec<String> {
+/// The `(offset, payload)` of up to `count` messages from offset 0.
+async fn poll_from_start(client: &IggyClient, count: u32) -> Vec<(u64,
String)> {
let polled = client
.poll_messages(
&Identifier::named(STREAM_NAME).unwrap(),
@@ -192,7 +195,12 @@ async fn poll_payloads_from_start(client: &IggyClient,
count: u32) -> Vec<String
polled
.messages
.iter()
- .map(|message| String::from_utf8_lossy(&message.payload).into_owned())
+ .map(|message| {
+ (
+ message.header.offset,
+ String::from_utf8_lossy(&message.payload).into_owned(),
+ )
+ })
.collect()
}
@@ -408,7 +416,7 @@ async fn
given_committed_sends_when_a_backup_is_killed_and_restarts_should_prese
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client, true).await;
+ create_stream_and_topic(&client, true, Durability::Persisted).await;
let mut acked = produce_acked(&client, "pre-kill", 200).await;
let leader = disk::leader_node_index(harness).await;
@@ -452,7 +460,7 @@ async fn
given_mid_flight_produce_when_the_primary_is_killed_should_elect_and_pr
harness: &mut TestHarness,
) {
let setup_client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&setup_client, false).await;
+ create_stream_and_topic(&setup_client, false,
Durability::Replicated).await;
let producer_client = harness.tcp_root_client().await.unwrap();
let stop = Arc::new(AtomicBool::new(false));
@@ -548,7 +556,7 @@ async fn
given_a_committed_consumer_offset_when_a_backup_is_killed_should_surviv
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client, false).await;
+ create_stream_and_topic(&client, false, Durability::Replicated).await;
let acked = produce_acked(&client, "offset-fodder", 10).await;
let stored_offset = acked.last().expect("ten acked sends").0;
@@ -591,7 +599,7 @@ async fn
given_eager_flush_topic_when_the_whole_cluster_is_killed_should_recover
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client, true).await;
+ create_stream_and_topic(&client, true, Durability::Persisted).await;
let acked = produce_acked(&client, "flushed", 30).await;
let payloads: Vec<String> = acked.iter().map(|(_, payload)|
payload.clone()).collect();
wait_until_payloads_installed(harness, &payloads,
FLUSH_INSTALL_TIMEOUT).await;
@@ -627,30 +635,33 @@ async fn
given_replicated_eager_flush_topic_when_the_whole_cluster_is_killed_sho
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- client
- .create_stream(STREAM_NAME, &StreamCreateOptions::default())
- .await
- .expect("create stream");
- client
- .create_topic(
- &Identifier::named(STREAM_NAME).unwrap(),
- TOPIC_NAME,
- &TopicCreateOptions {
- partitions_count: Some(1),
- message_expiry: Some(IggyExpiry::NeverExpire),
- messages_required_to_save: Some(1),
- durability: Durability::Replicated,
- ..TopicCreateOptions::default()
- },
- )
- .await
- .expect("create topic");
+ create_stream_and_topic(&client, true, Durability::Replicated).await;
let acked = produce_acked(&client, "flushed", 30).await;
let payloads: Vec<String> = acked.iter().map(|(_, payload)|
payload.clone()).collect();
wait_until_payloads_installed(harness, &payloads,
FLUSH_INSTALL_TIMEOUT).await;
// Same reason as above for keeping the producer's connection open.
harness.kill_cluster().expect("SIGKILL every node");
+ // Read with every node down, so no publication can land after the read.
+ let frontiers: Vec<Option<u64>> = (0..harness.cluster_size())
+ .map(|node|
disk::read_partition_wal_frontier(&harness.node(node).data_path()))
+ .collect();
+ // Whichever log the election picks, recovery then keeps the same prefix.
+ let frontier = frontiers[0].expect("node 0 published a partition WAL
frontier");
+ assert!(
+ frontiers.iter().all(|other| *other == Some(frontier)),
+ "every node must publish the same WAL frontier: {frontiers:?}"
+ );
+ let (mut expected, discarded): (Vec<_>, Vec<_>) = acked
+ .iter()
+ .cloned()
+ .partition(|(offset, _)| *offset < frontier);
+ assert!(
+ !discarded.is_empty(),
+ "the published WAL frontier {frontier} covers all {} acks, so the
crash pins no loss",
+ acked.len()
+ );
+
harness
.restart_cluster()
.await
@@ -661,18 +672,21 @@ async fn
given_replicated_eager_flush_topic_when_the_whole_cluster_is_killed_sho
// A write that reads back proves the partition serves again, so the
// missing acks below are discarded, not still recovering.
let after_restart = produce_acked_when_serving(&client,
CONVERGE_TIMEOUT).await;
- wait_for_acked_readable(&client, &[after_restart], CONVERGE_TIMEOUT)
- .await
- .unwrap_or_else(|state| panic!("the partition should serve after the
restart: {state}"));
- let readable = poll_payloads_from_start(&client, acked.len() as u32 +
16).await;
- let survived = payloads
- .iter()
- .filter(|payload| readable.contains(payload))
- .count();
- assert!(
- survived < acked.len(),
- "a whole-cluster SIGKILL discards replicated acks past the published
WAL frontier, but \
- all {survived} survived"
+ wait_for_acked_readable(
+ &client,
+ std::slice::from_ref(&after_restart),
+ CONVERGE_TIMEOUT,
+ )
+ .await
+ .unwrap_or_else(|state| panic!("the partition should serve after the
restart: {state}"));
+ expected.push(after_restart);
+ let readable = poll_from_start(&client, acked.len() as u32 + 16).await;
+ assert_eq!(
+ readable,
+ expected,
+ "a whole-cluster SIGKILL must keep exactly the acks below the
published WAL frontier \
+ {frontier} and discard the {} past it",
+ discarded.len()
);
}
@@ -1015,7 +1029,7 @@ async fn
given_uninitialized_partitions_when_all_replicas_restart_should_elect_a
harness: &mut TestHarness,
) {
let client = harness.tcp_root_client().await.unwrap();
- create_stream_and_topic(&client, true).await;
+ create_stream_and_topic(&client, true, Durability::Persisted).await;
let stream = Identifier::named(STREAM_NAME).unwrap();
let topic = Identifier::named(TOPIC_NAME).unwrap();
let details = client.get_topic(&stream, &topic).await.unwrap().unwrap();
diff --git a/core/journal/src/partition_journal.rs
b/core/journal/src/partition_journal.rs
index 536b430a2..342a7c0a9 100644
--- a/core/journal/src/partition_journal.rs
+++ b/core/journal/src/partition_journal.rs
@@ -168,6 +168,18 @@ impl PartitionPrepareJournal {
Ok(state.is_some())
}
+ /// The segment tail in whichever slot of `frontier`, the bytes of a
+ /// [`FRONTIER_FILE_NAME`] file, recovery would select. When every slot
+ /// verifies, recovery truncates the tail segment to it. `None` when no
slot
+ /// verifies or the WAL does not own segment bodies.
+ #[must_use]
+ pub fn published_segment_tail(frontier: &[u8]) -> Option<SegmentPosition> {
+ let (state, _, _) = newest_frontier_slot(frontier);
+ state?
+ .segment_storage
+ .map(|segments| segments.tail.position)
+ }
+
/// # Errors
/// Returns an error on I/O failure or invalid durable history.
pub async fn open(directory: &Path, group: u64, incarnation: u64) ->
io::Result<Self> {
@@ -402,23 +414,11 @@ impl<S: DurableStorage> PartitionPrepareJournal<S> {
file: Option<&S::File>,
slots: usize,
) -> io::Result<(Option<JournalState>, u64, usize)> {
- let mut newest = None;
- let mut sequence = 0;
- let mut verified = 0;
let Some(file) = file.filter(|_| slots > 0) else {
- return Ok((newest, sequence, verified));
+ return Ok((None, 0, 0));
};
let bytes = file.read(0, slots * PARTITION_WAL_BLOCK_SIZE).await?;
- for slot in bytes.as_chunks::<PARTITION_WAL_BLOCK_SIZE>().0 {
- if let Ok((state, slot_sequence)) = JournalState::decode(slot) {
- verified += 1;
- if newest.is_none() || slot_sequence > sequence {
- newest = Some(state);
- sequence = slot_sequence;
- }
- }
- }
- Ok((newest, sequence, verified))
+ Ok(newest_frontier_slot(&bytes))
}
pub const fn certified_log_view(&self) -> Option<u32> {
@@ -1650,6 +1650,24 @@ const fn frontier_offset(sequence: u64) -> u64 {
(sequence % FRONTIER_SLOTS as u64) * PARTITION_WAL_BLOCK_SIZE as u64
}
+/// The newest verifying slot in `bytes`, its publication sequence, and how
+/// many whole slots verified. See [`PartitionPrepareJournal::read_frontier`].
+fn newest_frontier_slot(bytes: &[u8]) -> (Option<JournalState>, u64, usize) {
+ let mut newest = None;
+ let mut sequence = 0;
+ let mut verified = 0;
+ for slot in bytes.as_chunks::<PARTITION_WAL_BLOCK_SIZE>().0 {
+ if let Ok((state, slot_sequence)) = JournalState::decode(slot) {
+ verified += 1;
+ if newest.is_none() || slot_sequence > sequence {
+ newest = Some(state);
+ sequence = slot_sequence;
+ }
+ }
+ }
+ (newest, sequence, verified)
+}
+
/// Padded size of a prepare record, including its envelope.
///
/// # Errors