1use std::{
17 any::Any,
18 env,
19 ffi::OsStr,
20 io,
21 panic::{self, AssertUnwindSafe},
22 path::{Path, PathBuf},
23 process::{Command, ExitCode, Output, Stdio},
24 thread,
25 time::{Duration, Instant},
26};
27
28use nautilus_event_store::{
29 EventStoreError, IndexDrift, MarkerCountKind, MarkerFinding, MarkerRecordKind, MarkerVerifier,
30 MarkerVerifyReport, RedbMarkerBackend, Verifier, VerifyError, VerifyFinding, VerifyReport,
31};
32
33const EXIT_CLEAN: u8 = 0;
34const EXIT_CORRUPT: u8 = 1;
35const EXIT_ERROR: u8 = 2;
36const WORKER_POLL_INTERVAL: Duration = Duration::from_millis(100);
37const DEFAULT_WORKER_TIMEOUT: Duration = Duration::from_secs(30);
38const TIMEOUT_ENV: &str = "NAUTILUS_EVENT_STORE_VERIFY_TIMEOUT_SECS";
39#[cfg(debug_assertions)]
40const WORKER_SLEEP_ENV: &str = "NAUTILUS_EVENT_STORE_VERIFY_SLEEP_WORKER_MS";
41
42enum WorkerOutput {
43 Exited(Output),
44 TimedOut { output: Output, timeout: Duration },
45}
46
47enum MarkerScan {
48 Absent,
49 Present(MarkerVerifyReport),
50}
51
52fn main() -> ExitCode {
53 let mut args = env::args_os();
54 let program = args.next().unwrap_or_else(|| OsStr::new("verify").into());
55 let Some(first) = args.next() else {
56 print_usage(&program);
57 return ExitCode::from(EXIT_ERROR);
58 };
59
60 if first.as_os_str() == OsStr::new("--worker") {
61 let Some(path) = args.next() else {
62 print_usage(&program);
63 return ExitCode::from(EXIT_ERROR);
64 };
65
66 if args.next().is_some() {
67 print_usage(&program);
68 return ExitCode::from(EXIT_ERROR);
69 }
70
71 let path = PathBuf::from(path);
72 return verify_run_file_worker(path.as_path());
73 }
74
75 if args.next().is_some() {
76 print_usage(&program);
77 return ExitCode::from(EXIT_ERROR);
78 }
79
80 let path = PathBuf::from(first);
81 verify_run_file(path.as_path())
82}
83
84fn verify_run_file(path: &Path) -> ExitCode {
85 let output = match run_worker(path) {
86 Ok(output) => output,
87 Err(e) => {
88 eprintln!("error path={} error=\"worker: {e}\"", path.display());
89 return ExitCode::from(EXIT_ERROR);
90 }
91 };
92
93 match output {
94 WorkerOutput::Exited(output) => classify_worker_exit(path, &output),
95 WorkerOutput::TimedOut { output, timeout } => {
96 println!(
97 "corrupt path={} worker_status=\"timeout after {}s\" quarantine=not-performed",
98 path.display(),
99 timeout.as_secs(),
100 );
101 relay_output(&output);
102 ExitCode::from(EXIT_CORRUPT)
103 }
104 }
105}
106
107fn classify_worker_exit(path: &Path, output: &Output) -> ExitCode {
108 match output.status.code() {
109 Some(code)
110 if code == i32::from(EXIT_CLEAN)
111 || code == i32::from(EXIT_CORRUPT)
112 || code == i32::from(EXIT_ERROR) =>
113 {
114 relay_output(output);
115 ExitCode::from(u8::try_from(code).expect("known verifier exit code"))
116 }
117 _ => {
118 println!(
119 "corrupt path={} worker_status=\"{}\" quarantine=not-performed",
120 path.display(),
121 output.status,
122 );
123 relay_output(output);
124 ExitCode::from(EXIT_CORRUPT)
125 }
126 }
127}
128
129fn verify_run_file_worker(path: &Path) -> ExitCode {
130 abort_worker_when_requested();
131 sleep_worker_when_requested();
132
133 let previous_hook = panic::take_hook();
134 panic::set_hook(Box::new(|_| {}));
135 let result = panic::catch_unwind(AssertUnwindSafe(|| verify_run_file_inner(path)));
136 panic::set_hook(previous_hook);
137
138 match result {
139 Ok(code) => code,
140 Err(payload) => {
141 println!(
142 "corrupt path={} panic=\"{}\" quarantine=not-performed",
143 path.display(),
144 panic_message(payload.as_ref()),
145 );
146 ExitCode::from(EXIT_CORRUPT)
147 }
148 }
149}
150
151fn verify_run_file_inner(path: &Path) -> ExitCode {
152 match Verifier::open_redb_file(path).and_then(|verifier| verifier.verify()) {
153 Ok(report) => match scan_marker_sidecar(path, &report) {
154 Ok(markers) => print_report(&report, &markers),
155 Err((marker_path, err)) => print_marker_error(marker_path.as_path(), &err),
156 },
157 Err(e) => print_error(path, &e),
158 }
159}
160
161fn scan_marker_sidecar(
162 path: &Path,
163 entry_report: &VerifyReport,
164) -> Result<MarkerScan, (PathBuf, VerifyError)> {
165 let Some(marker_path) = marker_sidecar_path(path) else {
166 return Ok(MarkerScan::Absent);
167 };
168
169 if !marker_path.exists() {
170 return Ok(MarkerScan::Absent);
171 }
172
173 let backend = RedbMarkerBackend::open_read_only_file(&marker_path)
174 .map_err(|e| (marker_path.clone(), VerifyError::Backend(e)))?;
175 let report = MarkerVerifier::scan(&backend, entry_report.high_watermark)
176 .map_err(|e| (marker_path.clone(), VerifyError::Backend(e)))?;
177 Ok(MarkerScan::Present(report))
178}
179
180fn marker_sidecar_path(path: &Path) -> Option<PathBuf> {
181 let stem = path.file_stem()?;
182 let mut file_name = stem.to_os_string();
183 file_name.push(".markers.redb");
184 Some(path.with_file_name(file_name))
185}
186
187fn print_report(report: &VerifyReport, markers: &MarkerScan) -> ExitCode {
188 let marker_findings = marker_finding_count(markers);
189
190 if report.is_clean() && marker_findings == 0 {
191 println!(
192 "clean run_id={} status={:?} high_watermark={} entries_scanned={} {}",
193 report.run_id,
194 report.status,
195 report.high_watermark,
196 report.entries_scanned,
197 marker_summary(markers),
198 );
199 return ExitCode::from(EXIT_CLEAN);
200 }
201
202 println!(
203 "corrupt run_id={} status={:?} high_watermark={} entries_scanned={} findings={} marker_findings={} {} quarantine=not-performed",
204 report.run_id,
205 report.status,
206 report.high_watermark,
207 report.entries_scanned,
208 report.findings.len() + marker_findings,
209 marker_findings,
210 marker_summary(markers),
211 );
212
213 for finding in &report.findings {
214 print_finding(finding);
215 }
216
217 if let MarkerScan::Present(report) = markers {
218 for finding in &report.findings {
219 print_marker_finding(finding);
220 }
221 }
222
223 ExitCode::from(EXIT_CORRUPT)
224}
225
226fn marker_finding_count(markers: &MarkerScan) -> usize {
227 match markers {
228 MarkerScan::Absent => 0,
229 MarkerScan::Present(report) => report.findings.len(),
230 }
231}
232
233fn marker_summary(markers: &MarkerScan) -> String {
234 match markers {
235 MarkerScan::Absent => "markers=absent".to_string(),
236 MarkerScan::Present(report) if report.is_clean() => format!(
237 "markers=clean marker_run_id={} marker_status={:?} marker_snapshots_scanned={} marker_hifi_scanned={} marker_gaps_scanned={} marker_dict_entries_scanned={}",
238 report.run_id,
239 report.status,
240 report.snapshots_scanned,
241 report.hifi_scanned,
242 report.gaps_scanned,
243 report.dict_entries_scanned,
244 ),
245 MarkerScan::Present(report) => format!(
246 "markers=corrupt marker_run_id={} marker_status={:?} marker_snapshots_scanned={} marker_hifi_scanned={} marker_gaps_scanned={} marker_dict_entries_scanned={} marker_findings={}",
247 report.run_id,
248 report.status,
249 report.snapshots_scanned,
250 report.hifi_scanned,
251 report.gaps_scanned,
252 report.dict_entries_scanned,
253 report.findings.len(),
254 ),
255 }
256}
257
258fn print_error(path: &Path, err: &VerifyError) -> ExitCode {
259 if matches!(err, VerifyError::Backend(EventStoreError::Corrupted(_))) {
260 println!(
261 "corrupt path={} error=\"{}\" quarantine=not-performed",
262 path.display(),
263 err,
264 );
265 return ExitCode::from(EXIT_CORRUPT);
266 }
267
268 eprintln!("error path={} error=\"{}\"", path.display(), err);
269 ExitCode::from(EXIT_ERROR)
270}
271
272fn print_marker_error(path: &Path, err: &VerifyError) -> ExitCode {
273 if matches!(err, VerifyError::Backend(EventStoreError::Corrupted(_))) {
274 println!(
275 "corrupt path={} markers=error error=\"{}\" quarantine=not-performed",
276 path.display(),
277 err,
278 );
279 return ExitCode::from(EXIT_CORRUPT);
280 }
281
282 eprintln!(
283 "error path={} markers=error error=\"{}\"",
284 path.display(),
285 err
286 );
287 ExitCode::from(EXIT_ERROR)
288}
289
290fn print_finding(finding: &VerifyFinding) {
291 match finding {
292 VerifyFinding::HashMismatch { seq } => {
293 println!("- hash mismatch at seq {seq}");
294 }
295 VerifyFinding::Gap { range } => {
296 println!("- gap from seq {} to {}", range.from, range.to);
297 }
298 VerifyFinding::SeqMismatch {
299 table_key,
300 embedded_seq,
301 } => {
302 println!("- seq mismatch at table key {table_key}: embedded seq was {embedded_seq}");
303 }
304 VerifyFinding::IndexDrift { kind, key, drift } => {
305 print_index_drift(*kind, key, *drift);
306 }
307 VerifyFinding::ManifestMismatch { kind, reason } => {
308 println!("- manifest mismatch {kind:?}: {reason}");
309 }
310 VerifyFinding::SnapshotAnchorInvalid { reason } => {
311 println!("- snapshot anchor invalid: {reason}");
312 }
313 }
314}
315
316fn print_index_drift(kind: nautilus_event_store::IndexKind, key: &str, drift: IndexDrift) {
317 match drift {
318 IndexDrift::DanglingTarget { stored_seq } => {
319 println!("- index drift {kind:?} key={key}: dangling target seq {stored_seq}");
320 }
321 IndexDrift::TargetCorrupted { stored_seq } => {
322 println!("- index drift {kind:?} key={key}: corrupted target seq {stored_seq}");
323 }
324 }
325}
326
327fn print_marker_finding(finding: &MarkerFinding) {
328 match finding {
329 MarkerFinding::ManifestCountMismatch {
330 kind,
331 manifest_count,
332 scanned_count,
333 } => {
334 println!(
335 "- marker manifest count mismatch {}: manifest={manifest_count} scanned={scanned_count}",
336 marker_count_name(*kind),
337 );
338 }
339 MarkerFinding::MarkerSeqGap {
340 from_marker_seq,
341 to_marker_seq,
342 } => {
343 println!("- marker seq gap from {from_marker_seq} to {to_marker_seq}");
344 }
345 MarkerFinding::MarkerSeqOverlap {
346 from_marker_seq,
347 to_marker_seq,
348 } => {
349 println!("- marker seq overlap from {from_marker_seq} to {to_marker_seq}");
350 }
351 MarkerFinding::InvalidMarkerGap {
352 from_marker_seq,
353 to_marker_seq,
354 } => {
355 println!("- invalid marker gap from {from_marker_seq} to {to_marker_seq}");
356 }
357 MarkerFinding::EventSeqRegressed {
358 marker_seq,
359 previous_event_seq_before,
360 event_seq_before,
361 } => {
362 println!(
363 "- marker event seq regressed marker_seq={marker_seq}: previous={previous_event_seq_before} current={event_seq_before}",
364 );
365 }
366 MarkerFinding::EventSeqExceedsHighWatermark {
367 marker_seq,
368 event_seq_before,
369 high_watermark,
370 } => {
371 println!(
372 "- marker event seq exceeds high watermark marker_seq={marker_seq}: event_seq_before={event_seq_before} high_watermark={high_watermark}",
373 );
374 }
375 MarkerFinding::CursorCountRegressed {
376 marker_seq,
377 slot,
378 previous_count,
379 count,
380 } => {
381 println!(
382 "- marker cursor count regressed marker_seq={marker_seq} slot={slot}: previous={previous_count} current={count}",
383 );
384 }
385 MarkerFinding::CursorTsInitRegressed {
386 marker_seq,
387 slot,
388 previous_ts_init_hi,
389 ts_init_hi,
390 } => {
391 println!(
392 "- marker cursor ts_init_hi regressed marker_seq={marker_seq} slot={slot}: previous={} current={}",
393 previous_ts_init_hi.as_u64(),
394 ts_init_hi.as_u64(),
395 );
396 }
397 MarkerFinding::HashMismatch {
398 record,
399 marker_seq,
400 slot,
401 } => {
402 print_marker_hash_mismatch(*record, *marker_seq, *slot);
403 }
404 }
405}
406
407fn print_marker_hash_mismatch(
408 record: MarkerRecordKind,
409 marker_seq: Option<u64>,
410 slot: Option<u32>,
411) {
412 match (marker_seq, slot) {
413 (Some(marker_seq), Some(slot)) => println!(
414 "- marker hash mismatch {} marker_seq={marker_seq} slot={slot}",
415 marker_record_name(record),
416 ),
417 (Some(marker_seq), None) => println!(
418 "- marker hash mismatch {} marker_seq={marker_seq}",
419 marker_record_name(record),
420 ),
421 (None, Some(slot)) => println!(
422 "- marker hash mismatch {} slot={slot}",
423 marker_record_name(record),
424 ),
425 (None, None) => println!("- marker hash mismatch {}", marker_record_name(record)),
426 }
427}
428
429fn marker_record_name(record: MarkerRecordKind) -> &'static str {
430 match record {
431 MarkerRecordKind::Snapshot => "snapshot",
432 MarkerRecordKind::HiFi => "hifi",
433 MarkerRecordKind::Gap => "gap",
434 MarkerRecordKind::Dict => "dict",
435 }
436}
437
438fn marker_count_name(kind: MarkerCountKind) -> &'static str {
439 match kind {
440 MarkerCountKind::Snapshot => "snapshot",
441 MarkerCountKind::HiFi => "hifi",
442 MarkerCountKind::Gap => "gap",
443 MarkerCountKind::Dict => "dict",
444 }
445}
446
447fn print_usage(program: &OsStr) {
448 eprintln!("usage: {} <run-file.redb>", program.to_string_lossy());
449}
450
451fn run_worker(path: &Path) -> io::Result<WorkerOutput> {
452 let current_exe = env::current_exe()?;
453 let mut child = Command::new(current_exe)
454 .arg("--worker")
455 .arg(path)
456 .stdout(Stdio::piped())
457 .stderr(Stdio::piped())
458 .spawn()?;
459 let timeout = worker_timeout();
460 let deadline = Instant::now() + timeout;
461
462 loop {
463 if child.try_wait()?.is_some() {
464 return child.wait_with_output().map(WorkerOutput::Exited);
465 }
466
467 if Instant::now() >= deadline {
468 let _ = child.kill();
469 return child
470 .wait_with_output()
471 .map(|output| WorkerOutput::TimedOut { output, timeout });
472 }
473
474 thread::sleep(WORKER_POLL_INTERVAL);
475 }
476}
477
478fn relay_output(output: &Output) {
479 if !output.stdout.is_empty() {
480 print!("{}", String::from_utf8_lossy(&output.stdout));
481 }
482
483 if !output.stderr.is_empty() {
484 eprint!("{}", String::from_utf8_lossy(&output.stderr));
485 }
486}
487
488fn panic_message(payload: &(dyn Any + Send)) -> &str {
489 if let Some(message) = payload.downcast_ref::<&'static str>() {
490 message
491 } else if let Some(message) = payload.downcast_ref::<String>() {
492 message.as_str()
493 } else {
494 "unknown panic"
495 }
496}
497
498fn worker_timeout() -> Duration {
499 env::var(TIMEOUT_ENV)
500 .ok()
501 .and_then(|value| value.parse::<u64>().ok())
502 .map_or(DEFAULT_WORKER_TIMEOUT, Duration::from_secs)
503}
504
505fn abort_worker_when_requested() {
506 #[cfg(debug_assertions)]
507 if env::var_os("NAUTILUS_EVENT_STORE_VERIFY_ABORT_WORKER").is_some() {
508 std::process::abort();
509 }
510}
511
512fn sleep_worker_when_requested() {
513 #[cfg(debug_assertions)]
514 if let Some(raw) = env::var_os(WORKER_SLEEP_ENV)
515 && let Ok(ms) = raw.to_string_lossy().parse::<u64>()
516 {
517 thread::sleep(Duration::from_millis(ms));
518 }
519}