Skip to main content

verify/
verify.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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}