This is an automated email from the ASF dual-hosted git repository.
spetz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 91c3ca54b test(simulator): cover durability failures no existing test
can reach (#4132)
91c3ca54b is described below
commit 91c3ca54bbaa1a6adcc8a996f8d9eb77d705bf2b
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Fri Sep 11 18:05:59 2026 +0200
test(simulator): cover durability failures no existing test can reach
(#4132)
Adds extra tests scenarios after #4092 PR.
---
core/configs/src/configs_impl/file_provider.rs | 60 ++++
core/integration/tests/cluster/crash_durability.rs | 37 +++
core/partitions/src/iggy_partition.rs | 73 ++++-
core/partitions/src/persistence.rs | 43 +++
core/simulator/src/lib.rs | 53 ++++
core/simulator/src/storage.rs | 96 +++++-
core/simulator/src/storage/tests.rs | 332 ++++++++++++++++++++-
7 files changed, 684 insertions(+), 10 deletions(-)
diff --git a/core/configs/src/configs_impl/file_provider.rs
b/core/configs/src/configs_impl/file_provider.rs
index bc692d8ed..f384352fd 100644
--- a/core/configs/src/configs_impl/file_provider.rs
+++ b/core/configs/src/configs_impl/file_provider.rs
@@ -402,6 +402,34 @@ mod tests {
assert!(found.is_empty(), "unexpected matches: {found:?}");
}
+ /// The allowlist is exact names but the filter is a bare `IGGY_` prefix,
and
+ /// `IGGY_` prefixes every sibling binary's namespace. A shared container
+ /// environment, a shared `env_file`, or the `.env` that `main.rs` loads
+ /// through `dotenvy` before `load_config` runs will refuse server boot.
+ #[test]
+ #[ignore = "PR #4092 review: the `IGGY_` prefix fence refuses the repo's
own `IGGY_CONNECTORS_*`, `IGGY_MCP_*` and CLI variables, with no opt-out"]
+ fn
given_a_sibling_binarys_env_vars_when_rejecting_then_the_server_should_still_boot()
{
+ let siblings = [
+ "IGGY_CONNECTORS_CONFIG_PATH",
+ "IGGY_CONNECTORS_STATE_PATH",
+ "IGGY_MCP_CONFIG_PATH",
+ "IGGY_MCP_TRANSPORT",
+ "IGGY_HOME",
+ "IGGY_USERNAME",
+ "IGGY_PASSWORD",
+ ];
+ let unknown = unknown_env_names(
+ names(&siblings).into_iter(),
+ "IGGY_",
+ crate::server_config::server::SERVER_PROCESS_ENV_VARS,
+ );
+
+ assert!(
+ unknown.is_empty(),
+ "the server refuses to boot when its own sibling products'
variables are present: {unknown:?}"
+ );
+ }
+
#[test]
fn given_another_configs_prefix_when_matching_then_should_report_none() {
let found = relocated_env_vars(
@@ -413,6 +441,38 @@ mod tests {
assert!(found.is_empty(), "unexpected matches: {found:?}");
}
+ /// `main.rs` loads a `.env` through `dotenvy` before `load_config` runs,
and
+ /// `dotenvy` injects into the process environment that
`reject_unknown_env_names`
+ /// scans with `env::vars_os()`. So the fence does not need a shared
container
+ /// or a shared `env_file`: a `.env` in the working directory is enough.
+ ///
+ /// Mutates the process environment, so it must not run beside another test
+ /// that reads it.
+ #[test]
+ #[ignore = "PR #4092 review: a `.env` loaded by `dotenvy` before
`load_config` refuses server boot; also mutates the process environment, so it
must not run in parallel"]
+ fn
given_a_dotenv_with_a_connectors_variable_when_loading_then_the_server_should_boot()
{
+ // SAFETY: single-threaded assertion over a variable no other test
reads.
+ unsafe { std::env::set_var("IGGY_CONNECTORS_CONFIG_PATH",
"/etc/iggy/connectors.toml") };
+
+ let provider = FileConfigProvider::new(
+ "nonexistent-config.toml".to_string(),
+ Toml::string(""),
+ false,
+ None,
+ )
+ .with_relocated_keys("IGGY_", &[])
+
.with_known_env_names(crate::server_config::server::SERVER_PROCESS_ENV_VARS.to_vec());
+ let rejected = provider.reject_unknown_env_names();
+
+ // SAFETY: paired with the set above.
+ unsafe { std::env::remove_var("IGGY_CONNECTORS_CONFIG_PATH") };
+
+ assert!(
+ rejected.is_ok(),
+ "a .env naming the connectors runtime's own config path refuses
server boot, with no opt-out and a message that names no remedy"
+ );
+ }
+
#[test]
fn given_no_relocated_keys_when_rejecting_then_should_accept() {
let provider = FileConfigProvider::new(
diff --git a/core/integration/tests/cluster/crash_durability.rs
b/core/integration/tests/cluster/crash_durability.rs
index 61d58f142..4967966e0 100644
--- a/core/integration/tests/cluster/crash_durability.rs
+++ b/core/integration/tests/cluster/crash_durability.rs
@@ -859,3 +859,40 @@ async fn verify_transferred_quorum(
sleep(POLL_INTERVAL).await;
}
}
+
+/// Every persisted case in this file kills with SIGKILL, and the module doc
+/// above states why that cannot reach fsync ordering. This pins the premise:
+/// a completed `write()` lives in the page cache, which the kernel owns, so
+/// process death cannot lose it. Consequently no barrier order (`file.sync()`,
+/// `sync_data()`, `fsync_dir()`) changes the outcome of a single test here,
+/// and `persisted` differs from `replicated` only in surviving writes that
+/// were never synced.
+///
+/// Covering the real contract needs a fault that discards unsynced pages:
+/// either the deterministic simulator (drop the persisted-topic assert in
+/// `core/shard/src/lib.rs` and route partition storage through
+/// `DurableStorage`) or `dm-log-writes` / `dm-flakey --drop_writes` under the
+/// data directory.
+#[test]
+fn
given_a_completed_write_when_the_process_is_sigkilled_then_the_bytes_should_survive()
{
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("unsynced");
+ let status = std::process::Command::new("sh")
+ .arg("-c")
+ .arg(r#"printf 'acknowledged' > "$0"; kill -9 $$"#)
+ .arg(&path)
+ .status()
+ .expect("spawn a writer that dies before any barrier");
+ assert!(
+ !status.success(),
+ "the writer exited normally, so it is not modelling a crash"
+ );
+
+ let survived = std::fs::read(&path).unwrap_or_default();
+ assert_eq!(
+ survived.as_slice(),
+ b"acknowledged",
+ "an unsynced write did not survive SIGKILL, so the persisted cases in
this file may be probing barrier order after all: {}",
+ String::from_utf8_lossy(&survived)
+ );
+}
diff --git a/core/partitions/src/iggy_partition.rs
b/core/partitions/src/iggy_partition.rs
index 630189f9e..b4849701b 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -8449,7 +8449,7 @@ mod tests {
const TEST_CLUSTER: u128 = 1;
- fn checksummed_segment_prepare(
+ pub(super) fn checksummed_segment_prepare(
op: u64,
parent: u128,
offset: u64,
@@ -10659,7 +10659,7 @@ mod tests {
/// can assert on reply bytes without a connection registry (whose slot
/// guard would borrow the partition across `on_request(&mut self)`).
#[derive(Debug, Default)]
- struct RecordingBus {
+ pub(super) struct RecordingBus {
sent_to_clients: Rc<RefCell<Vec<(u128, Frozen<MESSAGE_ALIGN>)>>>,
sent_to_replicas: Rc<RefCell<Vec<(u8, Frozen<MESSAGE_ALIGN>)>>>,
}
@@ -10692,13 +10692,13 @@ mod tests {
fn set_client_forward_fn(&self, _f: message_bus::ClientForwardFn) {}
}
- type SentFrames = Rc<RefCell<Vec<(u128, Frozen<MESSAGE_ALIGN>)>>>;
+ pub(super) type SentFrames = Rc<RefCell<Vec<(u128,
Frozen<MESSAGE_ALIGN>)>>>;
fn recording_partition() -> (IggyPartition<RecordingBus>, SentFrames) {
recording_partition_at(0, 1)
}
- fn recording_partition_at(
+ pub(super) fn recording_partition_at(
replica: u8,
replica_count: u8,
) -> (IggyPartition<RecordingBus>, SentFrames) {
@@ -15148,6 +15148,71 @@ mod retention_tests {
}
}
+#[cfg(test)]
+mod review_4092_tests {
+ use super::tests::{checksummed_segment_prepare, recording_partition_at};
+ use super::*;
+
+ /// ENOSPC on 28 is the raw errno; `io::ErrorKind::StorageFull` is
unstable.
+ const ENOSPC: i32 = 28;
+
+ /// `tick_partitions` turns a partition's `fatal()` into a server shutdown
+ /// (`shard/src/lib.rs:7378-7382`). A refused offset write leaves prior
bytes
+ /// intact and nothing undefined, so it should fence the partition at
worst,
+ /// the way `mark_materialization_missing` and `partitions.tombstone`
already
+ /// do for an unserviceable namespace.
+ #[compio::test]
+ #[ignore = "PR #4092 review: a refused consumer-offset write raises
`FatalCommit`, which the shard pump converts into a whole-node shutdown"]
+ async fn
given_a_full_disk_when_driving_persistence_then_only_the_partition_should_fence()
{
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _replies) = recording_partition_at(0, 3);
+
partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.consumer_offset_durability =
iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let persistence = Rc::clone(partition.persistence.as_ref().unwrap());
+
+ persistence.fail_operation(
+ std::io::Error::from_raw_os_error(ENOSPC),
+ Operation::StoreConsumerOffset,
+ );
+ partition.drive_persistence().await;
+
+ assert!(
+ partition.fatal().is_none(),
+ "a refused consumer-offset write raised FatalCommit, which the
shard pump converts into a whole-node shutdown; every other partition on the
core is taken down with it, including topics with no persisted policy"
+ );
+ }
+
+ /// Contested between reviewers: distsys argued the pre-checks leave only a
+ /// genuinely divergent prepare here, where fail-closed is defensible;
storage
+ /// argued the same errno classification fix covers both call sites.
Recorded
+ /// so the decision is explicit rather than implied by a missing test.
+ #[compio::test]
+ #[ignore = "PR #4092 review: CONTESTED between reviewers -- whether a
divergent prepare at an already-accepted op should latch the whole partition"]
+ async fn
given_a_divergent_prepare_when_submitting_then_the_partition_should_not_latch()
{
+ let directory = tempfile::tempdir().unwrap();
+ let (mut partition, _replies) = recording_partition_at(0, 3);
+
partition.set_partition_dir(directory.path().to_string_lossy().into_owned());
+ partition.runtime_options.durability =
iggy_common::Durability::Persisted;
+ partition.open_persistence().await.unwrap();
+ let persistence = Rc::clone(partition.persistence.as_ref().unwrap());
+
+ let first = checksummed_segment_prepare(1, 0, 0, b"first");
+ assert!(partition.submit_prepare_persistence(first.into_frozen(),
Operation::SendMessages));
+
+ // Same op, different bytes: `append` answers InvalidData, which is not
+ // `WouldBlock`, so `:1192-1193` latches the whole partition.
+ let divergent = checksummed_segment_prepare(1, 0, 0, b"divergent");
+ assert!(
+ !partition.submit_prepare_persistence(divergent.into_frozen(),
Operation::SendMessages)
+ );
+ assert!(
+ persistence.failure().is_none(),
+ "a single refused prepare latched the partition permanently;
`failure` has no clearing path, so every later durability query answers false"
+ );
+ }
+}
+
#[cfg(test)]
mod purge_floor_tests {
use super::tests::{
diff --git a/core/partitions/src/persistence.rs
b/core/partitions/src/persistence.rs
index 57b45af04..6a1e4b3dc 100644
--- a/core/partitions/src/persistence.rs
+++ b/core/partitions/src/persistence.rs
@@ -1305,6 +1305,49 @@ mod tests {
use server_common::{Message, iobuf::Owned};
use tempfile::tempdir;
+ /// `io::ErrorKind::StorageFull` is still unstable, so match the raw errno.
+ const ENOSPC: i32 = 28;
+
+ /// A full disk is a refused write, not a torn one: the prior bytes are
+ /// intact and nothing is undefined. Latching it fences the partition for
the
+ /// life of the process, and `drive_persistence` escalates that to a node
+ /// shutdown. The same function already treats open failures as retriable.
+ #[compio::test]
+ #[ignore = "PR #4092 review: a refused consumer-offset write latches an
unclearable `failure`, retroactively reporting already-acked prepares as
unwritten"]
+ async fn
given_a_full_disk_when_the_offset_write_is_refused_then_persistence_should_not_fence()
+ {
+ let directory = tempdir().unwrap();
+ let (persistence, _) =
+ PartitionPersistence::open(&directory.path().join("prepares-7"),
42, 7)
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ persistence
+ .append(first.clone().into_frozen(), true)
+ .unwrap();
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ assert!(persistence.is_written(first.header()));
+
+ persistence.fail_operation(
+ io::Error::from_raw_os_error(ENOSPC),
+ Operation::StoreConsumerOffset,
+ );
+
+ assert!(
+ persistence.is_written(first.header()),
+ "an unrelated offset write reported an already-durable prepare as
unwritten"
+ );
+ assert!(
+ persistence.failure().is_none(),
+ "ENOSPC on one consumer-offset record latched the partition;
nothing clears `failure` (its only `None` is the constructor), so
`is_written_through` stays false and `drive_persistence` raises FatalCommit"
+ );
+ assert!(
+ persistence.start(),
+ "the writer refuses to start again after a recoverable errno"
+ );
+ }
+
#[compio::test]
async fn
completion_is_generation_scoped_and_buffered_work_does_not_ack_durability() {
let directory = tempdir().unwrap();
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 747e5b70b..7ad93eaa4 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -7784,3 +7784,56 @@ mod metadata_read_frontier_tests {
.commit_min()
}
}
+
+#[cfg(test)]
+mod review_4092_dst_tests {
+ //! The deterministic simulator is this repo's strongest correctness tool
and
+ //! it is hard-asserted out of the feature this PR adds: `init_partition`
+ //! (`core/shard/src/lib.rs:4193-4198`) panics for any topic whose
durability
+ //! or consumer-offset durability is persisted. So `PrepareOk` gating,
log-view
+ //! certification, commit ordering and quorum durability have no fault
+ //! coverage at any granularity.
+
+ use super::*;
+
+ #[test]
+ #[ignore = "PR #4092 review: no simulator API can seed a persisted topic,
and `init_partition` asserts them out of the cluster simulator entirely"]
+ fn
given_the_cluster_simulator_when_seeding_a_topic_then_a_persisted_policy_should_be_expressible()
+ {
+ let replica_count = 3u8;
+ let client: u128 = 1;
+ let network_opts = packet::PacketSimulatorOptions {
+ node_count: replica_count,
+ client_count: 1,
+ ..packet::PacketSimulatorOptions::default()
+ };
+ let mut sim = Simulator::new(replica_count as usize,
[client].into_iter(), network_opts);
+ for _ in 0..100 {
+ sim.step();
+ }
+
+ let namespace = IggyNamespace::new(1, 1, 0);
+ sim.seed_stream_topic_partition(namespace);
+
+ let options = sim.replicas[0].shards[0]
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .read(|inner| {
+ inner
+ .items
+ .get(namespace.stream_id())
+ .and_then(|stream| stream.topics.get(namespace.topic_id()))
+ .map(|topic| {
+
iggy_common::TopicRuntimeOptions::from_resource_options(&topic.options)
+ })
+ .unwrap_or_default()
+ });
+
+ assert!(
+ options.durability.is_persisted() ||
options.consumer_offset_durability.is_persisted(),
+ "no simulator API can seed a persisted topic, so the durability
guarantee this PR adds is unreachable from the deterministic simulator and
`init_partition` asserts it out anyway"
+ );
+ }
+}
diff --git a/core/simulator/src/storage.rs b/core/simulator/src/storage.rs
index d8100dc09..e9a47a8d8 100644
--- a/core/simulator/src/storage.rs
+++ b/core/simulator/src/storage.rs
@@ -21,12 +21,17 @@
use journal::durable_storage::{DurableFile, DurableStorage, OpenMode,
StorageEntry};
use server_common::iobuf::Frozen;
-use std::cell::RefCell;
+use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::io;
use std::path::{Component, Path};
use std::rc::Rc;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+/// Each `SimStorage` is an independent filesystem, so writer identities must
not
+/// collide between instances the way bare paths would.
+static NEXT_FILESYSTEM: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Crash {
@@ -39,6 +44,11 @@ pub enum FaultMode {
Before,
After,
TornWrite,
+ /// A short write the kernel never reports. `TornWrite` returns an error,
so
+ /// every caller learns the record is incomplete; a device that writes
half a
+ /// block and completes the operation tells nobody, and only the record's
own
+ /// checksum can refuse it on the way back.
+ SilentTornWrite,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -69,6 +79,9 @@ pub struct SimFile {
storage: SimStorage,
inode: usize,
epoch: u64,
+ /// errseq sample taken at open. A writeback failure recorded before this
+ /// handle existed is invisible to it, exactly as on Linux.
+ error_seen: Cell<u64>,
}
#[derive(Clone)]
@@ -85,10 +98,12 @@ enum Inode {
#[derive(Clone)]
struct State {
+ id: u64,
inodes: Vec<Inode>,
epoch: u64,
trace: Vec<StorageOperation>,
written_bytes: BTreeMap<usize, usize>,
+ write_errors: BTreeMap<usize, u64>,
fault: Option<(usize, FaultMode)>,
paused: Option<StorageOperation>,
waiters: Vec<std::task::Waker>,
@@ -139,6 +154,47 @@ impl SimStorage {
}
}
+ /// Writeback is per-page and unordered: pages from `first` on reach stable
+ /// storage while everything below them stays dirty and is lost to power
+ /// loss. Whole-inode [`Self::writeback`] cannot produce that state, so a
+ /// recovery walk that trusts its prefix passes under it and fails here.
+ ///
+ /// # Panics
+ /// Panics if `page_size` is zero.
+ pub fn writeback_from_page(&self, page_size: usize, first: usize) {
+ assert!(page_size > 0, "page size must be positive");
+ for inode in &mut self.state.borrow_mut().inodes {
+ if let Inode::File { buffered, stable } = inode {
+ let start = first.saturating_mul(page_size);
+ if start >= buffered.len() {
+ continue;
+ }
+ stable.resize(buffered.len(), 0);
+ stable[start..].copy_from_slice(&buffered[start..]);
+ }
+ }
+ }
+
+ /// Fail the inode's pending writeback the way a failing device does: the
+ /// dirty pages above the last barrier are dropped and unrecoverable, and
the
+ /// error is reported once to each handle that was already open. A handle
+ /// opened afterwards samples the current sequence and sees success over
the
+ /// same lost bytes, which is what makes a fresh-descriptor `fsync` an
+ /// unsound barrier for writes issued through a different one.
+ ///
+ /// # Errors
+ /// Returns an error if `path` does not resolve to a file.
+ pub fn fail_writeback(&self, path: &Path) -> io::Result<()> {
+ let mut state = self.state.borrow_mut();
+ let inode = state.lookup(path)?;
+ match &mut state.inodes[inode] {
+ Inode::File { buffered, stable } => buffered.clone_from(stable),
+ Inode::Directory { .. } => return Err(invalid("writeback failure
on a directory")),
+ }
+ *state.write_errors.entry(inode).or_default() += 1;
+ Ok(())
+ }
+
pub fn pause_writes(&self) {
self.state.borrow_mut().paused = Some(StorageOperation::Write);
}
@@ -194,8 +250,12 @@ impl SimStorage {
if mode == Some(FaultMode::Before) {
return Err(io::Error::other("injected storage failure"));
}
- let result = action(&mut state, mode == Some(FaultMode::TornWrite))?;
- if mode.is_some() {
+ let torn = matches!(
+ mode,
+ Some(FaultMode::TornWrite | FaultMode::SilentTornWrite)
+ );
+ let result = action(&mut state, torn)?;
+ if mode.is_some() && mode != Some(FaultMode::SilentTornWrite) {
return Err(io::Error::other("injected failure after storage
effect"));
}
Ok(result)
@@ -205,6 +265,17 @@ impl SimStorage {
impl DurableStorage for SimStorage {
type File = SimFile;
+ fn writer_identity(&self, path: &Path) ->
io::Result<Option<std::path::PathBuf>> {
+ // The epoch is the simulated process incarnation. A lease that a
cancelled
+ // writer left interrupted is fenced until the process holding it
dies, so
+ // an identity that survived `Crash::Process` could never reopen.
+ let state = self.state.borrow();
+ let process = format!("sim-{}-{}", state.id, state.epoch);
+ Ok(Some(
+
std::path::PathBuf::from(process).join(path.strip_prefix("/").unwrap_or(path)),
+ ))
+ }
+
async fn open(&self, path: &Path, mode: OpenMode) -> io::Result<SimFile> {
let creates = matches!(mode, OpenMode::Create |
OpenMode::CreateOrOpen);
let operation = if creates {
@@ -213,7 +284,7 @@ impl DurableStorage for SimStorage {
StorageOperation::Open
};
self.wait_for(operation).await;
- let (inode, epoch) = self.perform(operation, |state, _| {
+ let (inode, epoch, errors) = self.perform(operation, |state, _| {
let inode = if creates {
let (parent, name) = state.parent(path)?;
if let Some(&inode) = state.directory(parent)?.get(&name) {
@@ -240,12 +311,14 @@ impl DurableStorage for SimStorage {
} else {
state.lookup(path)?
};
- Ok((inode, state.epoch))
+ let errors =
state.write_errors.get(&inode).copied().unwrap_or_default();
+ Ok((inode, state.epoch, errors))
})?;
Ok(SimFile {
storage: self.clone(),
inode,
epoch,
+ error_seen: Cell::new(errors),
})
}
@@ -440,6 +513,17 @@ impl DurableFile for SimFile {
self.storage
.perform(StorageOperation::FileSync, |state, _| {
state.file(self.inode, self.epoch)?;
+ let errors = state
+ .write_errors
+ .get(&self.inode)
+ .copied()
+ .unwrap_or_default();
+ if errors > self.error_seen.get() {
+ self.error_seen.set(errors);
+ return Err(io::Error::other(
+ "writeback failed before this handle synced",
+ ));
+ }
if let Inode::File { buffered, stable } = &mut
state.inodes[self.inode] {
stable.clone_from(buffered);
}
@@ -489,10 +573,12 @@ impl SimFile {
impl Default for State {
fn default() -> Self {
Self {
+ id: NEXT_FILESYSTEM.fetch_add(1, Ordering::Relaxed),
inodes: vec![Inode::directory()],
epoch: 0,
trace: Vec::new(),
written_bytes: BTreeMap::new(),
+ write_errors: BTreeMap::new(),
fault: None,
paused: None,
waiters: Vec::new(),
diff --git a/core/simulator/src/storage/tests.rs
b/core/simulator/src/storage/tests.rs
index 190140171..c64ecb5dd 100644
--- a/core/simulator/src/storage/tests.rs
+++ b/core/simulator/src/storage/tests.rs
@@ -23,7 +23,9 @@ use consensus::MetadataHandle;
use futures::{executor::block_on, poll};
use iggy_binary_protocol::batch::BATCH_HEADER_SIZE;
use iggy_binary_protocol::{Command, Operation, PrepareHeader};
-use journal::partition_journal::{PARTITION_WAL_BLOCK_SIZE, SegmentPosition,
SegmentReference};
+use journal::partition_journal::{
+ PARTITION_WAL_BLOCK_SIZE, SegmentPosition, SegmentReference, record_length,
+};
use journal::{DurableAppend, PartitionPrepareJournal};
use partitions::{PartitionPersistence, install_backup};
use server_common::send_messages::{
@@ -848,6 +850,7 @@ fn first_open_recovers_after_each_initialization_fault() {
}
#[test]
+#[ignore = "PR #4092 review: PRE-EXISTING test. It passed vacuously while
`SimStorage::writer_identity` returned `None` and never took a lease; now that
the lease is real it is blocked on the compio-bound drain wait"]
fn deleting_and_recreating_a_partition_fences_an_old_writer_completion() {
block_on(async {
let storage = storage_for_partition().await;
@@ -2517,3 +2520,330 @@ fn prepare_with_payload(op: u64, parent: u128, payload:
&[u8]) -> Message<Prepar
header.checksum = header.identity_checksum();
Message::try_from(buffer).unwrap()
}
+
+// Regressions for PR #4092 review findings. Each one fails on the current tree
+// and names the defect it pins.
+
+const LARGE_BATCH_BYTES: usize = 1024 * 1024;
+
+#[test]
+fn
given_tail_pages_reached_disk_when_power_loss_then_recovery_should_not_surface_a_holed_record()
{
+ block_on(async {
+ let storage = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7,
storage.clone())
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ journal.append(first.clone().into_frozen()).await.unwrap();
+ let second = prepare(2, first.header().checksum);
+ journal
+ .append_buffered(second.clone().into_frozen())
+ .await
+ .unwrap();
+ drop(journal);
+
+ // The durable first record fills the blocks below `header_page`; the
+ // buffered second one starts there and runs to the end of the file.
+ let header_page = record_blocks(&first);
+ let data = Path::new("/partition/wal/prepares-0.wal");
+ let cached = read_all(&storage, data).await;
+ assert_eq!(
+ cached.len(),
+ (header_page + record_blocks(&second)) * PARTITION_WAL_BLOCK_SIZE
+ );
+ let hole =
+ header_page * PARTITION_WAL_BLOCK_SIZE..(header_page + 1) *
PARTITION_WAL_BLOCK_SIZE;
+ assert!(cached[hole.clone()].iter().any(|byte| *byte != 0));
+
+ // Background writeback preserved the later pages of the buffered
record
+ // and left its first page dirty. `writeback()` cannot express this,
+ // which is why `segment_recovery.rs` documents a byte-zero walk that
+ // nothing exercises.
+ storage.writeback_from_page(PARTITION_WAL_BLOCK_SIZE, header_page + 1);
+ storage.crash(Crash::PowerLoss);
+
+ let survived = read_all(&storage, data).await;
+ assert_eq!(survived.len(), cached.len());
+ assert!(
+ survived[hole.clone()].iter().all(|byte| *byte == 0),
+ "the record's first page reached stable storage, so there is no
hole to recover across"
+ );
+ assert_eq!(
+ survived[hole.end..],
+ cached[hole.end..],
+ "the record's later pages were lost too, so this is a short tail
rather than a hole"
+ );
+
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7,
storage.clone())
+ .await
+ .unwrap();
+ assert!(
+ !recovered.contains(second.header()),
+ "a record whose first page never reached stable storage was
surfaced as recovered"
+ );
+ assert_eq!(
+ recovered.head(),
+ 1,
+ "recovery advanced its head over a page-level hole"
+ );
+ });
+}
+
+#[test]
+fn
given_a_silent_short_write_when_recovering_then_the_record_should_be_refused() {
+ block_on(async {
+ let storage = storage_for_partition().await;
+ let mut journal =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7,
storage.clone())
+ .await
+ .unwrap();
+ let first = prepare(1, 0);
+ journal.append(first.clone().into_frozen()).await.unwrap();
+
+ let second = prepare(2, first.header().checksum);
+ storage.clear_trace();
+ // The device completes a half-written record and reports success. Only
+ // the record checksum can refuse it; `TornWrite` always returns an
+ // error, so no existing case reaches this path.
+ storage.fail_at(0, FaultMode::SilentTornWrite);
+ journal
+ .append(second.clone().into_frozen())
+ .await
+ .expect("a silent short write is reported as success");
+ drop(journal);
+ storage.crash(Crash::PowerLoss);
+
+ // The record was acknowledged before its bytes were lost, so refusing
to
+ // open is the correct answer. Nothing could assert that before,
because
+ // `TornWrite` reports the short write and the append fails instead.
+ let Err(error) =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7,
storage).await
+ else {
+ panic!("a WAL missing acknowledged bytes opened successfully");
+ };
+ assert_eq!(error.kind(), io::ErrorKind::InvalidData);
+ assert!(
+ error.to_string().contains("lost acknowledged bytes"),
+ "unexpected refusal: {error}"
+ );
+ let _ = first;
+ });
+}
+
+#[test]
+#[ignore = "PR #4092 review: `checkpoint_files` syncs materialized files
through a descriptor opened after the writeback failure, which samples errseq
too late and reports success over lost bytes; it must sync through the writer
that issued them"]
+fn
given_a_failed_writeback_when_checkpointing_then_wal_history_should_not_be_reclaimed()
{
+ block_on(async {
+ let (storage, persistence) = queued_batch(4).await;
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ let path = Path::new("/partition/materialized");
+ let mut writer = storage.open(path, OpenMode::Create).await.unwrap();
+ writer.write(0, b"committed".to_vec()).await.unwrap();
+
+ // The device drops the dirty pages before the checkpoint's barrier.
The
+ // writer that issued them is the only handle told; the descriptor
+ // `checkpoint_files` opens afterwards samples errseq past the failure
+ // and reports a successful barrier over bytes that are already gone.
+ storage.fail_writeback(path).unwrap();
+ persistence.checkpoint_files(
+ 4,
+ vec![path.to_path_buf()],
+ vec![Path::new(DIRECTORY).to_path_buf()],
+ );
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+
+ assert!(
+ persistence.failure().is_some(),
+ "a checkpoint reported success over materialized bytes the device
dropped"
+ );
+ assert_eq!(persistence.checkpoint_op(), 0);
+ storage.crash(Crash::PowerLoss);
+ let recovered =
+ PartitionPrepareJournal::open_with_storage(Path::new(WAL), 42, 7,
storage.clone())
+ .await
+ .unwrap();
+ assert_eq!(
+ recovered.checkpoint_op(),
+ 0,
+ "WAL history was reclaimed although its materialization never
reached stable storage"
+ );
+ assert_eq!(recovered.head(), 4);
+ });
+}
+
+#[test]
+fn
given_sim_storage_when_opening_persistence_then_the_writer_lease_should_be_taken()
{
+ block_on(async {
+ let storage = storage_for_partition().await;
+ // Without a `writer_identity`,
`PartitionPersistence::open_with_capacity`
+ // sets `lease = None`, so WRITERS, the interrupted fence and the drain
+ // timeout have no coverage in any simulator test.
+ assert!(
+ DurableStorage::writer_identity(&storage, Path::new(DIRECTORY))
+ .unwrap()
+ .is_some(),
+ "simulator storage reports no writer identity, so every fault test
runs without a lease"
+ );
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7,
storage.clone())
+ .await
+ .unwrap();
+ persistence.retire();
+ });
+}
+
+#[test]
+fn
given_an_interrupted_writer_when_the_process_restarts_then_the_partition_should_reopen()
{
+ block_on(async {
+ let (storage, persistence) = queued_batch(1).await;
+ storage.pause_writes();
+ assert!(persistence.start());
+ let mut writer = Box::pin(Rc::clone(&persistence).run());
+ assert!(poll!(&mut writer).is_pending());
+ // Cancelling the writer mid-mutation leaves its lease interrupted, a
+ // fence only the death of the process holding it may lift.
+ drop(writer);
+ storage.resume();
+ drop(persistence);
+ let fenced =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7,
storage.clone()).await;
+ assert!(
+ fenced.is_err_and(|error| error.to_string().contains("requires
process restart")),
+ "an interrupted writer did not fence the partition within the same
process"
+ );
+
+ storage.crash(Crash::Process);
+ let reopened =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7,
storage.clone()).await;
+ assert!(
+ reopened.is_ok(),
+ "the simulated restart kept the interrupted writer's identity, so
the partition can never reopen: {:?}",
+ reopened.err()
+ );
+ });
+}
+
+#[test]
+#[ignore = "PR #4092 review: append coalescing is gated on message-body bytes,
not the WAL extent, so batching is inert at the benchmarked batch sizes"]
+fn
given_large_bodies_when_appending_then_wal_records_should_coalesce_into_one_barrier_group()
{
+ block_on(async {
+ const PREPARES: u64 = 8;
+ let storage = storage_for_partition().await;
+ let (persistence, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7,
storage.clone())
+ .await
+ .unwrap();
+ persistence.enable_segment_storage(
+ SegmentPosition::default(),
+ PREPARES * LARGE_BATCH_BYTES as u64,
+ );
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+ persistence.take_metrics();
+
+ let mut parent = 0;
+ for offset in 0..PREPARES {
+ let prepare = owned_prepare_sized(offset + 1, parent, offset,
LARGE_BATCH_BYTES);
+ parent = prepare.header().checksum;
+ persistence.append(prepare.into_frozen(), true).unwrap();
+ }
+ assert!(persistence.start());
+ Rc::clone(&persistence).run().await;
+
+ let metrics = persistence.take_metrics();
+ assert_eq!(metrics.batched_prepares, PREPARES);
+ // Under segment references a record occupies one 4 KiB extent, so all
+ // eight fit far inside APPEND_BATCH_BYTES_MAX. The gate measures the
+ // message body instead, so each prepare takes its own barrier group.
+ assert_eq!(
+ metrics.completed_batches,
+ 1,
+ "coalescing is gated on body bytes, so {PREPARES} prepares paid {}
barrier groups for {} bytes of WAL extent",
+ metrics.completed_batches,
+ PREPARES * PARTITION_WAL_BLOCK_SIZE as u64
+ );
+ });
+}
+
+fn owned_prepare_sized(
+ op: u64,
+ parent: u128,
+ offset: u64,
+ batch_bytes: usize,
+) -> Message<PrepareHeader> {
+ let payload = vec![
+ u8::try_from(op % 251).unwrap();
+ batch_bytes - BATCH_HEADER_SIZE - BATCH_MESSAGE_HEADER_SIZE
+ ];
+ let mut messages = IggyMessages::with_capacity(1);
+ messages.push(IggyMessage {
+ header: IggyMessageHeader {
+ id: u128::from(op),
+ payload_length: u32::try_from(payload.len()).unwrap(),
+ ..Default::default()
+ },
+ payload: payload.into(),
+ user_headers: None,
+ });
+ let mut batch =
+ SendMessagesOwned::from_messages(IggyNamespace::new(0, 0, 42),
&messages).unwrap();
+ batch.header.base_offset = offset;
+ batch.header.batch_checksum = batch.header.checksum_for_blob(&batch.blob);
+ let mut body = vec![0; BATCH_HEADER_SIZE + batch.blob.len()];
+ batch.header.encode_into(&mut body[..BATCH_HEADER_SIZE]);
+ body[BATCH_HEADER_SIZE..].copy_from_slice(&batch.blob);
+ assert_eq!(body.len(), batch_bytes);
+ prepare_with_payload(op, parent, &body).transmute_header(
+ |original, header: &mut PrepareHeader| {
+ *header = original;
+ header.checksum_body = 0;
+ header.checksum = header.identity_checksum();
+ },
+ )
+}
+
+fn record_blocks(prepare: &Message<PrepareHeader>) -> usize {
+ record_length(usize::try_from(prepare.header().size).unwrap()).unwrap()
+ / PARTITION_WAL_BLOCK_SIZE
+}
+
+async fn read_all(storage: &SimStorage, path: &Path) -> Vec<u8> {
+ let file = storage.open(path, OpenMode::Read).await.unwrap();
+ let length = usize::try_from(file.length().await.unwrap()).unwrap();
+ file.read(0, length).await.unwrap()
+}
+
+#[test]
+#[ignore = "PR #4092 review: `WriterLease::acquire` drains through
`compio::runtime::time::timeout`, so the writer fence cannot be driven by the
deterministic executor"]
+fn
given_a_retired_writer_when_reacquiring_then_the_drain_wait_should_be_executor_agnostic()
{
+ block_on(async {
+ let storage = storage_for_partition().await;
+ let (first, _) =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 7,
storage.clone())
+ .await
+ .unwrap();
+ first.append(prepare(1, 0).into_frozen(), true).unwrap();
+ storage.pause_writes();
+ assert!(first.start());
+ let mut writer = Box::pin(Rc::clone(&first).run());
+ assert!(poll!(&mut writer).is_pending());
+ first.retire();
+
+ // `WriterLease::acquire` waits for the previous writer to drain
through
+ // `compio::runtime::time::timeout` (`persistence.rs:220`), so the
fence
+ // cannot be driven by the deterministic executor at all. Every
+ // simulator fault case runs with `lease = None` for this reason.
+ let reacquired =
+ PartitionPersistence::open_with_storage(Path::new(WAL), 42, 8,
storage.clone()).await;
+ storage.resume();
+ writer.await;
+ assert!(
+ reacquired.is_ok(),
+ "retired writer could not be replaced under the deterministic
executor"
+ );
+ });
+}