blob: 97d42b16c3f9c94f14766e3659dc9afacc56f9af [file]
// 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.
//! On-disk oracles over a node's data directory: segment walkers, cross-replica
//! byte comparison, consumer-offset and superblock readers, and cluster-role
//! lookups. Several older test files still hold local copies of these helpers;
//! consolidating them onto this module is a follow-up.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use consensus::VsrState;
use iggy::prelude::{ClusterClient, ClusterNodeRole};
use journal::superblock::{SLOT_FILE_NAMES, SuperblockContents, decode_slots};
use tokio::time::sleep;
use super::TestHarness;
// Poll the per-node segment `.log` sizes until they agree and hold steady,
// instead of a fixed sleep: a fixed wait either flakes under CI load or hides a
// real replication lag.
const CONVERGENCE_POLL_INTERVAL: Duration = Duration::from_millis(200);
const CONVERGENCE_DEADLINE: Duration = Duration::from_secs(20);
const CONVERGENCE_STABLE_POLLS: u32 = 3;
/// A partition segment `.log`, named for its 20-digit zero-padded base offset
/// (see `partitions::state_transfer`'s path builders).
///
/// Matches the segment file NAME shape, not the `.log` extension alone and not
/// a `streams/` path prefix: the server's own text log sits under the same data
/// root, so an extension-only match would count tracing output as segment data.
pub fn is_segment_log(path: &Path) -> bool {
path.extension().is_some_and(|extension| extension == "log")
&& path
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.len() == 20 && stem.bytes().all(|byte| byte.is_ascii_digit()))
}
/// Depth-first walk of `root`, skipping the `metadata` plane, returning the
/// first path for which `matches` is true. Callers wanting a full sweep return
/// `false` from `matches` and accumulate via its side effects.
pub fn walk(root: &Path, matches: &mut dyn FnMut(&Path) -> bool) -> Option<PathBuf> {
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
if dir.file_name().is_some_and(|name| name == "metadata") {
continue;
}
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else if matches(&path) {
return Some(path);
}
}
}
None
}
/// `Ok(())` when every payload in `expected` appears in node-local segment
/// bytes at a non-decreasing position, otherwise the first discrepancy.
pub fn installed_payloads_complete(data_path: &Path, expected: &[String]) -> Result<(), String> {
let mut chain = Vec::new();
let mut paths = Vec::new();
let _ = walk(data_path, &mut |path| {
if is_segment_log(path) {
paths.push(path.to_path_buf());
}
false
});
// Segment files are named for their zero-padded base offset, so lexical
// order is offset order.
paths.sort();
for path in paths {
let Ok(bytes) = fs::read(&path) else {
return Err(format!("{} could not be read", path.display()));
};
chain.extend_from_slice(&bytes);
}
let mut searched_from = 0;
for payload in expected {
let found = chain[searched_from..]
.windows(payload.len())
.enumerate()
.filter(|(_, window)| *window == payload.as_bytes())
.map(|(offset, _)| searched_from + offset)
// A bare find would match `message-1` inside `message-10`; skip
// digit-extended matches and take the next occurrence instead of
// rejecting the payload outright (the follow byte of a genuine
// match can itself be a digit, e.g. inside a following header).
.find(|start| {
chain
.get(start + payload.len())
.is_none_or(|byte| !byte.is_ascii_digit())
});
let Some(start) = found else {
return Err(format!(
"{payload:?} is absent from the {} installed bytes after position {searched_from}",
chain.len()
));
};
searched_from = start;
}
Ok(())
}
/// A file (relative to a node's data dir) whose bytes must match across
/// replicas: the partition segment `.log`, plus the replicated metadata WAL
/// when `include_wal` is set. Per-node files (logs, runtime, config, stdout)
/// are excluded by construction.
///
/// Callers default `include_wal` to false: WAL byte content legitimately
/// diverges across replicas once a node crosses its checkpoint margin, so only
/// runs kept well below that margin may compare it. Two metadata-plane files
/// are always excluded as local, per-replica artifacts:
///
/// - `metadata/snapshot.bin`: a local compaction artifact stamped with
/// `created_at = now()` and a per-replica `sequence_number`, plus unsorted
/// hashmap iteration order; it can never match across replicas.
/// - `state/`: not populated by the VSR plane; excluded for the same
/// local-artifact reason so it cannot start flaking if that changes.
///
/// The segment `.index` is excluded for the same class of reason: a local
/// sparse index (one entry per persist flush), not replicated and not part of
/// the VSR hash chain; recovery rebuilds it from the `.log`. Its length tracks
/// commit cadence, which differs between primary (one flush per op) and backup
/// (one flush per committed heartbeat range).
fn is_comparable(rel: &str, include_wal: bool) -> bool {
let is_segment = rel.starts_with("streams/") && rel.ends_with(".log");
let is_metadata_wal = rel == "metadata/journal.wal";
is_segment || (include_wal && is_metadata_wal)
}
/// Every comparable file under `root`, keyed by its `/`-separated relative path.
pub fn collect_comparable_files(root: &Path, include_wal: bool) -> BTreeMap<String, Vec<u8>> {
let mut files = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(path);
} else if file_type.is_file()
&& let Ok(rel) = path.strip_prefix(root)
{
let rel = rel.to_string_lossy().replace('\\', "/");
if is_comparable(&rel, include_wal) {
let bytes = fs::read(&path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
files.insert(rel, bytes);
}
}
}
}
files
}
/// 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() {
eprintln!(
"node {idx}: {} comparable file(s): {:?}",
node.len(),
node.iter()
.map(|(rel, bytes)| format!("{rel} ({} B)", bytes.len()))
.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<_>>()
);
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() {
let Some(bytes) = node.get(key) else {
problems.push(format!(
"`{key}` present on some replicas but MISSING on node {idx}"
));
continue;
};
let bytes: &[u8] = bytes;
match reference {
None => reference = Some((idx, bytes)),
Some((ref_idx, ref_bytes)) => {
if bytes != ref_bytes {
problems.push(describe_mismatch(key, ref_idx, ref_bytes, idx, bytes));
}
}
}
}
}
assert!(
problems.is_empty(),
"cross-replica data divergence ({} issue(s)):\n{}",
problems.len(),
problems.join("\n")
);
}
/// Human-readable first-difference report for one relative path across two nodes.
pub fn describe_mismatch(key: &str, a_idx: usize, a: &[u8], b_idx: usize, b: &[u8]) -> String {
let window = |buf: &[u8], at: usize| {
let start = at.saturating_sub(8);
let end = (at + 8).min(buf.len());
buf[start..end]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<Vec<_>>()
.join(" ")
};
match a.iter().zip(b.iter()).position(|(x, y)| x != y) {
Some(at) => format!(
"`{key}`: bytes differ between node {a_idx} ({} B) and node {b_idx} ({} B) at offset {at}. \
node{a_idx}=[{}] node{b_idx}=[{}]",
a.len(),
b.len(),
window(a, at),
window(b, at),
),
None => format!(
"`{key}`: length differs between node {a_idx} ({} B) and node {b_idx} ({} B)",
a.len(),
b.len(),
),
}
}
/// Poll each node's total segment `.log` bytes until all nodes agree and the
/// figure holds steady for a few consecutive polls, or the deadline (20s)
/// elapses. On timeout, return anyway: the byte-for-byte compare that follows
/// then fails with a precise diff instead of this masking a real lag.
pub async fn wait_for_log_convergence(data_paths: &[PathBuf]) {
let deadline = tokio::time::Instant::now() + CONVERGENCE_DEADLINE;
let mut previous: Option<Vec<u64>> = None;
let mut stable_polls = 0u32;
loop {
let sizes: Vec<u64> = data_paths
.iter()
.map(|root| total_log_bytes(root))
.collect();
let all_equal = sizes.iter().all(|size| *size == sizes[0]);
if all_equal && previous.as_ref() == Some(&sizes) {
stable_polls += 1;
if stable_polls >= CONVERGENCE_STABLE_POLLS {
return;
}
} else {
stable_polls = 0;
}
if tokio::time::Instant::now() >= deadline {
return;
}
previous = Some(sizes);
sleep(CONVERGENCE_POLL_INTERVAL).await;
}
}
/// Total bytes of every partition segment `.log` under a node's data dir.
/// Mirrors the `.log` selection in `is_comparable`; sizes only, no contents.
fn total_log_bytes(root: &Path) -> u64 {
let mut total = 0;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(path);
} else if file_type.is_file()
&& let Ok(rel) = path.strip_prefix(root)
{
let rel = rel.to_string_lossy().replace('\\', "/");
if rel.starts_with("streams/") && rel.ends_with(".log") {
total += fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0);
}
}
}
}
total
}
/// The u64 offset persisted under any `offsets/consumers/<id>` file in a node's
/// data dir, or `None` when no such file has been written yet. Walks the tree
/// so it is robust to the stream/topic/partition id layout. Reads the leading
/// u64 of the record: the file is offset + trailing checksum (see
/// `partitions::offset_storage::encode_offset_record`), and a shorter read
/// (persist truncates before writing) is treated as not-yet-written.
pub fn read_replicated_consumer_offset(data_dir: &Path) -> Option<u64> {
let mut stack: Vec<PathBuf> = vec![data_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(path);
continue;
}
let is_consumer_offset = path
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name == "consumers")
&& path
.parent()
.and_then(Path::parent)
.and_then(Path::file_name)
.is_some_and(|name| name == "offsets");
if is_consumer_offset
&& let Ok(bytes) = fs::read(&path)
&& let Some(offset_bytes) = bytes.first_chunk::<8>()
{
return Some(u64::from_le_bytes(*offset_bytes));
}
}
}
None
}
/// Decode a node's durable metadata `VsrState` from its on-disk superblock,
/// `None` if no record exists yet. Reads the two slot files with blocking I/O
/// (callers are tokio tests off any compio runtime) and decodes them through
/// the journal's own newest-verifying-wins selection, so the caller sees
/// exactly what `PingPongSuperblock::read_latest` would.
///
/// # Panics
/// If a slot holds bytes that do not verify. For callers that do not corrupt
/// slots that is a real durability bug; returning `None` would let pollers
/// read it as "not written yet" and time out on a misleading message.
pub fn read_metadata_superblock_state(data_path: &Path) -> Option<VsrState> {
// `<data_dir>/metadata/` is where shard 0 opens its `PingPongSuperblock`.
let dir = data_path.join("metadata");
read_superblock_state_in(&dir, "metadata")
}
/// The single partition directory's superblock record on a node, `None` while
/// no record exists yet (a partition group that never left view 0 has an empty
/// superblock, and its slot files may not exist at all). Intended for layouts
/// with exactly one partition group.
///
/// # Panics
/// Same contract as [`read_metadata_superblock_state`].
pub fn read_partition_superblock_state(data_path: &Path) -> Option<VsrState> {
let dir = find_partition_superblock_dir(data_path)?;
read_superblock_state_in(&dir, "partition")
}
fn read_superblock_state_in(dir: &Path, plane: &str) -> Option<VsrState> {
let slot_a = fs::read(dir.join(SLOT_FILE_NAMES[0])).ok();
let slot_b = fs::read(dir.join(SLOT_FILE_NAMES[1])).ok();
match decode_slots(slot_a.as_deref(), slot_b.as_deref()) {
SuperblockContents::Present(payload) => VsrState::try_from(payload.as_slice()).ok(),
SuperblockContents::Empty => None,
SuperblockContents::Unreadable { version } => panic!(
"{plane} superblock at {} is unreadable (version {version:?})",
dir.display()
),
}
}
/// First directory under `root` (metadata plane excluded) holding a superblock
/// slot file; walked so callers do not hard-code the
/// `streams/<s>/topics/<t>/partitions/<p>` layout.
pub fn find_partition_superblock_dir(root: &Path) -> Option<PathBuf> {
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
// The metadata plane keeps its own superblock under `<data>/metadata`;
// only partition records are of interest here.
if dir.file_name().is_some_and(|name| name == "metadata") {
continue;
}
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else if path
.file_name()
.is_some_and(|name| name == SLOT_FILE_NAMES[0])
{
return Some(dir);
}
}
}
None
}
/// Index of the node the metadata roster marks as leader, resolved by matching
/// the roster's TCP port against each node's bound address.
///
/// Reads through node 0. Use [`leader_node_index_via`] where node 0 may be
/// down or where the roster must be read from a named node.
///
/// # Panics
/// If no root client connects, the roster query fails, no node is marked
/// leader, or the leader's port matches no harness node.
pub async fn leader_node_index(harness: &TestHarness) -> usize {
leader_node_index_via(harness, 0).await
}
/// [`leader_node_index`] reading the roster through node `via`.
///
/// Named separately because which node answers matters once node 0 may be
/// down: the roster read is auth-gated, so it needs a node that can complete a
/// login, and a client built against a dead node cannot.
///
/// # Panics
/// As [`leader_node_index`].
pub async fn leader_node_index_via(harness: &TestHarness, via: usize) -> usize {
let client = harness
.root_client_for_node(via)
.await
.expect("a root client (redirecting to the leader if the dialed node is not it)");
let metadata = client
.get_cluster_metadata()
.await
.expect("get cluster metadata");
let leader_port = metadata
.nodes
.iter()
.find(|node| node.role == ClusterNodeRole::Leader)
.unwrap_or_else(|| panic!("the cluster must have elected a leader, got {metadata}"))
.endpoints
.tcp;
(0..harness.cluster_size())
.find(|index| {
harness
.node(*index)
.tcp_addr()
.is_some_and(|address| address.port() == leader_port)
})
.expect("the leader must be one of the roster nodes")
}