This is an automated email from the ASF dual-hosted git repository.

hubcio 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 60d621ad2 test(integration): add chaos monkey to catch data loss under 
node kills (#4407)
60d621ad2 is described below

commit 60d621ad24046895735fc2ef49f294283b30b497
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Sat Oct 10 19:37:12 2026 +0200

    test(integration): add chaos monkey to catch data loss under node kills 
(#4407)
---
 .config/nextest.toml                               |    8 +
 core/configs/src/configs_impl/file_provider.rs     |   12 +
 core/configs/src/server_config/server.rs           |    2 +-
 core/integration/src/harness/config/server.rs      |   10 +
 core/integration/src/harness/disk.rs               |  177 +-
 core/integration/src/harness/handle/server.rs      |  223 +-
 core/integration/tests/cluster/chaos_monkey.rs     | 2614 ++++++++++++++++++++
 .../tests/cluster/chaos_monkey/report.rs           |  151 ++
 core/integration/tests/cluster/mod.rs              |    1 +
 core/integration/tests/mod.rs                      |   27 +-
 10 files changed, 3180 insertions(+), 45 deletions(-)

diff --git a/.config/nextest.toml b/.config/nextest.toml
index d511b4599..23119ec0d 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -82,6 +82,14 @@ max-threads = 1
 filter = 'binary_id(iggy-gateway-kafka::kafka_client_e2e_tests)'
 test-group = "kafka_client_e2e"
 
+# The chaos monkey runs a five-node cluster for minutes 
(IGGY_TEST_CHAOS_DURATION_SECS, 120 by
+# default), then drains and verifies. It needs the whole box, so its kill and 
restart timing is
+# not distorted by other tests, and a timeout above the default 300 s. The cap 
is one hour.
+[[profile.default.overrides]]
+filter = 'package(integration) and test(/chaos_monkey/)'
+threads-required = "num-cpus"
+slow-timeout = { period = "60s", terminate-after = 60 }
+
 [profile.default]
 slow-timeout = { period = "60s", terminate-after = 5 }
 
diff --git a/core/configs/src/configs_impl/file_provider.rs 
b/core/configs/src/configs_impl/file_provider.rs
index 33b844fb7..056c4390f 100644
--- a/core/configs/src/configs_impl/file_provider.rs
+++ b/core/configs/src/configs_impl/file_provider.rs
@@ -497,6 +497,18 @@ mod tests {
         );
     }
 
+    /// The integration harness forwards every `IGGY_*` variable to the servers
+    /// it starts, its own `IGGY_TEST_*` knobs included.
+    #[test]
+    fn 
given_test_harness_variables_when_checking_then_the_server_should_boot() {
+        let unknown = server_unknown_env_names(&["IGGY_TEST_VERBOSE", 
"IGGY_TEST_CHAOS_SEED"]);
+
+        assert!(
+            unknown.is_empty(),
+            "the boot check must accept the test harness knobs, got: 
{unknown:?}"
+        );
+    }
+
     /// Runs the boot check's filter over `candidates` with the real server
     /// wiring, and without reading the ambient environment.
     fn server_unknown_env_names(candidates: &[&str]) -> Vec<String> {
diff --git a/core/configs/src/server_config/server.rs 
b/core/configs/src/server_config/server.rs
index bc818ada5..90531bda6 100644
--- a/core/configs/src/server_config/server.rs
+++ b/core/configs/src/server_config/server.rs
@@ -84,7 +84,7 @@ pub fn server_process_env_vars() -> impl Iterator<Item = 
&'static str> {
 }
 
 pub(crate) const SERVER_ALLOWED_ENV_PREFIXES: &[&str] =
-    &["IGGY_CONNECTORS_", "IGGY_KAFKA_", "IGGY_MCP_"];
+    &["IGGY_CONNECTORS_", "IGGY_KAFKA_", "IGGY_MCP_", "IGGY_TEST_"];
 
 const DEFAULT_CONFIG_PATH: &str = "core/server/config.toml";
 
diff --git a/core/integration/src/harness/config/server.rs 
b/core/integration/src/harness/config/server.rs
index d64be20e6..7fcb071ab 100644
--- a/core/integration/src/harness/config/server.rs
+++ b/core/integration/src/harness/config/server.rs
@@ -33,6 +33,14 @@ pub struct TestServerConfig {
     pub websocket_tls: Option<TlsConfig>,
     #[builder(default = true)]
     pub cleanup: bool,
+    /// Archive each previous process's captures before restarting. This forces
+    /// file capture even when `IGGY_TEST_VERBOSE` is set.
+    #[builder(default)]
+    pub preserve_logs_on_restart: bool,
+    /// Print complete captures on failure. Disabling this prints paths and
+    /// bounded tails, and forces file capture even with `IGGY_TEST_VERBOSE`.
+    #[builder(default = true)]
+    pub dump_logs_on_failure: bool,
     #[builder(default)]
     pub ip_kind: IpAddrKind,
     #[builder(default)]
@@ -80,5 +88,7 @@ mod tests {
         assert!(config.websocket_enabled);
         assert!(config.http_enabled);
         assert!(config.cleanup);
+        assert!(!config.preserve_logs_on_restart);
+        assert!(config.dump_logs_on_failure);
     }
 }
diff --git a/core/integration/src/harness/disk.rs 
b/core/integration/src/harness/disk.rs
index 32e0d400f..398340bc8 100644
--- a/core/integration/src/harness/disk.rs
+++ b/core/integration/src/harness/disk.rs
@@ -218,43 +218,64 @@ pub fn collect_comparable_files(root: &Path, include_wal: 
bool) -> BTreeMap<Stri
     files
 }
 
+/// Comparable file sizes in node order and every detected cross-replica 
mismatch.
+#[derive(Debug)]
+pub struct ReplicaDataComparison {
+    pub files: Vec<BTreeMap<String, usize>>,
+    pub problems: Vec<String>,
+}
+
 /// Byte-compare every comparable file across the given node data dirs,
 /// panicking with a per-file diff on any divergence.
-///
-/// Guards against a vacuous pass: node 0 must hold at least one produced
-/// segment, otherwise nothing was persisted and the comparison proves nothing.
 pub fn assert_replica_data_identical(data_paths: &[PathBuf], include_wal: 
bool) {
-    let per_node: Vec<BTreeMap<String, Vec<u8>>> = data_paths
-        .iter()
-        .map(|root| collect_comparable_files(root, include_wal))
-        .collect();
-
-    for (idx, node) in per_node.iter().enumerate() {
+    let comparison = compare_replica_data(data_paths, include_wal);
+    for (idx, node) in comparison.files.iter().enumerate() {
         eprintln!(
             "node {idx}: {} comparable file(s): {:?}",
             node.len(),
             node.iter()
-                .map(|(rel, bytes)| format!("{rel} ({} B)", bytes.len()))
+                .map(|(rel, size)| format!("{rel} ({size} B)"))
                 .collect::<Vec<_>>()
         );
     }
 
-    let node0 = &per_node[0];
     assert!(
-        node0
-            .keys()
-            .any(|k| k.starts_with("streams/") && k.ends_with(".log")),
-        "node 0 holds no segment .log under streams/ - no partition data was 
persisted, \
-         so the cross-replica comparison would be vacuous. Comparable files: 
{:?}",
-        node0.keys().collect::<Vec<_>>()
+        comparison.problems.is_empty(),
+        "cross-replica data divergence ({} issue(s)):\n{}",
+        comparison.problems.len(),
+        comparison.problems.join("\n")
     );
+}
+
+/// Byte-compare replica files without printing or asserting on mismatches.
+/// Node 0 must hold a segment; an empty comparison is reported as a problem.
+pub fn compare_replica_data(data_paths: &[PathBuf], include_wal: bool) -> 
ReplicaDataComparison {
+    let per_node: Vec<BTreeMap<String, Vec<u8>>> = data_paths
+        .iter()
+        .map(|root| collect_comparable_files(root, include_wal))
+        .collect();
+    let mut problems = Vec::new();
+    match per_node.first() {
+        Some(node0)
+            if !node0
+                .keys()
+                .any(|key| key.starts_with("streams/") && 
key.ends_with(".log")) =>
+        {
+            problems.push(format!(
+                "node 0 holds no segment .log under streams/ - no partition 
data was persisted, \
+                 so the cross-replica comparison would be vacuous. Comparable 
files: {:?}",
+                node0.keys().collect::<Vec<_>>()
+            ));
+        }
+        None => problems.push("no replica data paths were 
provided".to_owned()),
+        Some(_) => {}
+    }
 
     let all_keys: BTreeSet<&str> = per_node
         .iter()
         .flat_map(|node| node.keys().map(String::as_str))
         .collect();
 
-    let mut problems = Vec::new();
     for key in all_keys {
         let mut reference: Option<(usize, &[u8])> = None;
         for (idx, node) in per_node.iter().enumerate() {
@@ -276,12 +297,17 @@ pub fn assert_replica_data_identical(data_paths: 
&[PathBuf], include_wal: bool)
         }
     }
 
-    assert!(
-        problems.is_empty(),
-        "cross-replica data divergence ({} issue(s)):\n{}",
-        problems.len(),
-        problems.join("\n")
-    );
+    ReplicaDataComparison {
+        files: per_node
+            .into_iter()
+            .map(|node| {
+                node.into_iter()
+                    .map(|(relative_path, bytes)| (relative_path, bytes.len()))
+                    .collect()
+            })
+            .collect(),
+        problems,
+    }
 }
 
 /// Human-readable first-difference report for one relative path across two 
nodes.
@@ -528,3 +554,106 @@ pub async fn leader_node_index_via(harness: &TestHarness, 
via: usize) -> usize {
         })
         .expect("the leader must be one of the roster nodes")
 }
+
+#[cfg(test)]
+mod tests {
+    use std::collections::BTreeMap;
+    use std::fs;
+    use std::path::Path;
+
+    use tempfile::tempdir;
+
+    use super::{assert_replica_data_identical, compare_replica_data};
+
+    const SEGMENT_PATH: &str = 
"streams/1/topics/1/partitions/0/00000000000000000000.log";
+    const NEXT_SEGMENT_PATH: &str = 
"streams/1/topics/1/partitions/0/00000000000000000010.log";
+    const INDEX_PATH: &str = 
"streams/1/topics/1/partitions/0/00000000000000000000.index";
+    const WAL_PATH: &str = "metadata/journal.wal";
+
+    #[test]
+    fn 
given_identical_segments_when_comparing_should_inventory_only_replicated_files()
 {
+        let directory = tempdir().unwrap();
+        let paths = [
+            directory.path().join("node0"),
+            directory.path().join("node1"),
+        ];
+        let payload = b"replicated messages";
+        for (index, path) in paths.iter().enumerate() {
+            write_file(path, SEGMENT_PATH, payload);
+            write_file(path, INDEX_PATH, &index.to_le_bytes());
+            write_file(path, WAL_PATH, &index.to_le_bytes());
+        }
+
+        let comparison = compare_replica_data(&paths, false);
+        assert!(comparison.problems.is_empty(), "{comparison:?}");
+        let expected = BTreeMap::from([(SEGMENT_PATH.to_owned(), 
payload.len())]);
+        assert_eq!(comparison.files, vec![expected.clone(), expected]);
+        assert_replica_data_identical(&paths, false);
+
+        let comparison = compare_replica_data(&paths, true);
+        assert_eq!(comparison.problems.len(), 1, "{comparison:?}");
+        assert!(comparison.problems[0].contains(WAL_PATH), "{comparison:?}");
+        assert!(
+            comparison.problems[0].contains("bytes differ"),
+            "{comparison:?}"
+        );
+    }
+
+    #[test]
+    fn 
given_divergent_and_missing_segments_when_comparing_should_report_both() {
+        let directory = tempdir().unwrap();
+        let paths = [
+            directory.path().join("node0"),
+            directory.path().join("node1"),
+        ];
+        write_file(&paths[0], SEGMENT_PATH, b"original");
+        write_file(&paths[1], SEGMENT_PATH, b"modified");
+        write_file(&paths[1], NEXT_SEGMENT_PATH, b"next segment");
+
+        let comparison = compare_replica_data(&paths, false);
+        assert_eq!(comparison.problems.len(), 2, "{comparison:?}");
+        assert!(
+            comparison.problems.iter().any(|problem| {
+                problem.contains(SEGMENT_PATH) && problem.contains("bytes 
differ")
+            }),
+            "{comparison:?}"
+        );
+        assert!(
+            comparison.problems.iter().any(|problem| {
+                problem.contains(NEXT_SEGMENT_PATH) && 
problem.contains("MISSING on node 0")
+            }),
+            "{comparison:?}"
+        );
+        assert!(!comparison.files[0].contains_key(NEXT_SEGMENT_PATH));
+        assert_eq!(
+            comparison.files[1].get(NEXT_SEGMENT_PATH),
+            Some(&b"next segment".len())
+        );
+    }
+
+    #[test]
+    fn given_no_segments_when_comparing_should_reject_vacuous_agreement() {
+        let directory = tempdir().unwrap();
+        let paths = [
+            directory.path().join("node0"),
+            directory.path().join("node1"),
+        ];
+        for path in &paths {
+            write_file(path, WAL_PATH, b"matching metadata");
+        }
+
+        for include_wal in [false, true] {
+            let comparison = compare_replica_data(&paths, include_wal);
+            assert_eq!(comparison.problems.len(), 1, "{comparison:?}");
+            assert!(comparison.problems[0].contains("vacuous"), 
"{comparison:?}");
+        }
+        let comparison = compare_replica_data(&[], false);
+        assert!(!comparison.problems.is_empty(), "{comparison:?}");
+    }
+
+    fn write_file(root: &Path, relative_path: &str, bytes: &[u8]) {
+        let path = root.join(relative_path);
+        fs::create_dir_all(path.parent().unwrap()).unwrap();
+        fs::write(path, bytes).unwrap();
+    }
+}
diff --git a/core/integration/src/harness/handle/server.rs 
b/core/integration/src/harness/handle/server.rs
index 11972f475..e697cc507 100644
--- a/core/integration/src/harness/handle/server.rs
+++ b/core/integration/src/harness/handle/server.rs
@@ -29,7 +29,7 @@ use iggy_common::TransportProtocol;
 use rand::RngExt as _;
 use std::collections::HashMap;
 use std::fs::{self, File, OpenOptions};
-use std::io::Write;
+use std::io::{Read, Seek, SeekFrom, Write};
 use std::net::SocketAddr;
 use std::os::unix::process::CommandExt;
 use std::path::{Path, PathBuf};
@@ -43,6 +43,8 @@ use toml::Value;
 const SLEEP_INTERVAL_MS: u64 = 20;
 const MAX_PORT_WAIT_DURATION_S: u64 = 60;
 const TEST_VERBOSITY_ENV_VAR: &str = "IGGY_TEST_VERBOSE";
+const FAILURE_LOG_TAIL_BYTES: u64 = 4096;
+const FAILURE_LOG_TAIL_LINES: usize = 8;
 /// The server truncates the dump and then writes it in one go, so a read in
 /// between parses as valid TOML with sections or keys missing; readiness
 /// retries on this message.
@@ -76,6 +78,7 @@ pub struct ServerHandle {
     addrs: ServerProtocolAddr,
     stdout_path: Option<PathBuf>,
     stderr_path: Option<PathBuf>,
+    log_incarnation: usize,
     watchdog_handle: Option<JoinHandle<()>>,
     watchdog_stop: Arc<AtomicBool>,
     generated_cert_dir: Option<PathBuf>,
@@ -175,6 +178,35 @@ impl ServerHandle {
         super::common::collect_logs(&self.stdout_path, &self.stderr_path)
     }
 
+    fn failure_logs(&self) -> (String, String) {
+        failure_logs(
+            &self.stdout_path,
+            &self.stderr_path,
+            self.config.dump_logs_on_failure,
+        )
+    }
+
+    fn archive_previous_logs(&mut self) -> Result<(), TestBinaryError> {
+        if !self.config.preserve_logs_on_restart {
+            return Ok(());
+        }
+        let (Some(stdout_path), Some(stderr_path)) = (&self.stdout_path, 
&self.stderr_path) else {
+            return Ok(());
+        };
+        let archive_dir = self.context.base_dir().join(format!(
+            "server_{}_incarnation_{}",
+            self.server_id, self.log_incarnation
+        ));
+        archive_log_files(stdout_path, stderr_path, &archive_dir)?;
+        self.log_incarnation += 1;
+        Ok(())
+    }
+
+    /// The stderr log when it holds a panic that can leave a dead task behind.
+    pub fn stderr_panic_report(&self) -> Option<String> {
+        super::common::stderr_panic_report(&self.stderr_path)
+    }
+
     /// True once this node has logged that its replica mesh is fully formed
     /// (connected to all expected peers). Cluster-only; a single-node server
     /// never emits the marker. Used by the cluster-readiness gate to ensure
@@ -505,7 +537,7 @@ impl ServerHandle {
             if let Some(child) = self.child_handle.as_mut()
                 && let Ok(Some(status)) = child.try_wait()
             {
-                let (stdout, stderr) = self.collect_logs();
+                let (stdout, stderr) = self.failure_logs();
                 return Err(TestBinaryError::ProcessCrashed {
                     binary: self.launched_binary(),
                     exit_code: status.code(),
@@ -626,11 +658,18 @@ impl ServerHandle {
         let stop_signal = self.watchdog_stop.clone();
         let stdout_path = self.stdout_path.clone();
         let stderr_path = self.stderr_path.clone();
+        let dump_logs_on_failure = self.config.dump_logs_on_failure;
 
         let handle = thread::Builder::new()
             .name("test-server-watchdog".to_string())
             .spawn(move || {
-                Self::watchdog_loop(pid, stop_signal, stdout_path, 
stderr_path);
+                Self::watchdog_loop(
+                    pid,
+                    stop_signal,
+                    stdout_path,
+                    stderr_path,
+                    dump_logs_on_failure,
+                );
             })
             .expect("Failed to spawn watchdog thread");
 
@@ -642,6 +681,7 @@ impl ServerHandle {
         stop_signal: Arc<AtomicBool>,
         stdout_path: Option<PathBuf>,
         stderr_path: Option<PathBuf>,
+        dump_logs_on_failure: bool,
     ) {
         const CHECK_INTERVAL: Duration = Duration::from_millis(100);
 
@@ -651,15 +691,8 @@ impl ServerHandle {
             }
 
             if !super::common::is_process_alive(pid) {
-                let stdout_content = stdout_path
-                    .as_ref()
-                    .and_then(|p| fs::read_to_string(p).ok())
-                    .unwrap_or_else(|| "[No stdout log]".to_string());
-
-                let stderr_content = stderr_path
-                    .as_ref()
-                    .and_then(|p| fs::read_to_string(p).ok())
-                    .unwrap_or_else(|| "[No stderr log]".to_string());
+                let (stdout_content, stderr_content) =
+                    failure_logs(&stdout_path, &stderr_path, 
dump_logs_on_failure);
 
                 let exit_status = super::common::reap_exit_status(pid)
                     .unwrap_or_else(|| "unavailable (already 
reaped)".to_string());
@@ -701,6 +734,7 @@ impl ServerHandle {
             addrs: ServerProtocolAddr::empty(),
             stdout_path: None,
             stderr_path: None,
+            log_incarnation: 0,
             watchdog_handle: None,
             watchdog_stop: Arc::new(AtomicBool::new(false)),
             generated_cert_dir: None,
@@ -880,6 +914,7 @@ impl TestBinary for ServerHandle {
             addrs: ServerProtocolAddr::empty(),
             stdout_path: None,
             stderr_path: None,
+            log_incarnation: 0,
             watchdog_handle: None,
             watchdog_stop: Arc::new(AtomicBool::new(false)),
             generated_cert_dir: None,
@@ -1009,13 +1044,16 @@ impl TestBinary for ServerHandle {
         // `cluster.enabled=true`, so single-node tests see no effect.
         command.arg("--replica-id").arg(self.server_id.to_string());
 
-        let verbose = std::env::var(TEST_VERBOSITY_ENV_VAR).is_ok()
-            || self.envs.contains_key(TEST_VERBOSITY_ENV_VAR);
+        let verbose = (std::env::var(TEST_VERBOSITY_ENV_VAR).is_ok()
+            || self.envs.contains_key(TEST_VERBOSITY_ENV_VAR))
+            && self.config.dump_logs_on_failure
+            && !self.config.preserve_logs_on_restart;
 
         if verbose {
             command.stdout(Stdio::inherit());
             command.stderr(Stdio::inherit());
         } else {
+            self.archive_previous_logs()?;
             let stdout_path = self.stdout_log_path();
             let stderr_path = self.stderr_log_path();
 
@@ -1084,7 +1122,7 @@ impl TestBinary for ServerHandle {
 
     fn assert_running(&self) {
         if let Some(pid) = self.pid().filter(|&p| 
!super::common::is_process_alive(p)) {
-            let (stdout, stderr) = self.collect_logs();
+            let (stdout, stderr) = self.failure_logs();
             panic!(
                 "Server process (pid {}) has crashed\n\n\
                  === STDOUT ===\n{}\n\n\
@@ -1216,6 +1254,11 @@ impl Drop for ServerHandle {
         let _ = self.stop();
         let label = self.log_label();
         if let Some(report) = 
super::common::stderr_panic_report(&self.stderr_path) {
+            let report = if self.config.dump_logs_on_failure {
+                report
+            } else {
+                concise_log(self.stderr_path.as_deref())
+            };
             if std::thread::panicking() {
                 // Ahead of the full dump, which buries these lines under the
                 // complete stdout of every node.
@@ -1225,17 +1268,81 @@ impl Drop for ServerHandle {
                 // failing here is the only thing that surfaces it. The panic
                 // unwinds out of this `Drop` before the dump below runs, so
                 // print this node's logs first.
-                let (stdout, stderr) =
-                    super::common::collect_logs(&self.stdout_path, 
&self.stderr_path);
+                let (stdout, stderr) = self.failure_logs();
                 eprintln!("{label} stdout:\n{stdout}");
                 eprintln!("{label} stderr:\n{stderr}");
                 panic!("{label} panicked:\n{report}");
             }
         }
-        super::common::dump_logs_on_panic(&label, &self.stdout_path, 
&self.stderr_path);
+        if std::thread::panicking() {
+            let (stdout, stderr) = self.failure_logs();
+            eprintln!("{label} stdout:\n{stdout}");
+            eprintln!("{label} stderr:\n{stderr}");
+        }
+    }
+}
+
+fn archive_log_files(
+    stdout_path: &Path,
+    stderr_path: &Path,
+    archive_dir: &Path,
+) -> Result<(), TestBinaryError> {
+    fs::create_dir(archive_dir).map_err(|source| 
TestBinaryError::FileSystemError {
+        path: archive_dir.to_path_buf(),
+        source,
+    })?;
+    for (source_path, name) in [(stdout_path, "stdout.log"), (stderr_path, 
"stderr.log")] {
+        let archive_path = archive_dir.join(name);
+        fs::copy(source_path, &archive_path).map_err(|source| {
+            TestBinaryError::FileSystemError {
+                path: archive_path,
+                source,
+            }
+        })?;
+    }
+    Ok(())
+}
+
+fn failure_logs(
+    stdout_path: &Option<PathBuf>,
+    stderr_path: &Option<PathBuf>,
+    dump_logs_on_failure: bool,
+) -> (String, String) {
+    if dump_logs_on_failure {
+        super::common::collect_logs(stdout_path, stderr_path)
+    } else {
+        (
+            concise_log(stdout_path.as_deref()),
+            concise_log(stderr_path.as_deref()),
+        )
     }
 }
 
+fn concise_log(path: Option<&Path>) -> String {
+    let Some(path) = path else {
+        return "[No captured log]".to_owned();
+    };
+    match log_tail(path) {
+        Ok(tail) => format!("{} (tail):\n{tail}", path.display()),
+        Err(error) => format!("{} (unreadable: {error})", path.display()),
+    }
+}
+
+fn log_tail(path: &Path) -> std::io::Result<String> {
+    let mut file = File::open(path)?;
+    let start = file
+        .metadata()?
+        .len()
+        .saturating_sub(FAILURE_LOG_TAIL_BYTES);
+    file.seek(SeekFrom::Start(start))?;
+    let mut bytes = Vec::new();
+    file.take(FAILURE_LOG_TAIL_BYTES).read_to_end(&mut bytes)?;
+    let text = String::from_utf8_lossy(&bytes);
+    let mut lines: Vec<&str> = 
text.lines().rev().take(FAILURE_LOG_TAIL_LINES).collect();
+    lines.reverse();
+    Ok(lines.join("\n"))
+}
+
 /// Drop ANSI escape sequences (`ESC [ ... <letter>`) so log markers match
 /// the text an operator sees, not the color codes around it.
 fn strip_ansi(log: &str) -> String {
@@ -1276,3 +1383,83 @@ fn generate_test_certificates(cert_dir: &str) -> 
Result<(), Box<dyn std::error::
 
     Ok(())
 }
+
+#[cfg(test)]
+mod tests {
+    use super::{FAILURE_LOG_TAIL_LINES, TestBinaryError, archive_log_files, 
failure_logs};
+    use std::fs;
+    use std::io::ErrorKind;
+    use tempfile::tempdir;
+
+    #[test]
+    fn 
given_restarts_when_captures_are_truncated_should_preserve_each_incarnation() {
+        let directory = tempdir().unwrap();
+        let stdout_path = directory.path().join("stdout.log");
+        let stderr_path = directory.path().join("stderr.log");
+        let incarnations: [(&[u8], &[u8]); 2] = [
+            (b"first stdout\xff", b"first stderr\xfe"),
+            (b"second stdout", b"second stderr"),
+        ];
+
+        for (incarnation, (stdout, stderr)) in incarnations.iter().enumerate() 
{
+            fs::write(&stdout_path, stdout).unwrap();
+            fs::write(&stderr_path, stderr).unwrap();
+            let archive_dir = 
directory.path().join(format!("incarnation_{incarnation}"));
+            archive_log_files(&stdout_path, &stderr_path, 
&archive_dir).unwrap();
+            fs::write(&stdout_path, []).unwrap();
+            fs::write(&stderr_path, []).unwrap();
+        }
+
+        for (incarnation, (stdout, stderr)) in incarnations.iter().enumerate() 
{
+            let archive_dir = 
directory.path().join(format!("incarnation_{incarnation}"));
+            assert_eq!(fs::read(archive_dir.join("stdout.log")).unwrap(), 
*stdout);
+            assert_eq!(fs::read(archive_dir.join("stderr.log")).unwrap(), 
*stderr);
+        }
+        assert!(fs::read(&stdout_path).unwrap().is_empty());
+        assert!(fs::read(&stderr_path).unwrap().is_empty());
+    }
+
+    #[test]
+    fn 
given_existing_archive_when_archiving_should_fail_without_overwriting_evidence()
 {
+        let directory = tempdir().unwrap();
+        let stdout_path = directory.path().join("stdout.log");
+        let stderr_path = directory.path().join("stderr.log");
+        let archive_dir = directory.path().join("incarnation_0");
+        fs::write(&stdout_path, b"original stdout").unwrap();
+        fs::write(&stderr_path, b"original stderr").unwrap();
+        archive_log_files(&stdout_path, &stderr_path, &archive_dir).unwrap();
+        fs::write(&stdout_path, b"new stdout").unwrap();
+
+        let error = archive_log_files(&stdout_path, &stderr_path, 
&archive_dir).unwrap_err();
+        assert!(matches!(
+            error,
+            TestBinaryError::FileSystemError { path, source }
+                if path == archive_dir && source.kind() == 
ErrorKind::AlreadyExists
+        ));
+        assert_eq!(
+            fs::read(archive_dir.join("stdout.log")).unwrap(),
+            b"original stdout"
+        );
+        assert_eq!(fs::read(&stdout_path).unwrap(), b"new stdout");
+    }
+
+    #[test]
+    fn 
given_large_capture_when_concise_failure_requested_should_show_path_and_bounded_tail()
 {
+        let directory = tempdir().unwrap();
+        let path = directory.path().join("stdout.log");
+        let mut contents = "older output\n".repeat(1000);
+        for line in 0..FAILURE_LOG_TAIL_LINES {
+            contents.push_str(&format!("tail {line}\n"));
+        }
+        fs::write(&path, &contents).unwrap();
+
+        let (concise, _) = failure_logs(&Some(path.clone()), &None, false);
+        assert!(concise.starts_with(&format!("{} (tail):\n", path.display())));
+        assert!(!concise.contains("older output"));
+        assert_eq!(concise.lines().count(), FAILURE_LOG_TAIL_LINES + 1);
+        assert!(concise.ends_with(&format!("tail {}", FAILURE_LOG_TAIL_LINES - 
1)));
+
+        let (complete, _) = failure_logs(&Some(path), &None, true);
+        assert_eq!(complete, contents);
+    }
+}
diff --git a/core/integration/tests/cluster/chaos_monkey.rs 
b/core/integration/tests/cluster/chaos_monkey.rs
new file mode 100644
index 000000000..c177a58f3
--- /dev/null
+++ b/core/integration/tests/cluster/chaos_monkey.rs
@@ -0,0 +1,2614 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Runs producers and consumers against five nodes while killing and 
restarting
+//! one random node at a time. This is an opt-in test that takes several 
minutes.
+//!
+//! # What it checks
+//!
+//! Producers put their identity and sequence number in each message id. The
+//! test checks acknowledged messages against those ids, their payloads and
+//! their order. Consumer group members join and leave during the run, while
+//! standalone consumers also read and commit offsets.
+//!
+//! The main topic uses small segments and expires old messages. A second topic
+//! keeps its full history so the final checks can cover sealed segments too.
+//! Both use the default `replicated` durability. After the kills stop, readers
+//! drain their partitions. The test compares each node's messages, stored
+//! offsets and group membership. It then stops every node and, if the online
+//! checks passed, compares the persisted segment files byte for byte.
+//!
+//! Missing, corrupt, reordered, phantom or unexplained duplicate messages fail
+//! the test. So do partial batches, incorrect offset confirmations, missed
+//! deliveries, conflicting reads or group assignments, unexpected node exits
+//! or task panics, and replicas that disagree. Expiry must actually delete
+//! segments during the run. A short run that never exercises expiry fails.
+//!
+//! # Running it
+//!
+//! Build the server first. The test starts the binary already in `target/`.
+//!
+//! ```text
+//! cargo build --bin iggy-server
+//! IGGY_TEST_CHAOS_DURATION_SECS=120 IGGY_TEST_CHAOS_KILL_INTERVAL_SECS=15 \
+//! IGGY_TEST_CHAOS_DOWNTIME_SECS=5 IGGY_TEST_CHAOS_SEED=7 \
+//!     cargo nextest run -p integration --run-ignored only --no-capture -E 
'test(chaos_monkey)'
+//! ```
+//!
+//! Duration, kill interval and downtime default to 120, 15 and 5 seconds.
+//! Without `IGGY_TEST_CHAOS_SEED`, each run chooses and prints a random seed.
+//! Downtime must be shorter than the kill interval, and the duration must
+//! allow at least one kill and restart. These are chaos-phase durations;
+//! startup, draining and verification take additional time.
+//!
+//! The seed repeats random choices, not thread scheduling, disk latency or
+//! recovery progress. A failure is still a failed invariant, not an expected
+//! consequence of choosing a random workload. To investigate a run-order
+//! effect, repeat the same configurations alone and in both orders, using the
+//! same server binary. Compare actual fault times and retained message ranges.
+//!
+//! # Reports and logs
+//!
+//! The printed artifact directory under `test_logs/` is retained on success
+//! and failure. Remove it when the investigation is done; it includes server
+//! data as well as logs. `chaos.jsonl` contains one JSON object per line:
+//! metadata, phase changes, fault events, node views, retained message ranges,
+//! counters, check results and the final verdict. Metadata includes the server
+//! binary's SHA-256, host details and workload settings. Each node record 
points
+//! to its saved effective configuration.
+//!
+//! Events are written as they happen. A report without a `finished` event is
+//! incomplete, for example after a timeout or forced termination. A `panicked`
+//! verdict records an interrupted check, not a successful run. The final disk
+//! comparison is explicitly skipped when the online checks fail.
+//!
+//! Current captures are `server_<id>_stdout.log` and `server_<id>_stderr.log`.
+//! Earlier captures are saved in `server_<id>_incarnation_<n>/` before each
+//! restart, including startup retries. This test captures logs even when
+//! `IGGY_TEST_VERBOSE` is set, and prints only short failure excerpts.
+//! Client logs and the test's panic text are saved in `test_stdout.log`.
+//!
+//! # Observed failures and check limitations
+//!
+//! These are investigation notes, not reasons to accept a failing run:
+//!
+//! - Earlier runs found a rejoining node with committed messages still only
+//!   in memory and different segment files after shutdown. The shutdown path
+//!   has since changed, so that diagnosis needs rechecking. The stress case
+//!   used seed 99, 180 seconds, a 14-second kill interval and 10-second 
downtime.
+//!   That configuration does not fail on every run.
+//! - Under `replicated` durability, a restarted node repairs partition logs
+//!   from op 1. Repairing replicas can stop contributing to the write quorum.
+//!   Heavy disk load has stalled partitions for over a minute.
+//! - A `next` poll can observe a node before an acknowledged offset store has
+//!   been applied there. The test counts these reads as redeliveries and 
checks
+//!   stored offsets at the end; it cannot distinguish them from a lost store
+//!   during the workload.
+//! - HTTP group-offset reads currently resolve a standalone consumer instead.
+//!   The test reads group offsets over TCP.
+//! - A committed send or retried duplicate can be acknowledged without offset
+//!   confirmation. The test checks the batch's messages but cannot verify its
+//!   acknowledged placement. It fails if a producer gets no confirmations.
+//! - Metadata log divergence has been observed separately in the simulator
+//!   with `workload-fuzz --seed 62 --plane metadata --faults swarm 
--crash-prob
+//!   0.01 --restart-prob 0.05 --crash-primary`. One traced path adopts a
+//!   `StartView` probe without canonical headers and commits a discarded-view
+//!   operation. This real-process test has not established that failure path.
+//!
+//! The `IGGY_TEST_` variables are test controls. They are forwarded to child
+//! processes, whose environment validation permits that prefix.
+
+use std::collections::{BTreeMap, HashMap, HashSet, btree_map};
+use std::fmt;
+use std::fs::File;
+use std::future::Future;
+use std::io::Read;
+use std::ops::RangeInclusive;
+use std::panic::{AssertUnwindSafe, resume_unwind};
+use std::path::PathBuf;
+use std::process::Command;
+use std::str::FromStr;
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, SystemTime};
+
+use assert_cmd::cargo::CommandCargoExt;
+use bytes::Bytes;
+use futures::{FutureExt, StreamExt};
+use iggy::prelude::*;
+use integration::harness::{
+    TestBinary, TestBinaryError, TestHarness, TestServerConfig, disk, 
resolve_config_paths,
+};
+use rand::rngs::StdRng;
+use rand::{RngExt, SeedableRng};
+use ring::digest::{Context, SHA256};
+use serde::Serialize;
+use serde_json::json;
+use serial_test::parallel;
+use sysinfo::System;
+use tokio::sync::watch;
+use tokio::task::JoinHandle;
+use tokio::time::{Instant, sleep, timeout, timeout_at};
+
+use self::report::Report;
+
+mod report;
+
+const CLUSTER_NODES: usize = 5;
+const STREAM_NAME: &str = "chaos-stream";
+const TOPIC_NAME: &str = "chaos-topic";
+const HISTORY_TOPIC_NAME: &str = "chaos-history";
+const GROUP_NAME: &str = "chaos-group";
+const PARTITIONS: u32 = 4;
+/// Two producers per partition, so their batches interleave.
+const PRODUCERS: u32 = 8;
+/// One per partition of the history topic. Their ids follow the main topic's
+/// producers, so every message id stays unique.
+const HISTORY_PRODUCERS: u32 = PARTITIONS;
+/// Low-level members that churn. The `IggyConsumer` comes on top.
+const GROUP_MEMBERS: u32 = 3;
+/// The smallest segment the server accepts, so segments rotate under the 
kills.
+const SEGMENT_SIZE_BYTES: u64 = 1024 * 1024;
+/// A partition fills a segment in about half a minute, so sealed segments
+/// expire and get deleted during the run, while every reader stays much
+/// closer to the end than this.
+const MESSAGE_EXPIRY: Duration = Duration::from_secs(30);
+/// Standalone consumers get this id plus their partition. Away from
+/// `VERIFIER_CONSUMER_ID`.
+const FIRST_CONSUMER_ID: u32 = 10;
+/// The server hashes this name into the standalone `IggyConsumer`'s id.
+const STANDALONE_ICONSUMER_NAME: &str = "chaos-consumer";
+const STANDALONE_ICONSUMER_PARTITION: u32 = 0;
+
+const DURATION_ENV: &str = "IGGY_TEST_CHAOS_DURATION_SECS";
+const KILL_INTERVAL_ENV: &str = "IGGY_TEST_CHAOS_KILL_INTERVAL_SECS";
+const DOWNTIME_ENV: &str = "IGGY_TEST_CHAOS_DOWNTIME_SECS";
+const SEED_ENV: &str = "IGGY_TEST_CHAOS_SEED";
+const DEFAULT_DURATION: Duration = Duration::from_secs(120);
+const DEFAULT_KILL_INTERVAL: Duration = Duration::from_secs(15);
+const DEFAULT_DOWNTIME: Duration = Duration::from_secs(5);
+
+/// Every reconnect gives a client a new id, so a member whose node dies stays
+/// behind as a stale member until its lease expires. These values (taken from
+/// client_table_restart.rs) expire it within one kill interval instead of the
+/// default 30 s.
+const GROUP_HEARTBEAT_INTERVAL: &str = "500ms";
+const GROUP_SESSION_TIMEOUT: &str = "8s";
+/// Each node runs its own segment cleaner on a timer that starts at boot.
+/// With the default one-minute interval, a node killed within a minute of its
+/// boot never cleans, so expiry would barely run during the chaos phase.
+const CLEANER_INTERVAL: &str = "1s";
+
+const PRODUCER_BATCH: u32 = 10;
+const PRODUCER_PAUSE: Duration = Duration::from_millis(50);
+const POLL_BATCH: u32 = 100;
+const POLL_PAUSE: Duration = Duration::from_millis(50);
+const RETRY_PAUSE: Duration = Duration::from_millis(100);
+const MEMBER_SESSION_MS: RangeInclusive<u64> = 1_000..=4_000;
+const MEMBER_PAUSE_MS: RangeInclusive<u64> = 0..=1_000;
+const ICONSUMER_COMMIT_INTERVAL: Duration = Duration::from_secs(1);
+
+/// Above the SDK's own 30 s response timeout, so only a call that outlives
+/// its own deadline trips it.
+const CALL_TIMEOUT: Duration = Duration::from_secs(45);
+const CALL_TIMED_OUT: &str = "call_timed_out";
+const LEADER_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
+const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(1);
+const GROUP_SAMPLE_INTERVAL: Duration = Duration::from_secs(1);
+const RECOVERY_TIMEOUT: Duration = Duration::from_secs(60);
+const DRAIN_TIMEOUT: Duration = Duration::from_secs(90);
+const DRAIN_CHECK_INTERVAL: Duration = Duration::from_millis(500);
+const CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(60);
+const CONVERGENCE_RETRY: Duration = Duration::from_secs(1);
+
+const VERIFIER_CONSUMER_ID: u32 = 1;
+const VERIFY_BATCH: u32 = 1_000;
+const STDERR_TAIL_LINES: usize = 20;
+const REPORTED_EXAMPLES: usize = 5;
+const FAILURE_SUMMARY_CHARS: usize = 240;
+const DIGEST_BUFFER_SIZE: usize = 64 * 1024;
+
+type SharedLog = Arc<Mutex<ObservedLog>>;
+
+#[tokio::test(flavor = "multi_thread")]
+#[parallel]
+#[ignore = "runs for minutes against a five-node cluster; run with 
--run-ignored only"]
+async fn 
given_five_nodes_when_one_is_killed_every_interval_should_keep_acked_messages() 
{
+    crate::use_saved_logs_on_failure();
+    let config = ChaosConfig::from_env();
+    let mut harness = build_cluster();
+    let mut report =
+        Report::create(harness.test_dir(), Instant::now()).expect("create 
chaos report");
+    println!("{config}\nartifacts: {}", harness.test_dir().display());
+    report
+        .record("started", json!({"command_env": config.to_string()}))
+        .expect("write run start");
+
+    let result = AssertUnwindSafe(run_chaos(&mut harness, &config, &mut 
report))
+        .catch_unwind()
+        .await;
+    let violations = match result {
+        Ok(violations) => violations,
+        Err(payload) => {
+            let message = payload
+                .downcast_ref::<String>()
+                .map(String::as_str)
+                .or_else(|| payload.downcast_ref::<&str>().copied())
+                .unwrap_or("non-string panic payload");
+            if let Err(error) =
+                report.record("finished", json!({"outcome": "panicked", 
"error": message}))
+            {
+                eprintln!(
+                    "cannot record panic in {}: {error}",
+                    report.path().display()
+                );
+            }
+            resume_unwind(payload);
+        }
+    };
+    report
+        .record(
+            "finished",
+            json!({
+                "outcome": if violations.is_empty() { "passed" } else { 
"failed" },
+                "violations": violations,
+            }),
+        )
+        .expect("write final verdict");
+    let examples = violations
+        .iter()
+        .take(REPORTED_EXAMPLES)
+        .map(|violation| {
+            violation
+                .lines()
+                .next()
+                .unwrap_or_default()
+                .chars()
+                .take(FAILURE_SUMMARY_CHARS)
+                .collect::<String>()
+        })
+        .collect::<Vec<_>>()
+        .join("\n");
+    assert!(
+        violations.is_empty(),
+        "{} violations:\n{examples}\nreport: {}",
+        violations.len(),
+        report.path().display()
+    );
+    println!("all checks passed; report: {}", report.path().display());
+}
+
+async fn run_chaos(
+    harness: &mut TestHarness,
+    config: &ChaosConfig,
+    report: &mut Report,
+) -> Vec<String> {
+    report
+        .record("metadata", run_metadata(config))
+        .expect("write run metadata");
+    harness.start().await.expect("start the cluster");
+    report_nodes(harness, report, "cluster_started");
+    create_topic_and_group(harness).await;
+
+    let started = Instant::now();
+    report
+        .record("phase", json!({"name": "chaos"}))
+        .expect("write chaos phase");
+    let workload = Workload::spawn(harness, config.seed, started).await;
+    let mut violations = Vec::new();
+    // Kill and restart block the calling thread for seconds. The multi-thread
+    // runtime keeps the workload running on its worker threads meanwhile.
+    let kills = run_monkey(harness, config, started, &mut violations, 
report).await;
+    report
+        .record("phase", json!({"name": "drain"}))
+        .expect("write drain phase");
+    let mut outcomes = workload.finish().await;
+    outcomes.check(&mut violations);
+    report
+        .record(
+            "check",
+            json!({
+                "name": "workload",
+                "outcome": if violations.is_empty() { "passed" } else { 
"failed" },
+                "violations": violations,
+            }),
+        )
+        .expect("write workload check");
+
+    report
+        .record("phase", json!({"name": "verify"}))
+        .expect("write verification phase");
+    let verifiers = connect_verifiers(harness, &mut violations).await;
+    let view = wait_for_convergence(
+        &verifiers,
+        &outcomes.readers,
+        started,
+        &mut violations,
+        report,
+    )
+    .await;
+    let stats = verify_logs(
+        &verifiers,
+        view.as_ref(),
+        &mut outcomes,
+        &mut violations,
+        report,
+    )
+    .await;
+    for node in 0..CLUSTER_NODES {
+        if let Some(stderr) = panic_report(harness, node) {
+            violations.push(format!("node {node} panicked:\n{stderr}"));
+        }
+    }
+
+    record_summary(report, &kills, &outcomes, view.as_ref(), &stats);
+    report
+        .record(
+            "check",
+            json!({
+                "name": "online",
+                "outcome": if violations.is_empty() { "passed" } else { 
"failed" },
+                "violations": violations,
+            }),
+        )
+        .expect("write online checks");
+
+    // A graceful stop flushes what each node still holds in memory, so the
+    // segment files can be compared byte for byte at rest.
+    let data_paths: Vec<PathBuf> = harness
+        .all_servers()
+        .iter()
+        .map(|server| server.data_path())
+        .collect();
+    report
+        .record("phase", json!({"name": "stop"}))
+        .expect("write stop phase");
+    harness
+        .stop()
+        .await
+        .expect("stop the cluster for the at-rest comparison");
+    report_nodes(harness, report, "cluster_stopped");
+    for node in 0..CLUSTER_NODES {
+        if let Some(stderr) = panic_report(harness, node) {
+            let violation = format!("node {node} panicked:\n{stderr}");
+            if !violations.contains(&violation) {
+                violations.push(violation);
+            }
+        }
+    }
+    if !violations.is_empty() {
+        report
+            .record(
+                "check",
+                json!({
+                    "name": "replica_files",
+                    "outcome": "skipped",
+                    "reason": "online or shutdown checks failed",
+                }),
+            )
+            .expect("write skipped disk check");
+        return violations;
+    }
+    report
+        .record("phase", json!({"name": "replica_files"}))
+        .expect("write disk comparison phase");
+    let comparison = disk::compare_replica_data(&data_paths, false);
+    report
+        .record(
+            "check",
+            json!({
+                "name": "replica_files",
+                "outcome": if comparison.problems.is_empty() { "passed" } else 
{ "failed" },
+                "files": comparison.files,
+                "violations": comparison.problems,
+            }),
+        )
+        .expect("write disk comparison result");
+    violations.extend(comparison.problems);
+    violations
+}
+
+#[derive(Debug, Clone, Copy)]
+struct ChaosConfig {
+    duration: Duration,
+    kill_interval: Duration,
+    downtime: Duration,
+    seed: u64,
+}
+
+impl ChaosConfig {
+    fn from_env() -> Self {
+        let config = Self {
+            duration: env_value(DURATION_ENV).map_or(DEFAULT_DURATION, 
Duration::from_secs),
+            kill_interval: env_value(KILL_INTERVAL_ENV)
+                .map_or(DEFAULT_KILL_INTERVAL, Duration::from_secs),
+            downtime: env_value(DOWNTIME_ENV).map_or(DEFAULT_DOWNTIME, 
Duration::from_secs),
+            seed: env_value(SEED_ENV).unwrap_or_else(rand::random),
+        };
+        assert!(
+            config.downtime < config.kill_interval,
+            "{DOWNTIME_ENV} must be below {KILL_INTERVAL_ENV}, so at most one 
node is down: \
+             {config}"
+        );
+        assert!(
+            config.kill_interval + config.downtime <= config.duration,
+            "{DURATION_ENV} must leave room for at least one kill and restart: 
{config}"
+        );
+        config
+    }
+}
+
+impl fmt::Display for ChaosConfig {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "{DURATION_ENV}={} {KILL_INTERVAL_ENV}={} {DOWNTIME_ENV}={} 
{SEED_ENV}={}",
+            self.duration.as_secs(),
+            self.kill_interval.as_secs(),
+            self.downtime.as_secs(),
+            self.seed
+        )
+    }
+}
+
+fn env_value<T: FromStr>(name: &str) -> Option<T>
+where
+    T::Err: fmt::Display,
+{
+    let value = std::env::var(name).ok()?;
+    Some(
+        value
+            .parse()
+            .unwrap_or_else(|error| panic!("{name}={value} is not valid: 
{error}")),
+    )
+}
+
+fn build_cluster() -> TestHarness {
+    let overrides = HashMap::from([
+        (
+            "consumer_group.heartbeat_interval".to_owned(),
+            GROUP_HEARTBEAT_INTERVAL.to_owned(),
+        ),
+        (
+            "consumer_group.session_timeout".to_owned(),
+            GROUP_SESSION_TIMEOUT.to_owned(),
+        ),
+        (
+            "data_maintenance.messages.interval".to_owned(),
+            CLEANER_INTERVAL.to_owned(),
+        ),
+    ]);
+    let extra_envs = resolve_config_paths(&overrides).expect("valid server 
config paths");
+    TestHarness::builder()
+        .server(
+            TestServerConfig::builder()
+                .extra_envs(extra_envs)
+                .preserve_logs_on_restart(true)
+                .dump_logs_on_failure(false)
+                .build(),
+        )
+        .cluster_nodes(CLUSTER_NODES)
+        .cleanup(false)
+        .build()
+        .expect("build the harness")
+}
+
+async fn create_topic_and_group(harness: &TestHarness) {
+    let target = Target::new();
+    let client = harness.tcp_root_client().await.expect("root client");
+    client
+        .create_stream(STREAM_NAME)
+        .await
+        .expect("create the stream");
+    let options = TopicCreateOptions {
+        partitions_count: Some(PARTITIONS),
+        message_expiry: Some(IggyExpiry::ExpireDuration(IggyDuration::from(
+            MESSAGE_EXPIRY,
+        ))),
+        segment_size: Some(IggyByteSize::from(SEGMENT_SIZE_BYTES)),
+        ..TopicCreateOptions::default()
+    };
+    client
+        .create_topic(&target.stream, TOPIC_NAME, &options)
+        .await
+        .expect("create the topic");
+    let history = TopicCreateOptions {
+        partitions_count: Some(PARTITIONS),
+        segment_size: Some(IggyByteSize::from(SEGMENT_SIZE_BYTES)),
+        ..TopicCreateOptions::default()
+    };
+    client
+        .create_topic(&target.stream, HISTORY_TOPIC_NAME, &history)
+        .await
+        .expect("create the history topic");
+    client
+        .create_consumer_group(&target.stream, &target.topic, GROUP_NAME)
+        .await
+        .expect("create the consumer group");
+}
+
+/// Spreads first dials over the nodes. The SDK moves every client to the
+/// leader after sign-in anyway.
+struct Dialer<'a> {
+    harness: &'a TestHarness,
+    dialed: usize,
+}
+
+impl Dialer<'_> {
+    async fn connect(&mut self) -> IggyClient {
+        let node = self.dialed % CLUSTER_NODES;
+        self.dialed += 1;
+        self.harness
+            .node(node)
+            .tcp_client()
+            .expect("every node exposes TCP")
+            .with_reconnecting_root_login()
+            .connect()
+            .await
+            .expect("connect a TCP client")
+    }
+}
+
+#[derive(Clone)]
+struct Target {
+    /// The topic's name, for reports.
+    name: &'static str,
+    stream: Identifier,
+    topic: Identifier,
+    group: Identifier,
+}
+
+impl Target {
+    fn new() -> Self {
+        Self {
+            name: TOPIC_NAME,
+            stream: Identifier::named(STREAM_NAME).expect("valid stream name"),
+            topic: Identifier::named(TOPIC_NAME).expect("valid topic name"),
+            group: Identifier::named(GROUP_NAME).expect("valid group name"),
+        }
+    }
+
+    /// The history topic has no group, so `group` does not apply to it.
+    fn history() -> Self {
+        Self {
+            name: HISTORY_TOPIC_NAME,
+            topic: Identifier::named(HISTORY_TOPIC_NAME).expect("valid topic 
name"),
+            ..Self::new()
+        }
+    }
+}
+
+#[derive(Clone)]
+struct TaskContext {
+    target: Target,
+    log: SharedLog,
+    started: Instant,
+}
+
+/// A consumer identity with stored offsets of its own: the group, or a
+/// standalone consumer. It must receive every acknowledged message of its
+/// partitions, and no node deletes a segment past its stored offset.
+struct Reader {
+    name: String,
+    consumer: Consumer,
+    /// `None` for the group, which reads every partition.
+    partition: Option<u32>,
+    received: Mutex<Received>,
+}
+
+#[derive(Default)]
+struct Received {
+    ids: HashSet<u128>,
+    deliveries: usize,
+}
+
+impl Reader {
+    fn new(name: String, consumer: Consumer, partition: Option<u32>) -> 
Arc<Self> {
+        Arc::new(Self {
+            name,
+            consumer,
+            partition,
+            received: Mutex::default(),
+        })
+    }
+
+    fn reads(&self, partition: u32) -> bool {
+        self.partition.is_none_or(|own| own == partition)
+    }
+
+    /// `acked` holds the acknowledged ids of each partition.
+    fn received_all(&self, acked: &[HashSet<u128>]) -> bool {
+        let received = self.received.lock().expect("received ids lock");
+        (0u32..)
+            .zip(acked)
+            .all(|(partition, ids)| !self.reads(partition) || 
ids.is_subset(&received.ids))
+    }
+}
+
+/// Every offset a reader or a node returned, with the message first read
+/// there. Any later read of the offset must return the same message.
+struct ObservedLog {
+    partitions: Vec<BTreeMap<u64, LogEntry>>,
+    conflicts: Vec<String>,
+}
+
+impl Default for ObservedLog {
+    fn default() -> Self {
+        Self {
+            partitions: (0..PARTITIONS).map(|_| BTreeMap::new()).collect(),
+            conflicts: Vec::new(),
+        }
+    }
+}
+
+impl ObservedLog {
+    fn observe(&mut self, source: &str, partition: u32, entry: LogEntry) {
+        let Some(entries) = self.partitions.get_mut(partition as usize) else {
+            self.conflicts.push(format!(
+                "{source} read partition {partition} of a 
{PARTITIONS}-partition topic"
+            ));
+            return;
+        };
+        match entries.entry(entry.offset) {
+            btree_map::Entry::Vacant(slot) => {
+                slot.insert(entry);
+            }
+            btree_map::Entry::Occupied(slot) if *slot.get() != entry => {
+                self.conflicts.push(format!(
+                    "partition {partition}: {source} read {entry:?}, an 
earlier read got {:?}",
+                    slot.get()
+                ));
+            }
+            btree_map::Entry::Occupied(_) => {}
+        }
+    }
+
+    /// The highest offset read in each partition.
+    fn ends(&self) -> Vec<Option<u64>> {
+        self.partitions
+            .iter()
+            .map(|entries| entries.keys().next_back().copied())
+            .collect()
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ProducerPhase {
+    Send,
+    /// Get the batch in flight acknowledged, then stop.
+    Finish,
+    /// Stop now, even with a batch in flight.
+    Abandon,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ConsumerPhase {
+    /// Members join, poll for a random while, leave, pause and repeat.
+    /// Standalone consumers poll.
+    Consume,
+    /// Keep polling until every reader received and committed everything.
+    Drain,
+    /// Leave the group and stop.
+    Stop,
+}
+
+struct Workload {
+    /// The main topic's producers, then the history topic's.
+    producers: Vec<JoinHandle<ProducerOutcome>>,
+    consumers: Vec<JoinHandle<ConsumerOutcome>>,
+    /// The group's `IggyConsumer`. It stops before the other readers.
+    group_iconsumer: JoinHandle<ConsumerOutcome>,
+    sampler: JoinHandle<SamplerOutcome>,
+    producer_phase: watch::Sender<ProducerPhase>,
+    consumer_phase: watch::Sender<ConsumerPhase>,
+    group_iconsumer_phase: watch::Sender<ConsumerPhase>,
+    readers: Vec<Arc<Reader>>,
+    log: SharedLog,
+    /// Reads the stored offsets that end the drain.
+    observer: IggyClient,
+    started: Instant,
+}
+
+impl Workload {
+    async fn spawn(harness: &TestHarness, seed: u64, started: Instant) -> Self 
{
+        let context = TaskContext {
+            target: Target::new(),
+            log: SharedLog::default(),
+            started,
+        };
+        let history = TaskContext {
+            target: Target::history(),
+            ..context.clone()
+        };
+        let (producer_phase, producer_rx) = 
watch::channel(ProducerPhase::Send);
+        let (consumer_phase, consumer_rx) = 
watch::channel(ConsumerPhase::Consume);
+        let (group_iconsumer_phase, group_iconsumer_rx) = 
watch::channel(ConsumerPhase::Consume);
+        let mut dialer = Dialer { harness, dialed: 0 };
+
+        let mut producers = Vec::with_capacity((PRODUCERS + HISTORY_PRODUCERS) 
as usize);
+        for producer in 0..PRODUCERS + HISTORY_PRODUCERS {
+            let producer_context = if producer < PRODUCERS {
+                &context
+            } else {
+                &history
+            };
+            producers.push(tokio::spawn(run_producer(
+                dialer.connect().await,
+                producer,
+                producer_rx.clone(),
+                producer_context.clone(),
+            )));
+        }
+
+        let mut consumers = Vec::new();
+        let group = Reader::new(
+            "group".to_owned(),
+            Consumer::group(context.target.group.clone()),
+            None,
+        );
+        for member in 0..GROUP_MEMBERS {
+            // Offset from the monkey's own stream of numbers.
+            let rng = 
StdRng::seed_from_u64(seed.wrapping_add(u64::from(member) + 1));
+            consumers.push(tokio::spawn(run_member(
+                dialer.connect().await,
+                member,
+                rng,
+                Arc::clone(&group),
+                consumer_rx.clone(),
+                context.clone(),
+            )));
+        }
+        let client = dialer.connect().await;
+        let builder = client
+            .consumer_group(GROUP_NAME, STREAM_NAME, TOPIC_NAME)
+            .expect("group IggyConsumer builder");
+        let group_iconsumer = tokio::spawn(run_iggy_consumer(
+            client,
+            start_iggy_consumer(builder).await,
+            "group iggy-consumer".to_owned(),
+            Arc::clone(&group),
+            group_iconsumer_rx,
+            context.clone(),
+        ));
+        let mut readers = vec![group];
+
+        for partition in 0..PARTITIONS {
+            let id = FIRST_CONSUMER_ID + partition;
+            let reader = Reader::new(
+                format!("consumer {id}"),
+                Consumer::new(Identifier::numeric(id).expect("valid consumer 
id")),
+                Some(partition),
+            );
+            consumers.push(tokio::spawn(run_consumer(
+                dialer.connect().await,
+                Arc::clone(&reader),
+                consumer_rx.clone(),
+                context.clone(),
+            )));
+            readers.push(reader);
+        }
+        let client = dialer.connect().await;
+        let builder = client
+            .consumer(
+                STANDALONE_ICONSUMER_NAME,
+                STREAM_NAME,
+                TOPIC_NAME,
+                STANDALONE_ICONSUMER_PARTITION,
+            )
+            .expect("standalone IggyConsumer builder");
+        let reader = Reader::new(
+            format!("consumer {STANDALONE_ICONSUMER_NAME}"),
+            
Consumer::new(Identifier::named(STANDALONE_ICONSUMER_NAME).expect("valid 
name")),
+            Some(STANDALONE_ICONSUMER_PARTITION),
+        );
+        consumers.push(tokio::spawn(run_iggy_consumer(
+            client,
+            start_iggy_consumer(builder).await,
+            reader.name.clone(),
+            Arc::clone(&reader),
+            consumer_rx.clone(),
+            context.clone(),
+        )));
+        readers.push(reader);
+
+        let sampler = tokio::spawn(sample_group(
+            dialer.connect().await,
+            consumer_rx,
+            context.clone(),
+        ));
+
+        Self {
+            producers,
+            consumers,
+            group_iconsumer,
+            sampler,
+            producer_phase,
+            consumer_phase,
+            group_iconsumer_phase,
+            readers,
+            log: context.log,
+            observer: dialer.connect().await,
+            started,
+        }
+    }
+
+    /// Stops the producers, waits until every reader received and committed
+    /// everything, then stops the readers.
+    async fn finish(self) -> Outcomes {
+        let Self {
+            producers,
+            consumers,
+            group_iconsumer,
+            sampler,
+            producer_phase,
+            consumer_phase,
+            group_iconsumer_phase,
+            readers,
+            log,
+            observer,
+            started,
+        } = self;
+        println!(
+            "{} chaos phase over, stopping the producers",
+            stamp(started)
+        );
+        producer_phase.send_replace(ProducerPhase::Finish);
+        consumer_phase.send_replace(ConsumerPhase::Drain);
+
+        let deadline = Instant::now() + RECOVERY_TIMEOUT;
+        let mut producer_outcomes = Vec::with_capacity(producers.len());
+        for mut handle in producers {
+            let joined = match timeout_at(deadline, &mut handle).await {
+                Ok(joined) => joined,
+                Err(_) => {
+                    producer_phase.send_replace(ProducerPhase::Abandon);
+                    handle.await
+                }
+            };
+            producer_outcomes.push(joined.expect("producer task panicked"));
+        }
+        // `spawn` starts the history topic's producers after the main topic's.
+        let history_producers = producer_outcomes.split_off(PRODUCERS as 
usize);
+
+        let mut acked = vec![HashSet::new(); PARTITIONS as usize];
+        for producer in &producer_outcomes {
+            acked[producer.partition as 
usize].extend(producer.acked_keys().map(u128::from));
+        }
+        println!("{} producers stopped, draining the readers", stamp(started));
+        let drain_deadline = Instant::now() + DRAIN_TIMEOUT;
+        let undrained = loop {
+            match drain_progress(&observer, &readers, &log, &acked).await {
+                Ok(()) => break None,
+                Err(problem) if Instant::now() >= drain_deadline => break 
Some(problem),
+                Err(_) => sleep(DRAIN_CHECK_INTERVAL).await,
+            }
+        };
+
+        println!("{} drain over, readers stop", stamp(started));
+        // The group's `IggyConsumer` stops before the members leave. Its
+        // shutdown flush stores the last offset it consumed in every partition
+        // it ever held, unless it stored that offset already. A leave can move
+        // such a partition back to it, and the fence then admits the old
+        // offset, so the group offset moves back.
+        group_iconsumer_phase.send_replace(ConsumerPhase::Stop);
+        let mut consumer_outcomes = Vec::with_capacity(consumers.len() + 1);
+        consumer_outcomes.push(group_iconsumer.await.expect("consumer task 
panicked"));
+        consumer_phase.send_replace(ConsumerPhase::Stop);
+        for handle in consumers {
+            consumer_outcomes.push(handle.await.expect("consumer task 
panicked"));
+        }
+        Outcomes {
+            producers: producer_outcomes,
+            history_producers,
+            consumers: consumer_outcomes,
+            sampler: sampler.await.expect("sampler task panicked"),
+            readers,
+            log: std::mem::take(&mut *log.lock().expect("observed log lock")),
+            history_log: ObservedLog::default(),
+            undrained,
+        }
+    }
+}
+
+/// Ok once every reader received every acknowledged message of its
+/// partitions and stored the highest offset read there, otherwise what is
+/// still missing. That offset comes from the readers, not from a node, so a
+/// node that lags behind cannot end the drain early. This is also the only
+/// check of the group's stored offsets, see `http_readers`.
+async fn drain_progress(
+    observer: &IggyClient,
+    readers: &[Arc<Reader>],
+    log: &SharedLog,
+    acked: &[HashSet<u128>],
+) -> Result<(), String> {
+    if let Some(reader) = readers.iter().find(|reader| 
!reader.received_all(acked)) {
+        return Err(format!("{} lacks acknowledged messages", reader.name));
+    }
+    let ends = log.lock().expect("observed log lock").ends();
+    let target = Target::new();
+    for reader in readers {
+        for (partition, end) in (0u32..).zip(ends.iter().copied()) {
+            let Some(end) = end.filter(|_| reader.reads(partition)) else {
+                continue;
+            };
+            let stored = call(observer.get_consumer_offset(
+                &reader.consumer,
+                &target.stream,
+                &target.topic,
+                Some(partition),
+            ))
+            .await;
+            match stored {
+                Ok(Some(offset)) if offset.stored_offset == end => {}
+                Ok(stored) => {
+                    return Err(format!(
+                        "partition {partition}: {} stored offset {:?}, the 
highest offset read \
+                         there is {end}",
+                        reader.name,
+                        stored.map(|offset| offset.stored_offset)
+                    ));
+                }
+                Err(_) => {
+                    return Err(format!(
+                        "partition {partition}: the stored offset of {} cannot 
be read",
+                        reader.name
+                    ));
+                }
+            }
+        }
+    }
+    Ok(())
+}
+
+struct Outcomes {
+    producers: Vec<ProducerOutcome>,
+    history_producers: Vec<ProducerOutcome>,
+    consumers: Vec<ConsumerOutcome>,
+    sampler: SamplerOutcome,
+    readers: Vec<Arc<Reader>>,
+    log: ObservedLog,
+    /// Only the final read fills it, because nothing reads the history topic
+    /// before the end.
+    history_log: ObservedLog,
+    /// Why the drain timed out, if it did.
+    undrained: Option<String>,
+}
+
+impl Outcomes {
+    /// What the workload saw on its own, before any node is read.
+    fn check(&self, violations: &mut Vec<String>) {
+        for producer in self.producers.iter().chain(&self.history_producers) {
+            if producer.sent_batches() > producer.acked_batches {
+                violations.push(format!(
+                    "producer {} got no ack for batch {} within 
{RECOVERY_TIMEOUT:?} after the \
+                     chaos phase",
+                    producer.producer, producer.acked_batches
+                ));
+            }
+            // The server acks a batch without a confirmation when it cannot
+            // resolve the batch's offsets. A producer that never got one
+            // leaves the offset check with nothing to check.
+            if producer.acked_batches > 0 && producer.confirmations.is_empty() 
{
+                violations.push(format!(
+                    "producer {} got no offset confirmation for any of its {} 
acked batches",
+                    producer.producer, producer.acked_batches
+                ));
+            }
+            producer
+                .errors
+                .check(&format!("producer {}", producer.producer), violations);
+        }
+        for consumer in &self.consumers {
+            consumer.errors.check(&consumer.name, violations);
+        }
+        self.sampler.errors.check("the group sampler", violations);
+        violations.extend(self.sampler.violations.iter().cloned());
+        if let Some(problem) = &self.undrained {
+            violations.push(format!(
+                "the readers did not drain within {DRAIN_TIMEOUT:?}: {problem}"
+            ));
+        }
+    }
+}
+
+/// A message id carries its producer and sequence number, so the checks can
+/// account for every message without trusting the payload.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+struct MessageKey {
+    producer: u32,
+    seq: u64,
+}
+
+impl MessageKey {
+    fn batch(self) -> u64 {
+        self.seq / u64::from(PRODUCER_BATCH)
+    }
+
+    fn payload(self) -> Bytes {
+        Bytes::from(format!("{self}"))
+    }
+}
+
+impl fmt::Display for MessageKey {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "producer {} seq {}", self.producer, self.seq)
+    }
+}
+
+/// The high half holds the producer plus one, because the SDK replaces a zero
+/// id with a random one before it sends.
+impl From<MessageKey> for u128 {
+    fn from(key: MessageKey) -> Self {
+        ((u128::from(key.producer) + 1) << 64) | u128::from(key.seq)
+    }
+}
+
+impl TryFrom<u128> for MessageKey {
+    type Error = u128;
+
+    fn try_from(id: u128) -> Result<Self, Self::Error> {
+        let producer = (id >> 64).checked_sub(1).ok_or(id)?;
+        Ok(Self {
+            producer: u32::try_from(producer).map_err(|_| id)?,
+            // The low 64 bits are the sequence number.
+            seq: id as u64,
+        })
+    }
+}
+
+fn batch_keys(producer: u32, batch: u64) -> impl Iterator<Item = MessageKey> {
+    let first = batch * u64::from(PRODUCER_BATCH);
+    (first..first + u64::from(PRODUCER_BATCH)).map(move |seq| MessageKey { 
producer, seq })
+}
+
+/// Why a client call returned no value.
+enum Failure {
+    Iggy(IggyError),
+    TimedOut,
+}
+
+impl fmt::Display for Failure {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Iggy(error) => write!(f, "{error}"),
+            Self::TimedOut => write!(f, "no answer within {CALL_TIMEOUT:?}"),
+        }
+    }
+}
+
+async fn call<T>(request: impl Future<Output = Result<T, IggyError>>) -> 
Result<T, Failure> {
+    match timeout(CALL_TIMEOUT, request).await {
+        Ok(result) => result.map_err(Failure::Iggy),
+        Err(_) => Err(Failure::TimedOut),
+    }
+}
+
+/// Client errors by kind. Failover produces many kinds of errors. The
+/// forbidden ones mean the cluster lost committed state or rejected valid
+/// credentials.
+struct ErrorTally {
+    started: Instant,
+    counts: BTreeMap<&'static str, u64>,
+    forbidden: Vec<String>,
+}
+
+impl ErrorTally {
+    fn new(started: Instant) -> Self {
+        Self {
+            started,
+            counts: BTreeMap::new(),
+            forbidden: Vec::new(),
+        }
+    }
+
+    fn record(&mut self, failure: &Failure) {
+        let kind = match failure {
+            Failure::Iggy(error) => error.as_string(),
+            Failure::TimedOut => CALL_TIMED_OUT,
+        };
+        *self.counts.entry(kind).or_default() += 1;
+        if let Failure::Iggy(error) = failure
+            && is_forbidden(error)
+        {
+            self.forbidden
+                .push(format!("{} {error}", stamp(self.started)));
+        }
+    }
+
+    fn check(&self, name: &str, violations: &mut Vec<String>) {
+        report(
+            violations,
+            &format!("errors at {name} that mean lost state or rejected 
credentials"),
+            &self.forbidden,
+        );
+    }
+}
+
+fn is_forbidden(error: &IggyError) -> bool {
+    matches!(
+        error,
+        IggyError::StreamIdNotFound(_)
+            | IggyError::StreamNameNotFound(_)
+            | IggyError::TopicIdNotFound(..)
+            | IggyError::TopicNameNotFound(..)
+            | IggyError::ConsumerGroupIdNotFound(..)
+            | IggyError::ConsumerGroupNameNotFound(..)
+            | IggyError::InvalidCredentials
+            | IggyError::Unauthorized
+    )
+}
+
+struct ProducerOutcome {
+    producer: u32,
+    partition: u32,
+    /// Batches `0..acked_batches` were acknowledged, in order.
+    acked_batches: u64,
+    /// The send attempts of every batch that was sent, by batch. One entry
+    /// more than `acked_batches` only when the producer gave up on its last
+    /// batch. Every attempt can land, so a batch can appear in the log once
+    /// per attempt.
+    attempts: Vec<usize>,
+    confirmations: Vec<Confirmation>,
+    longest_wait: Duration,
+    errors: ErrorTally,
+}
+
+impl ProducerOutcome {
+    fn sent_batches(&self) -> u64 {
+        self.attempts.len() as u64
+    }
+
+    fn acked_keys(&self) -> impl Iterator<Item = MessageKey> {
+        (0..self.acked_batches).flat_map(|batch| batch_keys(self.producer, 
batch))
+    }
+}
+
+struct Confirmation {
+    batch: u64,
+    partition: u32,
+    base_offset: u64,
+}
+
+/// Sends fixed-size batches to one partition. A failed batch is sent again
+/// with the same message ids until it is acknowledged, so a duplicate can
+/// only come from a batch with a failed attempt.
+async fn run_producer(
+    client: IggyClient,
+    producer: u32,
+    phase: watch::Receiver<ProducerPhase>,
+    context: TaskContext,
+) -> ProducerOutcome {
+    let target = context.target;
+    let partition = producer % PARTITIONS;
+    let partitioning = Partitioning::partition_id(partition);
+    let mut outcome = ProducerOutcome {
+        producer,
+        partition,
+        acked_batches: 0,
+        attempts: Vec::new(),
+        confirmations: Vec::new(),
+        longest_wait: Duration::ZERO,
+        errors: ErrorTally::new(context.started),
+    };
+    while *phase.borrow() == ProducerPhase::Send {
+        let batch = outcome.sent_batches();
+        let mut messages: Vec<IggyMessage> = batch_keys(producer, batch)
+            .map(|key| {
+                IggyMessage::builder()
+                    .id(u128::from(key))
+                    .payload(key.payload())
+                    .build()
+                    .expect("valid message")
+            })
+            .collect();
+        let first_attempt = Instant::now();
+        let mut attempts = 0;
+        let acked =
+            loop {
+                attempts += 1;
+                let sent = call(client.send_messages(
+                    &target.stream,
+                    &target.topic,
+                    &partitioning,
+                    &mut messages,
+                ))
+                .await;
+                match sent {
+                    Ok(response) => {
+                        outcome
+                            .confirmations
+                            
.extend(response.confirmations.iter().map(|confirmation| {
+                                Confirmation {
+                                    batch,
+                                    partition: confirmation.partition_id,
+                                    base_offset: confirmation.base_offset,
+                                }
+                            }));
+                        break true;
+                    }
+                    Err(failure) => {
+                        outcome.errors.record(&failure);
+                        if *phase.borrow() == ProducerPhase::Abandon {
+                            break false;
+                        }
+                        sleep(RETRY_PAUSE).await;
+                    }
+                }
+            };
+        outcome.attempts.push(attempts);
+        if !acked {
+            return outcome;
+        }
+        outcome.acked_batches += 1;
+        outcome.longest_wait = 
outcome.longest_wait.max(first_attempt.elapsed());
+        sleep(PRODUCER_PAUSE).await;
+    }
+    outcome
+}
+
+struct ConsumerOutcome {
+    name: String,
+    deliveries: usize,
+    /// `None` for a task that never joins or leaves by itself: a standalone
+    /// consumer, or the group's `IggyConsumer`, whose SDK does it.
+    churn: Option<Churn>,
+    errors: ErrorTally,
+}
+
+struct Churn {
+    joins: u64,
+    leaves: u64,
+}
+
+impl ConsumerOutcome {
+    fn new(name: String, started: Instant) -> Self {
+        Self {
+            name,
+            deliveries: 0,
+            churn: None,
+            errors: ErrorTally::new(started),
+        }
+    }
+
+    /// Low-level consumers record a message before they commit it, and the
+    /// `IggyConsumer` commits only what it handed over, so a lost commit can
+    /// only cause a redelivery, never a gap.
+    fn record<'a>(
+        &mut self,
+        reader: &Reader,
+        partition: u32,
+        messages: impl IntoIterator<Item = &'a IggyMessage>,
+        log: &SharedLog,
+    ) {
+        let mut received = reader.received.lock().expect("received ids lock");
+        let mut log = log.lock().expect("observed log lock");
+        for message in messages {
+            self.deliveries += 1;
+            received.deliveries += 1;
+            received.ids.insert(message.header.id);
+            log.observe(&self.name, partition, LogEntry::from(message));
+        }
+    }
+}
+
+/// Polls the reader's next batch and stores its last offset.
+async fn poll_and_commit(
+    client: &IggyClient,
+    reader: &Reader,
+    outcome: &mut ConsumerOutcome,
+    context: &TaskContext,
+) -> Result<(), Failure> {
+    let target = &context.target;
+    let polled = call(client.poll_messages(
+        &target.stream,
+        &target.topic,
+        reader.partition,
+        &reader.consumer,
+        &PollingStrategy::next(),
+        POLL_BATCH,
+        false,
+    ))
+    .await?;
+    let Some(last) = polled.messages.last().map(|message| 
message.header.offset) else {
+        sleep(POLL_PAUSE).await;
+        return Ok(());
+    };
+    outcome.record(reader, polled.partition_id, &polled.messages, 
&context.log);
+    let stored = call(client.store_consumer_offset(
+        &reader.consumer,
+        &target.stream,
+        &target.topic,
+        Some(polled.partition_id),
+        last,
+    ))
+    .await;
+    if let Err(failure) = stored {
+        outcome.errors.record(&failure);
+    }
+    Ok(())
+}
+
+/// Joins the group, polls and commits for a random while, leaves, pauses and
+/// joins again. In the drain phase it stays joined until told to stop.
+async fn run_member(
+    client: IggyClient,
+    member: u32,
+    mut rng: StdRng,
+    group: Arc<Reader>,
+    phase: watch::Receiver<ConsumerPhase>,
+    context: TaskContext,
+) -> ConsumerOutcome {
+    let target = &context.target;
+    let mut outcome = ConsumerOutcome::new(format!("member {member}"), 
context.started);
+    let mut churn = Churn {
+        joins: 0,
+        leaves: 0,
+    };
+    while *phase.borrow() != ConsumerPhase::Stop {
+        let joined =
+            call(client.join_consumer_group(&target.stream, &target.topic, 
&target.group)).await;
+        if let Err(failure) = joined {
+            outcome.errors.record(&failure);
+            sleep(RETRY_PAUSE).await;
+            continue;
+        }
+        churn.joins += 1;
+        let session_end =
+            Instant::now() + 
Duration::from_millis(rng.random_range(MEMBER_SESSION_MS));
+        loop {
+            let current = *phase.borrow();
+            if current == ConsumerPhase::Stop
+                || (current == ConsumerPhase::Consume && Instant::now() >= 
session_end)
+            {
+                break;
+            }
+            if let Err(failure) = poll_and_commit(&client, &group, &mut 
outcome, &context).await {
+                let lost_membership = matches!(
+                    failure,
+                    Failure::Iggy(IggyError::ConsumerGroupMemberNotFound(..))
+                );
+                outcome.errors.record(&failure);
+                if lost_membership {
+                    break;
+                }
+                sleep(RETRY_PAUSE).await;
+            }
+        }
+        match call(client.leave_consumer_group(&target.stream, &target.topic, 
&target.group)).await
+        {
+            Ok(()) => churn.leaves += 1,
+            Err(failure) => outcome.errors.record(&failure),
+        }
+        if *phase.borrow() == ConsumerPhase::Consume {
+            
sleep(Duration::from_millis(rng.random_range(MEMBER_PAUSE_MS))).await;
+        }
+    }
+    outcome.churn = Some(churn);
+    outcome
+}
+
+/// Reads one partition from the consumer's own stored offset until told to
+/// stop.
+async fn run_consumer(
+    client: IggyClient,
+    reader: Arc<Reader>,
+    phase: watch::Receiver<ConsumerPhase>,
+    context: TaskContext,
+) -> ConsumerOutcome {
+    let mut outcome = ConsumerOutcome::new(reader.name.clone(), 
context.started);
+    while *phase.borrow() != ConsumerPhase::Stop {
+        if let Err(failure) = poll_and_commit(&client, &reader, &mut outcome, 
&context).await {
+            outcome.errors.record(&failure);
+            sleep(RETRY_PAUSE).await;
+        }
+    }
+    outcome
+}
+
+async fn start_iggy_consumer(builder: IggyConsumerBuilder) -> IggyConsumer {
+    let commit_interval =
+        NonZeroIggyDuration::new(ICONSUMER_COMMIT_INTERVAL).expect("non-zero 
commit interval");
+    let mut consumer = builder
+        // Commits only what was handed over. The default commits with the
+        // poll and can skip a batch whose reply was lost, which would break
+        // the no-loss check.
+        .auto_commit(AutoCommit::IntervalOrWhen(
+            commit_interval,
+            AutoCommitWhen::ConsumingAllMessages,
+        ))
+        .batch_length(POLL_BATCH)
+        .poll_interval(IggyDuration::from(POLL_PAUSE))
+        .build();
+    consumer.init().await.expect("init the IggyConsumer");
+    consumer
+}
+
+/// The group's `IggyConsumer` joins once and relies on the SDK to rejoin
+/// after every reconnect. The standalone one reads its partition.
+async fn run_iggy_consumer(
+    // Dropping the client aborts its heartbeat, and the session then expires.
+    _client: IggyClient,
+    mut consumer: IggyConsumer,
+    name: String,
+    reader: Arc<Reader>,
+    mut phase: watch::Receiver<ConsumerPhase>,
+    context: TaskContext,
+) -> ConsumerOutcome {
+    let mut outcome = ConsumerOutcome::new(name, context.started);
+    loop {
+        tokio::select! {
+            changed = phase.changed() => {
+                if changed.is_err() || *phase.borrow() == ConsumerPhase::Stop {
+                    break;
+                }
+            }
+            next = consumer.next() => match next {
+                Some(Ok(received)) => {
+                    outcome.record(
+                        &reader,
+                        received.partition_id,
+                        [&received.message],
+                        &context.log,
+                    );
+                }
+                Some(Err(error)) => 
outcome.errors.record(&Failure::Iggy(error)),
+                None => break,
+            },
+        }
+    }
+    if let Err(error) = consumer.shutdown().await {
+        outcome.errors.record(&Failure::Iggy(error));
+    }
+    outcome
+}
+
+struct SamplerOutcome {
+    samples: u64,
+    errors: ErrorTally,
+    violations: Vec<String>,
+}
+
+/// Reads the group once a second. A committed assignment never gives one
+/// partition to two members, even mid-handoff: a partition moves from its
+/// source to its target in one step.
+async fn sample_group(
+    client: IggyClient,
+    phase: watch::Receiver<ConsumerPhase>,
+    context: TaskContext,
+) -> SamplerOutcome {
+    let TaskContext {
+        target, started, ..
+    } = context;
+    let mut outcome = SamplerOutcome {
+        samples: 0,
+        errors: ErrorTally::new(started),
+        violations: Vec::new(),
+    };
+    while *phase.borrow() != ConsumerPhase::Stop {
+        match call(client.get_consumer_group(&target.stream, &target.topic, 
&target.group)).await {
+            Ok(Some(group)) => {
+                outcome.samples += 1;
+                if let Err(problem) = check_assignment(&group) {
+                    outcome
+                        .violations
+                        .push(format!("{} {problem}", stamp(started)));
+                }
+            }
+            Ok(None) => outcome.violations.push(format!(
+                "{} consumer group {GROUP_NAME} is gone",
+                stamp(started)
+            )),
+            Err(failure) => outcome.errors.record(&failure),
+        }
+        sleep(GROUP_SAMPLE_INTERVAL).await;
+    }
+    outcome
+}
+
+fn check_assignment(group: &ConsumerGroupDetails) -> Result<(), String> {
+    let mut owners = HashMap::new();
+    for member in &group.members {
+        for &partition in &member.partitions {
+            if partition >= PARTITIONS {
+                return Err(format!(
+                    "member {} holds partition {partition} of a 
{PARTITIONS}-partition topic",
+                    member.id
+                ));
+            }
+            if let Some(other) = owners.insert(partition, member.id) {
+                return Err(format!(
+                    "partition {partition} is held by members {other} and {}",
+                    member.id
+                ));
+            }
+        }
+    }
+    Ok(())
+}
+
+#[derive(Default)]
+struct KillStats {
+    kills: u32,
+    /// Kills of a node that named itself leader right before it died.
+    leader_kills: u32,
+}
+
+/// Kills a random node every kill interval and restarts it after the
+/// downtime. Prints each kill and restart as it happens, so a hung run shows
+/// where it stopped.
+async fn run_monkey(
+    harness: &mut TestHarness,
+    config: &ChaosConfig,
+    started: Instant,
+    violations: &mut Vec<String>,
+    report: &mut Report,
+) -> KillStats {
+    let mut rng = StdRng::seed_from_u64(config.seed);
+    let chaos_end = started + config.duration;
+    let mut stats = KillStats::default();
+    let mut next_kill = started + config.kill_interval;
+    while next_kill + config.downtime <= chaos_end {
+        if !watch_nodes(harness, next_kill, None, started, violations, 
report).await {
+            return stats;
+        }
+        let victim = rng.random_range(0..CLUSTER_NODES);
+        let leader = leader_seen_by(harness, victim).await;
+        let pid = harness.node(victim).pid();
+        report
+            .record(
+                "kill_requested",
+                json!({
+                    "node": victim, "pid": pid,
+                    "scheduled_elapsed_ms": 
next_kill.duration_since(started).as_millis(),
+                    "chaos_elapsed_ms": started.elapsed().as_millis(),
+                    "leader": leader.as_ref().ok(), "leader_error": 
leader.as_ref().err(),
+                }),
+            )
+            .expect("write kill request");
+        harness.kill_node(victim).expect("SIGKILL a node");
+        report
+            .record("killed", json!({"node": victim, "pid": pid}))
+            .expect("write kill result");
+        stats.kills += 1;
+        if leader == Ok(victim) {
+            stats.leader_kills += 1;
+        }
+        println!(
+            "{} kill node {victim} ({})",
+            stamp(started),
+            describe_leader(victim, &leader)
+        );
+        let back_at = Instant::now() + config.downtime;
+        if !watch_nodes(harness, back_at, Some(victim), started, violations, 
report).await {
+            return stats;
+        }
+        // A panic before the kill shows only in this incarnation's stderr,
+        // which the restart truncates.
+        if let Some(stderr) = panic_report(harness, victim) {
+            violations.push(format!(
+                "{} node {victim} panicked before it was killed:\n{stderr}",
+                stamp(started)
+            ));
+        }
+        let restarting = Instant::now();
+        if !restart(harness, victim, started, violations, report) {
+            return stats;
+        }
+        println!(
+            "{} restart node {victim} (up after {:.1}s)",
+            stamp(started),
+            restarting.elapsed().as_secs_f64()
+        );
+        next_kill += config.kill_interval;
+    }
+    watch_nodes(harness, chaos_end, None, started, violations, report).await;
+    stats
+}
+
+/// Sleeps until `until`, checking every node except `down` once a second. A
+/// node that died on its own is a violation. It is restarted, so the run
+/// keeps its fault budget. Returns false when a node cannot be restarted.
+async fn watch_nodes(
+    harness: &mut TestHarness,
+    until: Instant,
+    down: Option<usize>,
+    started: Instant,
+    violations: &mut Vec<String>,
+    report: &mut Report,
+) -> bool {
+    loop {
+        for node in (0..CLUSTER_NODES).filter(|&node| Some(node) != down) {
+            if harness.node(node).is_running() {
+                continue;
+            }
+            let (_, stderr) = harness.node(node).collect_logs();
+            report
+                .record(
+                    "unexpected_exit",
+                    json!({
+                        "node": node,
+                        "pid": harness.node(node).pid(),
+                        "stderr_tail": tail(&stderr),
+                    }),
+                )
+                .expect("write unexpected exit");
+            violations.push(format!(
+                "{} node {node} exited without being killed:\n{}",
+                stamp(started),
+                tail(&stderr)
+            ));
+            if !restart(harness, node, started, violations, report) {
+                return false;
+            }
+        }
+        let now = Instant::now();
+        if now >= until {
+            return true;
+        }
+        sleep((until - now).min(HEALTH_CHECK_INTERVAL)).await;
+    }
+}
+
+fn restart(
+    harness: &mut TestHarness,
+    node: usize,
+    started: Instant,
+    violations: &mut Vec<String>,
+    report: &mut Report,
+) -> bool {
+    report
+        .record("restart_requested", json!({"node": node}))
+        .expect("write restart request");
+    match harness.restart_node(node) {
+        Ok(()) => {
+            report
+                .record(
+                    "restarted",
+                    json!({"node": node, "pid": harness.node(node).pid()}),
+                )
+                .expect("write restart result");
+            true
+        }
+        Err(error) => {
+            report
+                .record(
+                    "restart_failed",
+                    json!({"node": node, "error": error.to_string()}),
+                )
+                .expect("write restart failure");
+            violations.push(format!(
+                "{} node {node} failed to restart: {error}",
+                stamp(started)
+            ));
+            false
+        }
+    }
+}
+
+/// The leader as `node` sees it, asked over a fresh HTTP client. A long-lived
+/// TCP client does not work here: the SDK never reconnects for a metadata
+/// call, so the client stays disconnected once its node dies.
+async fn leader_seen_by(harness: &TestHarness, node: usize) -> Result<usize, 
String> {
+    let lookup = async {
+        let client = connect_http(harness, node)
+            .await
+            .map_err(|error| error.to_string())?;
+        client
+            .get_cluster_metadata()
+            .await
+            .map_err(|error| error.to_string())
+    };
+    let metadata = timeout(LEADER_LOOKUP_TIMEOUT, lookup)
+        .await
+        .map_err(|_| "lookup timed out".to_owned())??;
+    let leader = metadata
+        .nodes
+        .iter()
+        .find(|node| node.role == ClusterNodeRole::Leader)
+        .ok_or_else(|| "no node is marked leader".to_owned())?;
+    (0..CLUSTER_NODES)
+        .find(|&index| {
+            harness
+                .node(index)
+                .tcp_addr()
+                .is_some_and(|address| address.port() == leader.endpoints.tcp)
+        })
+        .ok_or_else(|| format!("leader port {} belongs to no node", 
leader.endpoints.tcp))
+}
+
+fn describe_leader(victim: usize, leader: &Result<usize, String>) -> String {
+    match leader {
+        Ok(leader) if *leader == victim => "the leader".to_owned(),
+        Ok(leader) => format!("leader is node {leader}"),
+        Err(reason) => format!("leader unknown: {reason}"),
+    }
+}
+
+/// One HTTP client per node that answers, with the node's index. HTTP reads
+/// default to `serializable`, which a node serves from its own state instead
+/// of redirecting to the leader. A node without a client is a violation, and
+/// the checks go on with the other nodes, so the report still prints.
+async fn connect_verifiers(
+    harness: &TestHarness,
+    violations: &mut Vec<String>,
+) -> Vec<(usize, IggyClient)> {
+    let mut verifiers = Vec::with_capacity(CLUSTER_NODES);
+    for node in 0..CLUSTER_NODES {
+        match timeout(CALL_TIMEOUT, connect_http(harness, node)).await {
+            Ok(Ok(client)) => verifiers.push((node, client)),
+            Ok(Err(error)) => violations.push(format!("node {node}: no HTTP 
verifier: {error}")),
+            Err(_) => violations.push(format!(
+                "node {node}: no HTTP verifier within {CALL_TIMEOUT:?}"
+            )),
+        }
+    }
+    verifiers
+}
+
+async fn connect_http(harness: &TestHarness, node: usize) -> 
Result<IggyClient, TestBinaryError> {
+    harness
+        .node(node)
+        .http_client()?
+        .with_root_login()
+        .connect()
+        .await
+}
+
+/// A node's own answer about the topic, the stored offsets and the group.
+#[derive(Debug, PartialEq, Eq, Serialize)]
+struct NodeView {
+    /// `None` when the node does not know the topic.
+    partitions: Option<Vec<PartitionView>>,
+    /// The history topic's partitions, `None` when the node does not know it.
+    history: Option<Vec<PartitionView>>,
+    /// `(member id, partitions)`, or `None` when the node does not know the
+    /// group.
+    members: Option<Vec<(u32, Vec<u32>)>>,
+}
+
+#[derive(Debug, PartialEq, Eq, Serialize)]
+struct PartitionView {
+    current_offset: u64,
+    messages_count: u64,
+    segments_count: u32,
+    /// What each of the partition's `http_readers` stored, in reader order.
+    stored_offsets: Vec<Option<u64>>,
+}
+
+/// The readers of `partition` whose stored offset a node reports over HTTP.
+/// The group is not one of them: HTTP drops `Consumer::kind`, so the read
+/// would name a standalone consumer called like the group. The drain reads
+/// the group's offsets over TCP instead.
+fn http_readers(readers: &[Arc<Reader>], partition: u32) -> impl Iterator<Item 
= &Arc<Reader>> {
+    readers.iter().filter(move |reader| {
+        reader.consumer.kind == ConsumerKind::Consumer && 
reader.reads(partition)
+    })
+}
+
+async fn node_view(client: &IggyClient, readers: &[Arc<Reader>]) -> 
Result<NodeView, Failure> {
+    let target = Target::new();
+    let members = call(client.get_consumer_group(&target.stream, 
&target.topic, &target.group))
+        .await?
+        .map(|group| {
+            let mut members: Vec<(u32, Vec<u32>)> = group
+                .members
+                .into_iter()
+                .map(|member| {
+                    let mut partitions = member.partitions;
+                    partitions.sort_unstable();
+                    (member.id, partitions)
+                })
+                .collect();
+            members.sort_unstable();
+            members
+        });
+    Ok(NodeView {
+        partitions: partition_views(client, &target, readers).await?,
+        history: partition_views(client, &Target::history(), &[]).await?,
+        members,
+    })
+}
+
+/// A node's own answer about one topic and the stored offsets of the readers
+/// of each partition.
+async fn partition_views(
+    client: &IggyClient,
+    target: &Target,
+    readers: &[Arc<Reader>],
+) -> Result<Option<Vec<PartitionView>>, Failure> {
+    let Some(topic) = call(client.get_topic(&target.stream, 
&target.topic)).await? else {
+        return Ok(None);
+    };
+    let mut partitions = Vec::with_capacity(topic.partitions.len());
+    for partition in topic.partitions {
+        let mut stored_offsets = Vec::new();
+        for reader in http_readers(readers, partition.id) {
+            let stored = call(client.get_consumer_offset(
+                &reader.consumer,
+                &target.stream,
+                &target.topic,
+                Some(partition.id),
+            ))
+            .await?;
+            stored_offsets.push(stored.map(|offset| offset.stored_offset));
+        }
+        partitions.push(PartitionView {
+            current_offset: partition.current_offset,
+            messages_count: partition.messages_count,
+            segments_count: partition.segments_count,
+            stored_offsets,
+        });
+    }
+    Ok(Some(partitions))
+}
+
+/// Waits until the nodes reach the end state that `end_state_problems`
+/// describes. Returns the first node's view for the log checks.
+async fn wait_for_convergence(
+    verifiers: &[(usize, IggyClient)],
+    readers: &[Arc<Reader>],
+    started: Instant,
+    violations: &mut Vec<String>,
+    report: &mut Report,
+) -> Option<NodeView> {
+    let deadline = Instant::now() + CONVERGENCE_TIMEOUT;
+    loop {
+        let mut views = Vec::with_capacity(verifiers.len());
+        for (node, verifier) in verifiers {
+            views.push((*node, node_view(verifier, readers).await));
+        }
+        let mut problems = end_state_problems(&views, readers);
+        if problems.is_empty() {
+            if verifiers.len() == CLUSTER_NODES {
+                println!("{} nodes converged", stamp(started));
+            }
+        } else if Instant::now() >= deadline {
+            violations.push(format!(
+                "nodes did not reach the end state within 
{CONVERGENCE_TIMEOUT:?}:\n{}",
+                problems.join("\n")
+            ));
+        } else {
+            sleep(CONVERGENCE_RETRY).await;
+            continue;
+        }
+        if verifiers.len() != CLUSTER_NODES {
+            problems.push(format!(
+                "only {} of {CLUSTER_NODES} nodes have HTTP verifiers",
+                verifiers.len()
+            ));
+        }
+        for (node, view) in &views {
+            report
+                .record(
+                    "node_view",
+                    json!({
+                        "node": node,
+                        "view": view.as_ref().ok(),
+                        "error": view.as_ref().err().map(ToString::to_string),
+                    }),
+                )
+                .expect("write node view");
+        }
+        report
+            .record(
+                "check",
+                json!({
+                    "name": "convergence",
+                    "outcome": if problems.is_empty() { "passed" } else { 
"failed" },
+                    "nodes": verifiers.len(),
+                    "expected_nodes": CLUSTER_NODES,
+                    "violations": problems,
+                }),
+            )
+            .expect("write convergence result");
+        return views.into_iter().find_map(|(_, view)| view.ok());
+    }
+}
+
+/// What keeps the nodes from the end state. Every node answers the same, and
+/// both topics exist. The group is empty: every member left, and the members
+/// stranded on killed nodes expired. Every standalone reader stored the end
+/// of its partitions. Each partition of the main topic keeps only its active
+/// segment, because every sealed one expired.
+fn end_state_problems(
+    views: &[(usize, Result<NodeView, Failure>)],
+    readers: &[Arc<Reader>],
+) -> Vec<String> {
+    let mut problems = Vec::new();
+    let mut reference: Option<(usize, &NodeView)> = None;
+    for (node, view) in views {
+        match (view, reference) {
+            (Err(failure), _) => problems.push(format!("node {node} cannot be 
read: {failure}")),
+            (Ok(view), None) => reference = Some((*node, view)),
+            (Ok(view), Some((first, expected))) => {
+                if view != expected {
+                    problems.push(format!("node {node}: {view:?}, node 
{first}: {expected:?}"));
+                }
+            }
+        }
+    }
+    let Some((_, view)) = reference else {
+        return problems;
+    };
+    if view.history.is_none() {
+        problems.push(format!("topic {HISTORY_TOPIC_NAME} is gone"));
+    }
+    match &view.members {
+        None => problems.push(format!("consumer group {GROUP_NAME} is gone")),
+        Some(members) if !members.is_empty() => {
+            problems.push(format!("the group still has members {members:?}"));
+        }
+        Some(_) => {}
+    }
+    let Some(partitions) = &view.partitions else {
+        problems.push(format!("topic {TOPIC_NAME} is gone"));
+        return problems;
+    };
+    for (partition, view) in (0u32..).zip(partitions) {
+        for (reader, stored) in http_readers(readers, 
partition).zip(&view.stored_offsets) {
+            if *stored != Some(view.current_offset) {
+                problems.push(format!(
+                    "partition {partition}: {} stored offset {stored:?}, the 
partition ends at {}",
+                    reader.name, view.current_offset
+                ));
+            }
+        }
+        if view.segments_count != 1 {
+            problems.push(format!(
+                "partition {partition} keeps {} segments, but expiry leaves 
only the active one",
+                view.segments_count
+            ));
+        }
+    }
+    problems
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct LogEntry {
+    offset: u64,
+    id: u128,
+    payload: Bytes,
+}
+
+impl From<&IggyMessage> for LogEntry {
+    fn from(message: &IggyMessage) -> Self {
+        Self {
+            offset: message.header.offset,
+            id: message.header.id,
+            payload: message.payload.clone(),
+        }
+    }
+}
+
+/// Reads what a node retains of each partition. The read from offset 0
+/// starts at the oldest message that expiry left.
+async fn read_log(client: &IggyClient, target: &Target) -> 
Result<Vec<Vec<LogEntry>>, Failure> {
+    let consumer = Consumer::new(
+        Identifier::numeric(VERIFIER_CONSUMER_ID).expect("valid verifier 
consumer id"),
+    );
+    let mut log = Vec::with_capacity(PARTITIONS as usize);
+    for partition in 0..PARTITIONS {
+        let mut entries: Vec<LogEntry> = Vec::new();
+        loop {
+            let next = entries.last().map_or(0, |entry| entry.offset + 1);
+            let polled = call(client.poll_messages(
+                &target.stream,
+                &target.topic,
+                Some(partition),
+                &consumer,
+                &PollingStrategy::offset(next),
+                VERIFY_BATCH,
+                false,
+            ))
+            .await?;
+            // A reply that does not move forward ends the read. The offset
+            // check reports what it returned.
+            let progressed = polled
+                .messages
+                .last()
+                .is_some_and(|message| message.header.offset >= next);
+            entries.extend(polled.messages.iter().map(LogEntry::from));
+            if !progressed {
+                break;
+            }
+        }
+        log.push(entries);
+    }
+    Ok(log)
+}
+
+/// Reads both topics from every node and requires the nodes to agree. What
+/// they retain joins each topic's observed log, which is then checked against
+/// what the producers sent and what every reader received.
+async fn verify_logs(
+    verifiers: &[(usize, IggyClient)],
+    view: Option<&NodeView>,
+    outcomes: &mut Outcomes,
+    violations: &mut Vec<String>,
+    report: &mut Report,
+) -> [LogStats; 2] {
+    let partitions = view.and_then(|view| view.partitions.as_deref());
+    let history = view.and_then(|view| view.history.as_deref());
+    read_retained(
+        verifiers,
+        &Target::new(),
+        partitions,
+        &mut outcomes.log,
+        violations,
+        report,
+    )
+    .await;
+    read_retained(
+        verifiers,
+        &Target::history(),
+        history,
+        &mut outcomes.history_log,
+        violations,
+        report,
+    )
+    .await;
+    if let Some(partitions) = partitions
+        && partitions
+            .iter()
+            .all(|partition| partition.messages_count == 
partition.current_offset + 1)
+    {
+        violations.push(format!(
+            "expiry deleted no segment of {TOPIC_NAME}, so the run did not 
test expiry"
+        ));
+    }
+    [
+        check_log(
+            TOPIC_NAME,
+            &outcomes.log,
+            &outcomes.producers,
+            &outcomes.readers,
+            partitions,
+            violations,
+        ),
+        check_log(
+            HISTORY_TOPIC_NAME,
+            &outcomes.history_log,
+            &outcomes.history_producers,
+            &[],
+            history,
+            violations,
+        ),
+    ]
+}
+
+/// Reads what every node retains of one topic and requires the nodes to
+/// agree. The first copy that a node returned joins the topic's observed log.
+async fn read_retained(
+    verifiers: &[(usize, IggyClient)],
+    target: &Target,
+    partitions: Option<&[PartitionView]>,
+    log: &mut ObservedLog,
+    violations: &mut Vec<String>,
+    report: &mut Report,
+) {
+    let topic = target.name;
+    let mut reference = None;
+    for (node, verifier) in verifiers {
+        let retained = match read_log(verifier, target).await {
+            Ok(retained) => retained,
+            Err(failure) => {
+                violations.push(format!("node {node}: cannot read {topic}: 
{failure}"));
+                continue;
+            }
+        };
+        for (partition, entries) in retained.iter().enumerate() {
+            report
+                .record(
+                    "retained_range",
+                    json!({
+                        "node": node, "topic": topic, "partition": partition,
+                        "messages_count": entries.len(),
+                        "first_offset": entries.first().map(|entry| 
entry.offset),
+                        "last_offset": entries.last().map(|entry| 
entry.offset),
+                    }),
+                )
+                .expect("write retained range");
+        }
+        match &reference {
+            None => reference = Some(retained),
+            Some(reference) => compare_logs(topic, *node, reference, 
&retained, violations),
+        }
+    }
+    let Some(retained) = reference else {
+        return;
+    };
+    if let Some(partitions) = partitions {
+        check_retained(topic, &retained, partitions, violations);
+    }
+    for (partition, entries) in (0u32..).zip(retained) {
+        for entry in entries {
+            log.observe("the final read", partition, entry);
+        }
+    }
+}
+
+fn compare_logs(
+    topic: &str,
+    node: usize,
+    reference: &[Vec<LogEntry>],
+    log: &[Vec<LogEntry>],
+    violations: &mut Vec<String>,
+) {
+    for (partition, (expected, actual)) in 
reference.iter().zip(log).enumerate() {
+        let longest = expected.len().max(actual.len());
+        if let Some(position) = (0..longest).find(|&i| expected.get(i) != 
actual.get(i)) {
+            violations.push(format!(
+                "{topic} partition {partition}: node {node} holds {} messages, 
the first node \
+                 read holds {}; first difference at position {position}: {:?} 
vs {:?}",
+                actual.len(),
+                expected.len(),
+                actual.get(position),
+                expected.get(position)
+            ));
+        }
+    }
+}
+
+/// After expiry a node keeps the messages from its oldest retained one
+/// through the end of the partition, without a gap, as many as it reports.
+fn check_retained(
+    topic: &str,
+    log: &[Vec<LogEntry>],
+    partitions: &[PartitionView],
+    violations: &mut Vec<String>,
+) {
+    for (partition, (entries, view)) in 
(0u32..).zip(log.iter().zip(partitions)) {
+        let first = (view.current_offset + 
1).saturating_sub(view.messages_count);
+        if !entries
+            .iter()
+            .map(|entry| entry.offset)
+            .eq(first..=view.current_offset)
+        {
+            violations.push(format!(
+                "{topic} partition {partition}: the nodes retain {} messages 
from offset {:?}, \
+                 but report {} messages up to offset {}",
+                entries.len(),
+                entries.first().map(|entry| entry.offset),
+                view.messages_count,
+                view.current_offset
+            ));
+        }
+    }
+}
+
+struct LogStats {
+    topic: &'static str,
+    messages: usize,
+    duplicates: usize,
+}
+
+fn check_log(
+    topic: &'static str,
+    log: &ObservedLog,
+    producers: &[ProducerOutcome],
+    readers: &[Arc<Reader>],
+    partitions: Option<&[PartitionView]>,
+    violations: &mut Vec<String>,
+) -> LogStats {
+    report(
+        violations,
+        &format!("{topic}: reads contradict the topic or an earlier read of 
the same offset"),
+        &log.conflicts,
+    );
+
+    let mut occurrences: HashMap<u128, usize> = HashMap::new();
+    let mut last_first_seq: HashMap<u32, u64> = HashMap::new();
+    let mut unread = Vec::new();
+    let mut broken = Vec::new();
+    for (partition, entries) in (0u32..).zip(&log.partitions) {
+        let end = partitions
+            .and_then(|views| views.get(partition as usize))
+            .map(|view| view.current_offset);
+        let mut next = 0;
+        for (&offset, entry) in entries {
+            if offset > next {
+                unread.push(format!(
+                    "partition {partition}: offsets {next}..={}",
+                    offset - 1
+                ));
+            }
+            next = offset + 1;
+            if end.is_some_and(|end| offset > end) {
+                broken.push(format!(
+                    "partition {partition}: offset {offset} lies past the end 
of the partition"
+                ));
+            }
+            let occurrence = occurrences.entry(entry.id).or_default();
+            *occurrence += 1;
+            if let Err(problem) = check_entry(
+                entry,
+                partition,
+                *occurrence,
+                producers,
+                &mut last_first_seq,
+            ) {
+                broken.push(problem);
+            }
+        }
+        if let Some(end) = end
+            && next <= end
+        {
+            unread.push(format!("partition {partition}: offsets 
{next}..={end}"));
+        }
+    }
+    report(
+        violations,
+        &format!("{topic}: offset ranges that no reader and no node returned"),
+        &unread,
+    );
+    report(
+        violations,
+        &format!("{topic}: log entries break the producer contract"),
+        &broken,
+    );
+
+    // A batch lands whole, so every message of a batch has as many copies as
+    // the others.
+    let mut partial = Vec::new();
+    for producer in producers {
+        for batch in 0..producer.sent_batches() {
+            let copies: Vec<usize> = batch_keys(producer.producer, batch)
+                .map(|key| {
+                    occurrences
+                        .get(&u128::from(key))
+                        .copied()
+                        .unwrap_or_default()
+                })
+                .collect();
+            if copies.iter().min() != copies.iter().max() {
+                partial.push(format!(
+                    "producer {} batch {batch}: copies per message {copies:?}",
+                    producer.producer
+                ));
+            }
+        }
+    }
+    report(
+        violations,
+        &format!("{topic}: batches landed only in part"),
+        &partial,
+    );
+
+    let missing: Vec<String> = producers
+        .iter()
+        .flat_map(ProducerOutcome::acked_keys)
+        .filter(|key| !occurrences.contains_key(&u128::from(*key)))
+        .map(|key| key.to_string())
+        .collect();
+    report(
+        violations,
+        &format!("{topic}: acknowledged messages are missing"),
+        &missing,
+    );
+
+    let misplaced: Vec<String> = producers
+        .iter()
+        .flat_map(|producer| {
+            producer
+                .confirmations
+                .iter()
+                .filter(|confirmation| !confirmation_matches(log, 
producer.producer, confirmation))
+                .map(|confirmation| {
+                    format!(
+                        "producer {} batch {} acked at partition {} offset {}",
+                        producer.producer,
+                        confirmation.batch,
+                        confirmation.partition,
+                        confirmation.base_offset
+                    )
+                })
+        })
+        .collect();
+    report(
+        violations,
+        &format!("{topic}: acknowledgements point at the wrong offsets"),
+        &misplaced,
+    );
+
+    for reader in readers {
+        let received = reader.received.lock().expect("received ids lock");
+        let undelivered: Vec<String> = producers
+            .iter()
+            .filter(|producer| reader.reads(producer.partition))
+            .flat_map(ProducerOutcome::acked_keys)
+            .filter(|key| !received.ids.contains(&u128::from(*key)))
+            .map(|key| key.to_string())
+            .collect();
+        report(
+            violations,
+            &format!(
+                "{topic}: acknowledged messages never reached {}",
+                reader.name
+            ),
+            &undelivered,
+        );
+    }
+
+    let messages: usize = occurrences.values().sum();
+    LogStats {
+        topic,
+        messages,
+        duplicates: messages - occurrences.len(),
+    }
+}
+
+/// One entry against its producer's record. It must come from a batch that
+/// was sent, sit in its producer's partition and carry the payload its id
+/// implies. It may appear again only if its batch had a failed attempt, at
+/// most once per attempt, and first occurrences must follow the producer's
+/// sequence order.
+fn check_entry(
+    entry: &LogEntry,
+    partition: u32,
+    occurrence: usize,
+    producers: &[ProducerOutcome],
+    last_first_seq: &mut HashMap<u32, u64>,
+) -> Result<(), String> {
+    let key = MessageKey::try_from(entry.id)
+        .map_err(|id| format!("partition {partition}: unknown message id 
{id:#x}"))?;
+    let producer = producers
+        .iter()
+        .find(|producer| producer.producer == key.producer)
+        .ok_or_else(|| format!("partition {partition}: {key} has no 
producer"))?;
+    let Some(&attempts) = producer.attempts.get(key.batch() as usize) else {
+        return Err(format!("partition {partition}: {key} was never sent"));
+    };
+    if partition != producer.partition {
+        return Err(format!(
+            "{key} sits in partition {partition}, its producer sends to {}",
+            producer.partition
+        ));
+    }
+    if entry.payload != key.payload() {
+        return Err(format!("partition {partition}: {key} has a wrong 
payload"));
+    }
+    if occurrence > attempts {
+        return Err(format!(
+            "partition {partition}: {key} appears {occurrence} times, but its 
batch had \
+             {attempts} send attempt(s)"
+        ));
+    }
+    if occurrence > 1 {
+        return Ok(());
+    }
+    match last_first_seq.insert(key.producer, key.seq) {
+        Some(previous) if key.seq < previous => Err(format!(
+            "partition {partition}: {key} comes after seq {previous} of the 
same producer"
+        )),
+        _ => Ok(()),
+    }
+}
+
+/// An acknowledgement names where its batch landed, so the batch's messages
+/// must sit at consecutive offsets from there.
+fn confirmation_matches(log: &ObservedLog, producer: u32, confirmation: 
&Confirmation) -> bool {
+    (confirmation.base_offset..)
+        .zip(batch_keys(producer, confirmation.batch))
+        .all(|(offset, key)| entry_id(log, confirmation.partition, offset) == 
Some(u128::from(key)))
+}
+
+fn entry_id(log: &ObservedLog, partition: u32, offset: u64) -> Option<u128> {
+    log.partitions
+        .get(partition as usize)?
+        .get(&offset)
+        .map(|entry| entry.id)
+}
+
+/// One violation per kind of problem, with the first few examples, so a
+/// systematic failure does not bury the rest.
+fn report(violations: &mut Vec<String>, what: &str, problems: &[String]) {
+    if problems.is_empty() {
+        return;
+    }
+    let examples: Vec<&str> = problems
+        .iter()
+        .take(REPORTED_EXAMPLES)
+        .map(String::as_str)
+        .collect();
+    violations.push(format!(
+        "{} {what}, first: {}",
+        problems.len(),
+        examples.join("; ")
+    ));
+}
+
+fn panic_report(harness: &TestHarness, node: usize) -> Option<String> {
+    harness
+        .node(node)
+        .stderr_panic_report()
+        .map(|stderr| tail(&stderr))
+}
+
+fn tail(text: &str) -> String {
+    let lines: Vec<&str> = text.lines().collect();
+    lines[lines.len().saturating_sub(STDERR_TAIL_LINES)..].join("\n")
+}
+
+fn stamp(started: Instant) -> String {
+    format!("[{:>6.1}s]", started.elapsed().as_secs_f64())
+}
+
+fn record_summary(
+    report: &mut Report,
+    kills: &KillStats,
+    outcomes: &Outcomes,
+    view: Option<&NodeView>,
+    stats: &[LogStats],
+) {
+    let producers = [
+        (TOPIC_NAME, &outcomes.producers),
+        (HISTORY_TOPIC_NAME, &outcomes.history_producers),
+    ];
+    for (topic, producers) in producers {
+        for producer in producers {
+            report
+                .record(
+                    "producer",
+                    json!({
+                        "topic": topic,
+                        "producer": producer.producer,
+                        "partition": producer.partition,
+                        "acked_batches": producer.acked_batches,
+                        "retried_batches": producer
+                            .attempts
+                            .iter()
+                            .filter(|&&batch_attempts| batch_attempts > 1)
+                            .count(),
+                        "unconfirmed_batches": producer
+                            .acked_batches
+                            .saturating_sub(producer.confirmations.len() as 
u64),
+                        "longest_wait_secs": 
producer.longest_wait.as_secs_f64(),
+                        "errors": producer.errors.counts,
+                    }),
+                )
+                .expect("write producer outcome");
+        }
+    }
+    for consumer in &outcomes.consumers {
+        report
+            .record(
+                "consumer",
+                json!({
+                    "name": consumer.name,
+                    "deliveries": consumer.deliveries,
+                    "joins": consumer.churn.as_ref().map(|churn| churn.joins),
+                    "leaves": consumer.churn.as_ref().map(|churn| 
churn.leaves),
+                    "errors": consumer.errors.counts,
+                }),
+            )
+            .expect("write consumer outcome");
+    }
+    for reader in &outcomes.readers {
+        let received = reader.received.lock().expect("received ids lock");
+        report
+            .record(
+                "reader",
+                json!({
+                    "name": reader.name,
+                    "received": received.ids.len(),
+                    "redelivered": received.deliveries - received.ids.len(),
+                }),
+            )
+            .expect("write reader outcome");
+    }
+    for stats in stats {
+        report
+            .record(
+                "log",
+                json!({
+                    "topic": stats.topic,
+                    "messages": stats.messages,
+                    "duplicates": stats.duplicates,
+                }),
+            )
+            .expect("write log outcome");
+    }
+    report
+        .record(
+            "summary",
+            json!({
+                "kills": kills.kills, "leader_kills": kills.leader_kills,
+                "group_samples": outcomes.sampler.samples,
+                "group_sample_errors": outcomes.sampler.errors.counts,
+                "view": view,
+            }),
+        )
+        .expect("write run summary");
+}
+
+fn run_metadata(config: &ChaosConfig) -> serde_json::Value {
+    let mut system = System::new();
+    system.refresh_all();
+    let recorded_unix_ms = SystemTime::now()
+        .duration_since(SystemTime::UNIX_EPOCH)
+        .expect("system clock after epoch")
+        .as_millis();
+    json!({
+        "recorded_unix_ms": recorded_unix_ms,
+        "seed": config.seed,
+        "duration_secs": config.duration.as_secs(),
+        "kill_interval_secs": config.kill_interval.as_secs(),
+        "downtime_secs": config.downtime.as_secs(),
+        "command_env": config.to_string(),
+        "workload": {
+            "nodes": CLUSTER_NODES, "partitions": PARTITIONS,
+            "producers": PRODUCERS, "history_producers": HISTORY_PRODUCERS,
+            "group_members": GROUP_MEMBERS,
+            "segment_size_bytes": SEGMENT_SIZE_BYTES,
+            "message_expiry_secs": MESSAGE_EXPIRY.as_secs(),
+            "producer_batch": PRODUCER_BATCH,
+            "producer_pause_ms": PRODUCER_PAUSE.as_millis(),
+            "heartbeat_interval": GROUP_HEARTBEAT_INTERVAL,
+            "session_timeout": GROUP_SESSION_TIMEOUT,
+            "cleaner_interval": CLEANER_INTERVAL,
+        },
+        "host": {
+            "os": System::name(), "os_version": System::os_version(),
+            "kernel": System::kernel_version(), "arch": std::env::consts::ARCH,
+            "cpu": system.cpus().first().map(|cpu| cpu.brand()),
+            "logical_cpus": system.cpus().len(),
+            "available_parallelism": 
std::thread::available_parallelism().ok().map(|count| count.get()),
+            "total_memory_bytes": system.total_memory(),
+            "available_memory_bytes": system.available_memory(),
+        },
+        "server_binary": server_binary_metadata(),
+    })
+}
+
+fn server_binary_metadata() -> serde_json::Value {
+    let command = Command::cargo_bin("iggy-server").expect("locate the 
prebuilt server");
+    let path = PathBuf::from(command.get_program());
+    let fingerprint = || -> std::io::Result<String> {
+        let mut file = File::open(&path)?;
+        let mut digest = Context::new(&SHA256);
+        let mut buffer = [0; DIGEST_BUFFER_SIZE];
+        loop {
+            let read = file.read(&mut buffer)?;
+            if read == 0 {
+                break;
+            }
+            digest.update(&buffer[..read]);
+        }
+        Ok(hex::encode(digest.finish().as_ref()))
+    };
+    match fingerprint() {
+        Ok(sha256) => json!({"path": path, "sha256": sha256}),
+        Err(error) => json!({"path": path, "error": error.to_string()}),
+    }
+}
+
+fn report_nodes(harness: &TestHarness, report: &mut Report, event: &str) {
+    for (node, server) in harness.all_servers().iter().enumerate() {
+        let data_path = server.data_path();
+        report
+            .record(
+                event,
+                json!({
+                    "node": node, "pid": server.pid(), "running": 
server.is_running(),
+                    "data_path": data_path,
+                    "effective_config_path": 
data_path.join("runtime/current_config.toml"),
+                    "tcp_addr": server.tcp_addr().map(|address| 
address.to_string()),
+                    "http_addr": server.http_addr().map(|address| 
address.to_string()),
+                }),
+            )
+            .expect("write node state");
+    }
+}
diff --git a/core/integration/tests/cluster/chaos_monkey/report.rs 
b/core/integration/tests/cluster/chaos_monkey/report.rs
new file mode 100644
index 000000000..c63da3cea
--- /dev/null
+++ b/core/integration/tests/cluster/chaos_monkey/report.rs
@@ -0,0 +1,151 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fs::File;
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+
+use serde_json::{Value, json};
+use tokio::time::Instant;
+
+const REPORT_FILE: &str = "chaos.jsonl";
+
+pub(super) struct Report {
+    file: File,
+    path: PathBuf,
+    started: Instant,
+}
+
+impl Report {
+    pub(super) fn create(directory: &Path, started: Instant) -> 
io::Result<Self> {
+        let path = directory.join(REPORT_FILE);
+        let file = File::create(&path)?;
+        Ok(Self {
+            file,
+            path,
+            started,
+        })
+    }
+
+    pub(super) fn record(&mut self, event: &str, details: Value) -> 
io::Result<()> {
+        let mut record = serde_json::to_vec(&json!({
+            "event": event,
+            "elapsed_ms": self.started.elapsed().as_millis(),
+            "details": details,
+        }))
+        .map_err(io::Error::other)?;
+        record.push(b'\n');
+        self.file.write_all(&record)
+    }
+
+    pub(super) fn path(&self) -> &Path {
+        &self.path
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::fs::{self, File};
+    use std::io;
+
+    use serde_json::{Value, json};
+    use tempfile::tempdir;
+    use tokio::time::Instant;
+
+    use super::Report;
+
+    #[test]
+    fn 
given_incomplete_run_when_report_drops_should_preserve_recorded_events() {
+        let directory = tempdir().expect("create report directory");
+        let mut report = Report::create(directory.path(), 
Instant::now()).expect("create report");
+        report
+            .record("metadata", json!({ "seed": 7 }))
+            .expect("record metadata");
+        report
+            .record("kill", json!({ "node_id": 1 }))
+            .expect("record fault");
+
+        let path = report.path().to_owned();
+        let before_drop = fs::read_to_string(&path).expect("read report before 
drop");
+        drop(report);
+        let after_drop = fs::read_to_string(path).expect("read report after 
drop");
+        assert_eq!(
+            before_drop, after_drop,
+            "records must be written immediately"
+        );
+
+        let records: Vec<Value> = after_drop
+            .lines()
+            .map(|line| serde_json::from_str(line).expect("parse report 
event"))
+            .collect();
+        assert_eq!(records.len(), 2, "unfinished runs must retain both 
events");
+        assert_eq!(records[0]["event"], "metadata");
+        assert_eq!(records[0]["details"]["seed"], 7);
+        assert_eq!(records[1]["event"], "kill");
+        assert_eq!(records[1]["details"]["node_id"], 1);
+        assert!(
+            records[0]["elapsed_ms"].as_u64().expect("metadata time")
+                <= records[1]["elapsed_ms"].as_u64().expect("fault time"),
+            "event times must preserve execution order"
+        );
+    }
+
+    #[test]
+    fn given_multiline_details_when_recorded_should_preserve_jsonl_framing() {
+        let directory = tempdir().expect("create report directory");
+        let mut report = Report::create(directory.path(), 
Instant::now()).expect("create report");
+        let details = json!({ "message": "node \"1\" failed\nsecond 
line\r\nthird line" });
+        report
+            .record("failure", details.clone())
+            .expect("record multiline failure");
+
+        let contents = fs::read_to_string(report.path()).expect("read report");
+        assert_eq!(
+            contents.lines().count(),
+            1,
+            "one event must occupy one line"
+        );
+        assert!(
+            contents.ends_with('\n'),
+            "each event must end with a newline"
+        );
+        let record: Value = serde_json::from_str(&contents).expect("parse 
multiline failure");
+        assert_eq!(record["details"], details);
+    }
+
+    #[test]
+    fn given_missing_directory_when_report_created_should_return_io_error() {
+        let directory = tempdir().expect("create report parent directory");
+        let result = Report::create(&directory.path().join("missing"), 
Instant::now());
+        assert_eq!(
+            result.err().expect("missing directory must fail").kind(),
+            io::ErrorKind::NotFound
+        );
+    }
+
+    #[test]
+    fn given_unwritable_file_when_recorded_should_return_io_error() {
+        let directory = tempdir().expect("create report directory");
+        let mut report = Report::create(directory.path(), 
Instant::now()).expect("create report");
+        report.file = File::open(report.path()).expect("open report 
read-only");
+
+        assert!(
+            report.record("metadata", json!({ "seed": 7 })).is_err(),
+            "write failures must reach the caller"
+        );
+    }
+}
diff --git a/core/integration/tests/cluster/mod.rs 
b/core/integration/tests/cluster/mod.rs
index c4d183fb0..646feb985 100644
--- a/core/integration/tests/cluster/mod.rs
+++ b/core/integration/tests/cluster/mod.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod chaos_monkey;
 mod client_table_adversarial;
 mod client_table_restart;
 mod consumer_offset_quota;
diff --git a/core/integration/tests/mod.rs b/core/integration/tests/mod.rs
index ae40a2d4c..fc97dec14 100644
--- a/core/integration/tests/mod.rs
+++ b/core/integration/tests/mod.rs
@@ -20,7 +20,7 @@ use dtor::dtor;
 use integration::harness::get_test_directory;
 use lazy_static::lazy_static;
 use std::collections::{HashMap, HashSet};
-use std::fs::{File, OpenOptions};
+use std::fs::{self, File, OpenOptions};
 use std::io::Write;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex, Once, RwLock};
@@ -51,6 +51,7 @@ lazy_static! {
         Arc::new(RwLock::new(HashMap::new()));
     static ref FAILED_TEST_CASES: Arc<RwLock<HashSet<String>>> =
         Arc::new(RwLock::new(HashSet::new()));
+    static ref SAVED_LOG_TESTS: RwLock<HashSet<String>> = 
RwLock::new(HashSet::new());
     /// File handles for real-time log writing, keyed by test name.
     static ref LOG_FILES: Arc<Mutex<HashMap<String, File>>> =
         Arc::new(Mutex::new(HashMap::new()));
@@ -58,6 +59,16 @@ lazy_static! {
 
 static INIT: Once = Once::new();
 static UNKNOWN_TEST_NAME: &str = "unknown";
+const TEST_LOG_FILE: &str = "test_stdout.log";
+
+fn use_saved_logs_on_failure() {
+    let thread = thread::current();
+    if let Some(test_name) = thread.name()
+        && let Ok(mut tests) = SAVED_LOG_TESTS.write()
+    {
+        tests.insert(test_name.to_owned());
+    }
+}
 
 fn setup() {
     let log_buffer = LOGS_BUFFER.clone();
@@ -139,7 +150,7 @@ fn write_to_log_file(test_name: &str, buf: &[u8]) {
     if let std::collections::hash_map::Entry::Vacant(entry) = 
files.entry(test_name.to_string())
         && let Some(dir) = get_test_directory(test_name)
     {
-        let log_path = dir.join("test_stdout.log");
+        let log_path = dir.join(TEST_LOG_FILE);
         if let Ok(file) = 
OpenOptions::new().create(true).append(true).open(&log_path) {
             entry.insert(file);
         }
@@ -166,6 +177,18 @@ fn teardown() {
     {
         for test in failed.iter() {
             if let Some(logs) = buffer.get(test) {
+                if SAVED_LOG_TESTS
+                    .read()
+                    .is_ok_and(|tests| tests.contains(test))
+                    && let Some(directory) = get_test_directory(test)
+                {
+                    let path = directory.join(TEST_LOG_FILE);
+                    // The panic hook adds evidence to the buffer after the 
last tracing write.
+                    if path.is_file() && fs::write(&path, logs).is_ok() {
+                        eprintln!("Logs for failed test '{test}': {}", 
path.display());
+                        continue;
+                    }
+                }
                 eprintln!("Logs for failed test '{test}':");
                 eprintln!("{}", String::from_utf8_lossy(logs));
             }

Reply via email to