- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build - Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference - Add docker standalone/cluster/deploy images, compose, and prometheus config - Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry - Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites - Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
985 lines
33 KiB
Rust
985 lines
33 KiB
Rust
//! Integration tests for the tidalctl binary.
|
|
//!
|
|
//! Tests run the actual binary as a subprocess via `std::process::Command`.
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![allow(clippy::unwrap_used)]
|
|
|
|
use std::process::Command;
|
|
|
|
use tidaldb::{
|
|
db::temp::TempTidalHome,
|
|
wal::{SignalEvent, WalConfig, WalHandle},
|
|
};
|
|
|
|
fn tidalctl_bin() -> Command {
|
|
Command::new(env!("CARGO_BIN_EXE_tidalctl"))
|
|
}
|
|
|
|
/// Create a `TempTidalHome` with WAL segments and a checkpoint.
|
|
/// Returns the home so it stays alive (not dropped) for the test.
|
|
fn home_with_wal_data() -> TempTidalHome {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
let paths = home.paths();
|
|
paths.ensure_all().expect("create subdirs");
|
|
|
|
let config = WalConfig {
|
|
dir: home.path().to_path_buf(),
|
|
..WalConfig::default()
|
|
};
|
|
|
|
let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
|
|
|
|
for i in 1..=5 {
|
|
let event = SignalEvent {
|
|
entity_id: i,
|
|
signal_type: 1,
|
|
weight: 1.0,
|
|
timestamp_nanos: i * 1_000_000_000,
|
|
};
|
|
let _seq = handle.append(event).expect("append event");
|
|
}
|
|
|
|
handle.checkpoint(3).expect("checkpoint");
|
|
handle.shutdown().expect("shutdown WAL");
|
|
|
|
home
|
|
}
|
|
|
|
#[test]
|
|
fn status_with_wal_segments_exits_0() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["status", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"exit code: {}, stderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert_eq!(json["status"], "ok");
|
|
assert!(
|
|
json["wal"]["segments"].as_u64().unwrap_or(0) > 0,
|
|
"should have segments: {stdout}"
|
|
);
|
|
assert!(
|
|
json["wal"]["checkpoint_seq"].as_u64().unwrap_or(0) > 0,
|
|
"should have checkpoint: {stdout}"
|
|
);
|
|
assert!(json["version"].is_string());
|
|
assert!(json["build_hash"].is_string());
|
|
assert!(json["dirs"]["base"].is_string());
|
|
}
|
|
|
|
#[test]
|
|
fn status_empty_dir_shows_empty() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
let paths = home.paths();
|
|
paths.ensure_all().expect("create subdirs");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["status", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
assert_eq!(json["status"], "empty");
|
|
}
|
|
|
|
#[test]
|
|
fn status_nonexistent_path_exits_0_with_empty() {
|
|
// When the WAL dir doesn't exist, gather_wal_state returns segments=0
|
|
// which maps to "empty" status. The base dir may or may not exist.
|
|
let output = tidalctl_bin()
|
|
.args(["status", "--path", "/nonexistent/path/that/does/not/exist"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
// This should still be valid JSON regardless of status
|
|
if output.status.success() {
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
assert!(
|
|
json["status"] == "empty" || json["status"] == "error",
|
|
"status should be empty or error: {stdout}"
|
|
);
|
|
} else {
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stderr).expect("parse error JSON");
|
|
assert!(json["error"].is_string());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn paths_exits_0_with_all_dirs() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
let paths = home.paths();
|
|
paths.ensure_all().expect("create subdirs");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["paths", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
// Check all six directory paths are present
|
|
assert!(json["base"].is_string(), "missing base: {stdout}");
|
|
assert!(json["wal"].is_string(), "missing wal: {stdout}");
|
|
assert!(json["items"].is_string(), "missing items: {stdout}");
|
|
assert!(json["users"].is_string(), "missing users: {stdout}");
|
|
assert!(json["creators"].is_string(), "missing creators: {stdout}");
|
|
assert!(json["cache"].is_string(), "missing cache: {stdout}");
|
|
|
|
// Check exists map
|
|
assert!(json["exists"]["base"].is_boolean());
|
|
assert!(json["exists"]["wal"].is_boolean());
|
|
assert_eq!(json["exists"]["base"], true);
|
|
assert_eq!(json["exists"]["wal"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn paths_nonexistent_dir_shows_false_exists() {
|
|
let output = tidalctl_bin()
|
|
.args(["paths", "--path", "/nonexistent/does/not/exist"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert_eq!(json["exists"]["base"], false);
|
|
assert_eq!(json["exists"]["wal"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn pretty_flag_produces_indented_json() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["paths", "--path", home.path().to_str().unwrap(), "--pretty"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
// Pretty-printed JSON should have newlines and indentation
|
|
assert!(
|
|
stdout.contains('\n'),
|
|
"pretty output should have newlines: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains(" "),
|
|
"pretty output should have indentation: {stdout}"
|
|
);
|
|
// Should still be valid JSON
|
|
let _json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
}
|
|
|
|
#[test]
|
|
fn bad_command_exits_1() {
|
|
let output = tidalctl_bin()
|
|
.args(["nonexistent_command", "--path", "/tmp"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(!output.status.success(), "should exit 1 for bad command");
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
assert!(
|
|
stderr.contains("error"),
|
|
"stderr should contain error: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_args_exits_1() {
|
|
let output = tidalctl_bin().output().expect("run tidalctl");
|
|
|
|
assert!(!output.status.success(), "should exit 1 with no args");
|
|
}
|
|
|
|
#[test]
|
|
fn missing_path_flag_exits_1() {
|
|
let output = tidalctl_bin()
|
|
.args(["status"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(!output.status.success(), "should exit 1 without --path");
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
assert!(
|
|
stderr.contains("error"),
|
|
"stderr should contain error: {stderr}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// recover --verify-only tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn recover_verify_only_with_wal_data_json() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"exit code: {}, stderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert!(
|
|
json["total_events"].as_u64().unwrap_or(0) > 0,
|
|
"should have total_events: {stdout}"
|
|
);
|
|
assert!(
|
|
json["segment_count"].as_u64().unwrap_or(0) > 0,
|
|
"should have segments: {stdout}"
|
|
);
|
|
assert!(
|
|
json["checkpoint_seq"].as_u64().unwrap_or(0) > 0,
|
|
"should have checkpoint: {stdout}"
|
|
);
|
|
assert!(
|
|
json["estimated_recovery_secs"].as_f64().unwrap_or(0.0) > 0.0,
|
|
"should have recovery time estimate: {stdout}"
|
|
);
|
|
assert!(
|
|
json["segments"].is_array(),
|
|
"should have segments array: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recover_verify_only_empty_dir() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
let paths = home.paths();
|
|
paths.ensure_all().expect("create subdirs");
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert_eq!(json["total_events"], 0);
|
|
assert_eq!(json["segment_count"], 0);
|
|
assert_eq!(json["inconsistency_count"], 0);
|
|
}
|
|
|
|
#[test]
|
|
fn recover_verify_only_pretty_output() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
"--pretty",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"exit code: {}, stderr: {}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
assert!(
|
|
stdout.contains("WAL Diagnostic Report"),
|
|
"should contain report header: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Checkpoint:"),
|
|
"should contain checkpoint section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Events:"),
|
|
"should contain events section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Estimated Recovery Time:"),
|
|
"should contain recovery estimate: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Segment Inventory:"),
|
|
"should contain segment table: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recover_without_verify_only_exits_1() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["recover", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"should exit 1 without --verify-only"
|
|
);
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
assert!(
|
|
stderr.contains("verify-only"),
|
|
"stderr should mention verify-only: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recover_nonexistent_path_returns_empty_report() {
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
"/nonexistent/path/that/does/not/exist",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
// Should succeed with an empty report (no WAL dir = empty report).
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
assert_eq!(json["total_events"], 0);
|
|
assert_eq!(json["segment_count"], 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// diagnostics tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn diagnostics_json_output_valid() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"exit code: {}, stderr: {}",
|
|
output.status.code().unwrap_or(-1),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8(output.stdout).expect("valid utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
|
|
|
|
// Fields genuinely derivable offline from disk are real numbers.
|
|
assert!(json["version"].is_string());
|
|
assert!(json["wal_segments"].is_number());
|
|
assert!(json["checkpoint_age_seconds"].is_number());
|
|
assert!(json["tantivy_segments"].is_number());
|
|
assert!(json["tantivy_indexed_docs"].is_number());
|
|
assert!(json["checkpoint_wal_sequence"].is_number());
|
|
assert!(json["wal_total_bytes"].is_number());
|
|
|
|
// Fields that cannot be honestly derived offline MUST be JSON `null`, never
|
|
// a fabricated `0` that an operator would misread as a real value.
|
|
for field in [
|
|
"usearch_directory_bytes",
|
|
"sessions_active",
|
|
"sessions_closed_total",
|
|
"sessions_auto_closed_total",
|
|
"degradation_level",
|
|
"collection_count",
|
|
"cohort_count",
|
|
] {
|
|
assert!(
|
|
json[field].is_null(),
|
|
"{field} must be null offline, got {}: {stdout}",
|
|
json[field]
|
|
);
|
|
}
|
|
|
|
// The output is self-describing: every null field is documented with a reason.
|
|
let unavailable = json["offline_unavailable"]
|
|
.as_object()
|
|
.expect("offline_unavailable should be an object");
|
|
for field in [
|
|
"usearch_directory_bytes",
|
|
"sessions_active",
|
|
"sessions_closed_total",
|
|
"sessions_auto_closed_total",
|
|
"degradation_level",
|
|
"collection_count",
|
|
"cohort_count",
|
|
] {
|
|
assert!(
|
|
unavailable
|
|
.get(field)
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some(),
|
|
"offline_unavailable should explain {field}: {stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostics_pretty_output_readable() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"diagnostics",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
"--pretty",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("tidalDB Diagnostics"),
|
|
"missing header: {stdout}"
|
|
);
|
|
assert!(stdout.contains("WAL"), "missing WAL section: {stdout}");
|
|
assert!(
|
|
stdout.contains("Checkpoint"),
|
|
"missing Checkpoint section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Signal Ledger"),
|
|
"missing Signal Ledger section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Text Index (Tantivy)"),
|
|
"missing Text Index section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Vector Index (USearch)"),
|
|
"missing Vector Index section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Sessions"),
|
|
"missing Sessions section: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("Degradation"),
|
|
"missing Degradation section: {stdout}"
|
|
);
|
|
|
|
// Runtime-only / locked-keyspace fields must be marked unavailable, never
|
|
// printed as a fabricated "0".
|
|
assert!(
|
|
stdout.contains("not available offline"),
|
|
"offline-unavailable fields must say so, not print a fabricated 0: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("lock"),
|
|
"pretty output should explain the lock-free constraint: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostics_missing_dir_exits_1() {
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"diagnostics",
|
|
"--path",
|
|
"/nonexistent/tidaldb/diagnostics/path",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
assert_eq!(output.status.code(), Some(1));
|
|
}
|
|
|
|
// ── scope-stats (M9p1) ──────────────────────────────────────────────────────
|
|
|
|
/// Create a WAL with a mix of scoped events: 3 local, 2 community, 1 session.
|
|
fn home_with_scoped_wal_data() -> TempTidalHome {
|
|
use tidaldb::{governance::SignalScope, wal::format::EventRecord};
|
|
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
home.paths().ensure_all().expect("create subdirs");
|
|
|
|
let config = WalConfig {
|
|
dir: home.path().to_path_buf(),
|
|
..WalConfig::default()
|
|
};
|
|
let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
|
|
let sender = handle.sender();
|
|
|
|
// 3 local signals (default path).
|
|
for i in 1..=3 {
|
|
handle
|
|
.append(SignalEvent {
|
|
entity_id: i,
|
|
signal_type: 1,
|
|
weight: 1.0,
|
|
timestamp_nanos: i * 1_000_000_000,
|
|
})
|
|
.expect("append local");
|
|
}
|
|
// 2 community signals.
|
|
let community = SignalScope::Community(tidaldb::governance::CommunityId(1)).discriminant();
|
|
for i in 10..=11 {
|
|
sender
|
|
.append_record(EventRecord::scoped(
|
|
i,
|
|
1,
|
|
1.0,
|
|
i * 1_000_000_000,
|
|
community,
|
|
0,
|
|
1,
|
|
0,
|
|
))
|
|
.expect("append community");
|
|
}
|
|
// 1 session signal.
|
|
sender
|
|
.append_record(EventRecord::scoped(
|
|
20,
|
|
1,
|
|
1.0,
|
|
20_000_000_000,
|
|
SignalScope::Session.discriminant(),
|
|
0,
|
|
0,
|
|
0,
|
|
))
|
|
.expect("append session");
|
|
|
|
handle.checkpoint(0).expect("checkpoint");
|
|
handle.shutdown().expect("shutdown WAL");
|
|
home
|
|
}
|
|
|
|
#[test]
|
|
fn scope_stats_tallies_events_by_scope() {
|
|
let home = home_with_scoped_wal_data();
|
|
let output = tidalctl_bin()
|
|
.args(["scope-stats", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl scope-stats");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
let stats = &json["scope_stats"];
|
|
assert_eq!(stats["total_events"], 6, "6 total events: {stdout}");
|
|
assert_eq!(stats["by_scope"]["local"], 3);
|
|
assert_eq!(stats["by_scope"]["community"], 2);
|
|
assert_eq!(stats["by_scope"]["session"], 1);
|
|
assert_eq!(stats["by_scope"]["agent"], 0);
|
|
// share-eligible = everything non-local = 2 community + 1 session.
|
|
assert_eq!(stats["share_eligible"], 3, "share-eligible count: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn scope_stats_empty_wal_is_all_zero() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
home.paths().ensure_all().expect("create subdirs");
|
|
let output = tidalctl_bin()
|
|
.args(["scope-stats", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl scope-stats");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
assert_eq!(json["scope_stats"]["total_events"], 0);
|
|
assert_eq!(json["scope_stats"]["share_eligible"], 0);
|
|
}
|
|
|
|
// ── JSON escaping correctness (json_escape control-char hole) ────────────────
|
|
|
|
/// A path containing JSON control characters (tab, newline, carriage return,
|
|
/// NUL) must still produce VALID JSON on stdout — the old hand-rolled
|
|
/// `json_escape` only escaped `\` and `"`, emitting raw control chars that
|
|
/// strict parsers reject. `serde_json` now owns escaping, so the value must both
|
|
/// parse and round-trip byte-for-byte.
|
|
#[test]
|
|
fn paths_with_control_chars_emits_valid_json() {
|
|
// A directory path containing a tab, newline, and carriage return. (A NUL
|
|
// byte cannot survive an argv element on Unix, so the OS would reject it
|
|
// before reaching us; serde handles NUL in-process at the serializer layer
|
|
// either way.) We never create the path on disk; `paths` simply echoes the
|
|
// resolved path strings, so the exact bytes flow through the JSON serializer
|
|
// regardless of existence.
|
|
let weird = "/tmp/tab\there/new\nline/cr\rret/control\u{1}byte";
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["paths", "--path", weird])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(output.status.success(), "paths should exit 0");
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
|
|
// The whole document must be a single valid JSON object on one line: a raw
|
|
// newline in the value would either split the line or make this fail.
|
|
assert_eq!(
|
|
stdout.lines().count(),
|
|
1,
|
|
"compact JSON must be exactly one physical line: {stdout:?}"
|
|
);
|
|
let json: serde_json::Value =
|
|
serde_json::from_str(&stdout).expect("control-char path must still be valid JSON");
|
|
|
|
// The control characters must survive a round-trip exactly (serde escapes
|
|
// them on the wire and un-escapes on parse).
|
|
assert_eq!(
|
|
json["base"].as_str().expect("base is a string"),
|
|
weird,
|
|
"control chars must round-trip byte-for-byte: {stdout:?}"
|
|
);
|
|
}
|
|
|
|
/// The error envelope path (`CliError::to_json`) must also escape control
|
|
/// characters: an unknown-flag message that embeds user-supplied bytes with a
|
|
/// newline must yield parseable JSON on stderr, not a torn record.
|
|
#[test]
|
|
fn error_envelope_with_control_chars_is_valid_json() {
|
|
let output = tidalctl_bin()
|
|
.args(["status", "--path", "/tmp", "--bad\nflag"])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
|
|
assert!(!output.status.success(), "unknown flag should exit 1");
|
|
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
|
let json: serde_json::Value =
|
|
serde_json::from_str(stderr.trim()).expect("error envelope must be valid JSON");
|
|
let msg = json["error"].as_str().expect("error is a string");
|
|
assert!(
|
|
msg.contains("--bad\nflag"),
|
|
"error message must preserve the embedded control char: {stderr:?}"
|
|
);
|
|
}
|
|
|
|
// ── diagnostics: real signal-entry count + real WAL replay lag ───────────────
|
|
|
|
/// `signal_estimated_entries` must report an ACTUAL event count, not the old
|
|
/// `last_segment_seq` proxy. `home_with_wal_data` appends exactly 5 events at
|
|
/// sequences 1..=5, so the count must be 5 — the sequence-number proxy would
|
|
/// have reported the segment's first sequence (1) instead.
|
|
#[test]
|
|
fn diagnostics_signal_entries_is_real_count_not_seq_proxy() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
assert_eq!(
|
|
json["signal_estimated_entries"], 5,
|
|
"should be the real 5-event count, not a sequence proxy: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// `wal_lag_bytes` must be a REAL lag metric, not a verbatim copy of
|
|
/// `wal_total_bytes`. With a checkpoint advanced PAST every event, nothing is
|
|
/// left to replay, so lag must be 0 while total bytes stay > 0 — a duplicate
|
|
/// field could never produce that divergence.
|
|
#[test]
|
|
fn diagnostics_wal_lag_is_zero_when_fully_checkpointed() {
|
|
let home = TempTidalHome::new().expect("create temp home");
|
|
home.paths().ensure_all().expect("create subdirs");
|
|
|
|
let config = WalConfig {
|
|
dir: home.path().to_path_buf(),
|
|
..WalConfig::default()
|
|
};
|
|
let (handle, _replayed, _session_events) = WalHandle::open(config).expect("open WAL");
|
|
let mut last_seq = 0;
|
|
for i in 1..=5 {
|
|
last_seq = handle
|
|
.append(SignalEvent {
|
|
entity_id: i,
|
|
signal_type: 1,
|
|
weight: 1.0,
|
|
timestamp_nanos: i * 1_000_000_000,
|
|
})
|
|
.expect("append event");
|
|
}
|
|
// Checkpoint past the last written sequence: every event is now durable, so
|
|
// there is zero replay backlog.
|
|
handle
|
|
.checkpoint(last_seq + 1)
|
|
.expect("checkpoint past tail");
|
|
handle.shutdown().expect("shutdown WAL");
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
let total = json["wal_total_bytes"].as_u64().expect("total is a number");
|
|
let lag = json["wal_lag_bytes"].as_u64().expect("lag is a number");
|
|
assert!(total > 0, "should have WAL bytes on disk: {stdout}");
|
|
assert_eq!(
|
|
lag, 0,
|
|
"fully-checkpointed WAL has zero replay lag (not a copy of total={total}): {stdout}"
|
|
);
|
|
}
|
|
|
|
/// With a checkpoint covering only part of the WAL, lag must be non-zero and at
|
|
/// most the total — a real subset, again proving it is not a duplicate of total.
|
|
#[test]
|
|
fn diagnostics_wal_lag_is_nonzero_with_uncheckpointed_tail() {
|
|
// home_with_wal_data checkpoints at seq 3 but writes events 1..=5, so events
|
|
// 3,4,5 are uncheckpointed -> non-zero lag.
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl diagnostics");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
let total = json["wal_total_bytes"].as_u64().expect("total is a number");
|
|
let lag = json["wal_lag_bytes"].as_u64().expect("lag is a number");
|
|
assert!(lag > 0, "uncheckpointed tail must report lag: {stdout}");
|
|
assert!(lag <= total, "lag is a subset of total bytes: {stdout}");
|
|
}
|
|
|
|
// ── diagnostics: offline-unavailable reasons single-sourced ──────────────────
|
|
|
|
/// The JSON `offline_unavailable` reasons and the pretty-output reasons must be
|
|
/// the SAME strings (single-sourced from the REASON_* constants). We assert the
|
|
/// pretty output contains each exact JSON reason verbatim.
|
|
#[test]
|
|
fn diagnostics_offline_reasons_single_sourced_across_json_and_pretty() {
|
|
let home = home_with_wal_data();
|
|
|
|
let json_out = tidalctl_bin()
|
|
.args(["diagnostics", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run diagnostics json");
|
|
assert!(json_out.status.success());
|
|
let json: serde_json::Value =
|
|
serde_json::from_str(&String::from_utf8(json_out.stdout).expect("utf8")).expect("json");
|
|
|
|
let pretty_out = tidalctl_bin()
|
|
.args([
|
|
"diagnostics",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
"--pretty",
|
|
])
|
|
.output()
|
|
.expect("run diagnostics pretty");
|
|
assert!(pretty_out.status.success());
|
|
let pretty = String::from_utf8(pretty_out.stdout).expect("utf8");
|
|
|
|
let reasons = json["offline_unavailable"]
|
|
.as_object()
|
|
.expect("offline_unavailable is an object");
|
|
assert!(!reasons.is_empty(), "expected at least one reason");
|
|
for (field, reason) in reasons {
|
|
let reason = reason.as_str().expect("reason is a string");
|
|
assert!(
|
|
pretty.contains(reason),
|
|
"pretty output must reuse the JSON reason for {field}: {reason:?}\n{pretty}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── recover: session_journal summary must be in the output ───────────────────
|
|
|
|
/// `recover --verify-only` JSON must include the `session_journal` summary that
|
|
/// `diagnose_wal` now produces — the old hand-rolled builder dropped it.
|
|
#[test]
|
|
fn recover_json_includes_session_journal_summary() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
let sj = json["session_journal"]
|
|
.as_object()
|
|
.expect("session_journal must be present: {stdout}");
|
|
assert!(sj.contains_key("present"), "present field: {stdout}");
|
|
assert!(sj.contains_key("file_size"), "file_size field: {stdout}");
|
|
assert!(
|
|
sj.contains_key("event_count"),
|
|
"event_count field: {stdout}"
|
|
);
|
|
assert!(
|
|
sj.contains_key("corrupt_records"),
|
|
"corrupt_records field: {stdout}"
|
|
);
|
|
assert!(
|
|
json["session_journal"]["present"].is_boolean(),
|
|
"present is a bool: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// The pretty recover output must also render the session-journal section.
|
|
#[test]
|
|
fn recover_pretty_includes_session_journal_section() {
|
|
let home = home_with_wal_data();
|
|
|
|
let output = tidalctl_bin()
|
|
.args([
|
|
"recover",
|
|
"--verify-only",
|
|
"--path",
|
|
home.path().to_str().unwrap(),
|
|
"--pretty",
|
|
])
|
|
.output()
|
|
.expect("run tidalctl");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
assert!(
|
|
stdout.contains("Session Journal:"),
|
|
"pretty recover must include session-journal section: {stdout}"
|
|
);
|
|
}
|
|
|
|
// ── scope-stats: torn-tail / inconsistency indicator ─────────────────────────
|
|
|
|
/// Append raw garbage after the last valid WAL batch to simulate a torn/corrupt
|
|
/// tail. `read_all_events` stops at the bad magic and silently undercounts, so
|
|
/// scope-stats must surface `complete: false` with a non-zero
|
|
/// `inconsistency_count` instead of pretending the tally is whole.
|
|
#[test]
|
|
fn scope_stats_torn_tail_reports_inconsistency() {
|
|
use std::io::Write;
|
|
|
|
let home = home_with_scoped_wal_data();
|
|
let wal_dir = home.paths().wal_dir();
|
|
|
|
// Find the (single) WAL segment file and append garbage past the last batch.
|
|
let seg_path = std::fs::read_dir(&wal_dir)
|
|
.expect("read wal dir")
|
|
.filter_map(Result::ok)
|
|
.map(|e| e.path())
|
|
.find(|p| {
|
|
p.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.is_some_and(|n| n.starts_with("wal-") && n.ends_with(".seg"))
|
|
})
|
|
.expect("a WAL segment file must exist");
|
|
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(&seg_path)
|
|
.expect("open segment for append");
|
|
// Bytes that are NOT the batch magic -> the scan stops here and counts a
|
|
// corrupt/truncated tail.
|
|
f.write_all(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02, 0x03])
|
|
.expect("append garbage tail");
|
|
f.sync_all().expect("sync");
|
|
drop(f);
|
|
|
|
let output = tidalctl_bin()
|
|
.args(["scope-stats", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl scope-stats");
|
|
assert!(
|
|
output.status.success(),
|
|
"scope-stats must still succeed on a torn tail: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
|
|
let body = &json["scope_stats"];
|
|
assert_eq!(
|
|
body["complete"], false,
|
|
"torn tail must mark the tally incomplete: {stdout}"
|
|
);
|
|
assert!(
|
|
body["inconsistency_count"].as_u64().unwrap_or(0) > 0,
|
|
"torn tail must surface a non-zero inconsistency_count: {stdout}"
|
|
);
|
|
// The valid prefix is still tallied (3 local + 2 community + 1 session were
|
|
// written before the garbage).
|
|
assert!(
|
|
body["total_events"].as_u64().unwrap_or(0) >= 1,
|
|
"the valid prefix must still be counted: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// A clean WAL must report `complete: true` and zero inconsistencies, so the
|
|
/// indicator is meaningful (not always-false).
|
|
#[test]
|
|
fn scope_stats_clean_wal_reports_complete() {
|
|
let home = home_with_scoped_wal_data();
|
|
let output = tidalctl_bin()
|
|
.args(["scope-stats", "--path", home.path().to_str().unwrap()])
|
|
.output()
|
|
.expect("run tidalctl scope-stats");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
|
let json: serde_json::Value = serde_json::from_str(&stdout).expect("parse JSON");
|
|
let body = &json["scope_stats"];
|
|
assert_eq!(body["complete"], true, "clean WAL is complete: {stdout}");
|
|
assert_eq!(
|
|
body["inconsistency_count"], 0,
|
|
"clean WAL has no inconsistencies: {stdout}"
|
|
);
|
|
}
|