1use std::path::PathBuf;
11
12use clap::{Args, Parser, Subcommand};
13use fathomdb::{
14 CheckIntegrityOpts, CorruptionLocator, DumpProfileReport, DumpRowCountsReport,
15 DumpSchemaReport, Engine, EngineError, EngineOpenError, ExciseReport, Finding, IntegrityReport,
16 RebuildKind, RebuildReport, SafeExportArtifact, SchemaObject, Section, TraceReport,
17 TruncateWalReport, TruncateWalStatus, VerifyEmbedderReport, VerifyEmbedderStatus,
18};
19use serde_json::{json, Value};
20
21pub mod exit_code {
26 pub const OK: i32 = 0;
28
29 pub const RECOVERY_ACCEPTED_LOSS: i32 = 64;
31
32 pub const DOCTOR_FOUND_ISSUES: i32 = 65;
34
35 pub const EXPORT_FAILURE: i32 = 66;
37
38 pub const UNRECOVERABLE: i32 = 70;
40
41 pub const LOCK_HELD: i32 = 71;
43}
44
45#[derive(Debug, Parser)]
47#[command(name = "fathomdb", version, about = "FathomDB operator CLI", long_about = None)]
48pub struct Cli {
49 #[command(subcommand)]
50 pub command: Command,
51}
52
53#[derive(Debug, Subcommand)]
58pub enum Command {
59 Recover(RecoverArgs),
61 Doctor(DoctorArgs),
63}
64
65#[derive(Debug, Args)]
67pub struct DoctorArgs {
68 #[command(subcommand)]
69 pub command: DoctorCommand,
70}
71
72#[derive(Debug, Args)]
77pub struct RecoverArgs {
78 #[arg(long)]
80 pub accept_data_loss: bool,
81
82 #[arg(long)]
84 pub truncate_wal: bool,
85
86 #[arg(long)]
88 pub rebuild_vec0: bool,
89
90 #[arg(long)]
92 pub rebuild_projections: bool,
93
94 #[arg(long)]
96 pub excise_source: Option<String>,
97
98 #[arg(long)]
100 pub json: bool,
101
102 pub db_path: PathBuf,
104}
105
106#[derive(Debug, Subcommand)]
108pub enum DoctorCommand {
109 CheckIntegrity(CheckIntegrityArgs),
111 SafeExport(SafeExportArgs),
113 VerifyEmbedder(VerifyEmbedderArgs),
115 Trace(TraceArgs),
117 DumpSchema(SimpleDoctorArgs),
119 DumpRowCounts(SimpleDoctorArgs),
121 DumpProfile(SimpleDoctorArgs),
123}
124
125#[derive(Debug, Args)]
129pub struct SimpleDoctorArgs {
130 #[arg(long)]
132 pub json: bool,
133
134 pub db_path: PathBuf,
136}
137
138#[derive(Debug, Args)]
140pub struct CheckIntegrityArgs {
141 #[arg(long)]
143 pub quick: bool,
144
145 #[arg(long)]
147 pub full: bool,
148
149 #[arg(long = "round-trip")]
151 pub round_trip: bool,
152
153 #[arg(long)]
155 pub pretty: bool,
156
157 #[arg(long)]
159 pub json: bool,
160
161 pub db_path: PathBuf,
163}
164
165#[derive(Debug, Args)]
167pub struct SafeExportArgs {
168 pub out: PathBuf,
170
171 #[arg(long)]
173 pub manifest: Option<PathBuf>,
174
175 #[arg(long)]
177 pub json: bool,
178
179 pub db_path: PathBuf,
181}
182
183#[derive(Debug, Args)]
187pub struct VerifyEmbedderArgs {
188 #[arg(long)]
191 pub identity: String,
192
193 #[arg(long)]
195 pub dimension: u32,
196
197 #[arg(long)]
199 pub json: bool,
200
201 pub db_path: PathBuf,
203}
204
205#[derive(Debug, Args)]
207pub struct TraceArgs {
208 #[arg(long = "source-ref")]
210 pub source_ref: String,
211
212 #[arg(long)]
214 pub json: bool,
215
216 pub db_path: PathBuf,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum CliOutcome {
224 Clean,
226 Findings,
228 ExportFailure,
230 RecoveryAcceptedLoss,
232 LockHeld,
234 Unrecoverable,
236}
237
238#[must_use]
240pub fn outcome_to_exit_code(outcome: CliOutcome) -> i32 {
241 match outcome {
242 CliOutcome::Clean => exit_code::OK,
243 CliOutcome::Findings => exit_code::DOCTOR_FOUND_ISSUES,
244 CliOutcome::ExportFailure => exit_code::EXPORT_FAILURE,
245 CliOutcome::RecoveryAcceptedLoss => exit_code::RECOVERY_ACCEPTED_LOSS,
246 CliOutcome::LockHeld => exit_code::LOCK_HELD,
247 CliOutcome::Unrecoverable => exit_code::UNRECOVERABLE,
248 }
249}
250
251#[must_use]
254pub fn engine_error_to_outcome(err: &EngineError) -> CliOutcome {
255 match err {
256 EngineError::Closing => CliOutcome::LockHeld,
257 _ => CliOutcome::Unrecoverable,
258 }
259}
260
261#[must_use]
264pub fn engine_open_error_to_outcome(err: &EngineOpenError) -> CliOutcome {
265 match err {
266 EngineOpenError::DatabaseLocked { .. } => CliOutcome::LockHeld,
267 _ => CliOutcome::Unrecoverable,
268 }
269}
270
271#[must_use]
282pub fn run(cli: Cli) -> i32 {
283 match cli.command {
284 Command::Recover(args) => run_recover(args),
285 Command::Doctor(d) => run_doctor(d.command),
286 }
287}
288
289fn run_recover(args: RecoverArgs) -> i32 {
290 if !args.accept_data_loss {
291 println!(
292 r#"{{"status":"refused","verb":"recover","code":"E_RECOVER_REQUIRES_ACCEPT_DATA_LOSS"}}"#
293 );
294 return exit_code::UNRECOVERABLE;
295 }
296
297 if args.rebuild_projections {
298 return wire_recover(&args.db_path, "rebuild-projections", |e| {
299 e.rebuild_projections().map(|r| rebuild_report_json("rebuild-projections", &r))
300 });
301 }
302 if args.rebuild_vec0 {
303 return wire_recover(&args.db_path, "rebuild-vec0", |e| {
304 e.rebuild_vec0().map(|r| rebuild_report_json("rebuild-vec0", &r))
305 });
306 }
307 if let Some(source_id) = args.excise_source.as_deref() {
308 return wire_recover(&args.db_path, "excise-source", |e| {
309 e.excise_source(source_id).map(|r| excise_report_json(&r))
310 });
311 }
312 if args.truncate_wal {
313 return wire_recover(&args.db_path, "truncate-wal", |e| {
314 e.truncate_wal().map(|r| truncate_wal_report_json(&r))
315 });
316 }
317
318 println!(r#"{{"status":"not_implemented","verb":"recover"}}"#);
320 exit_code::UNRECOVERABLE
321}
322
323fn run_doctor(cmd: DoctorCommand) -> i32 {
324 match cmd {
325 DoctorCommand::CheckIntegrity(args) => {
326 let opts = CheckIntegrityOpts {
327 quick: args.quick,
328 full: args.full,
329 round_trip: args.round_trip,
330 };
331 run_doctor_verb(&args.db_path, "check-integrity", |e| {
332 e.check_integrity(opts).map(|r| integrity_report_outcome(&r))
333 })
334 }
335 DoctorCommand::SafeExport(args) => {
336 let manifest = args.manifest.clone().unwrap_or_else(|| {
337 let mut p = args.out.clone();
338 let name = p
339 .file_name()
340 .map(|s| s.to_string_lossy().into_owned())
341 .unwrap_or_else(|| "export".to_string());
342 p.set_file_name(format!("{name}.manifest.json"));
343 p
344 });
345 run_doctor_verb_with_error_outcome(
346 &args.db_path,
347 "safe-export",
348 CliOutcome::ExportFailure,
349 |e| {
350 e.safe_export(&args.out, &manifest)
351 .map(|r| (safe_export_json(&r), CliOutcome::Clean))
352 },
353 )
354 }
355 DoctorCommand::Trace(args) => run_doctor_verb(&args.db_path, "trace", |e| {
356 e.trace_source_ref(&args.source_ref).map(|r| (trace_report_json(&r), CliOutcome::Clean))
357 }),
358 DoctorCommand::VerifyEmbedder(args) => {
359 let identity = args.identity.clone();
360 let dimension = args.dimension;
361 run_doctor_verb(&args.db_path, "verify-embedder", |e| {
362 e.verify_embedder(&identity, dimension)
363 .map(|r| (verify_embedder_report_json(&r), CliOutcome::Clean))
364 })
365 }
366 DoctorCommand::DumpSchema(args) => run_doctor_verb(&args.db_path, "dump-schema", |e| {
367 e.dump_schema().map(|r| (dump_schema_report_json(&r), CliOutcome::Clean))
368 }),
369 DoctorCommand::DumpRowCounts(args) => {
370 run_doctor_verb(&args.db_path, "dump-row-counts", |e| {
371 e.dump_row_counts().map(|r| (dump_row_counts_report_json(&r), CliOutcome::Clean))
372 })
373 }
374 DoctorCommand::DumpProfile(args) => run_doctor_verb(&args.db_path, "dump-profile", |e| {
375 e.dump_profile().map(|r| (dump_profile_report_json(&r), CliOutcome::Clean))
376 }),
377 }
378}
379
380fn run_doctor_verb<F>(db_path: &std::path::Path, verb: &str, f: F) -> i32
383where
384 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
385{
386 run_doctor_verb_inner(db_path, verb, None, f)
387}
388
389fn run_doctor_verb_with_error_outcome<F>(
394 db_path: &std::path::Path,
395 verb: &str,
396 error_outcome: CliOutcome,
397 f: F,
398) -> i32
399where
400 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
401{
402 run_doctor_verb_inner(db_path, verb, Some(error_outcome), f)
403}
404
405fn run_doctor_verb_inner<F>(
406 db_path: &std::path::Path,
407 verb: &str,
408 error_outcome: Option<CliOutcome>,
409 f: F,
410) -> i32
411where
412 F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
413{
414 let opened = match Engine::open(db_path.to_path_buf()) {
415 Ok(o) => o,
416 Err(err) => return emit_engine_open_error(verb, &err),
417 };
418 match f(&opened.engine) {
419 Ok((value, outcome)) => {
420 println!("{value}");
421 outcome_to_exit_code(outcome)
422 }
423 Err(err) => match error_outcome {
424 Some(outcome) => emit_engine_error_with_outcome(verb, &err, outcome),
425 None => emit_engine_error(verb, &err),
426 },
427 }
428}
429
430fn wire_recover<F>(db_path: &std::path::Path, sub_verb: &str, f: F) -> i32
433where
434 F: FnOnce(&Engine) -> Result<Value, EngineError>,
435{
436 let opened = match Engine::open(db_path.to_path_buf()) {
437 Ok(o) => o,
438 Err(err) => return emit_engine_open_error(sub_verb, &err),
439 };
440 match f(&opened.engine) {
441 Ok(value) => {
442 println!("{value}");
443 outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss)
444 }
445 Err(err) => emit_engine_error(sub_verb, &err),
446 }
447}
448
449fn emit_engine_error(verb: &str, err: &EngineError) -> i32 {
450 emit_engine_error_with_outcome(verb, err, engine_error_to_outcome(err))
451}
452
453fn emit_engine_error_with_outcome(verb: &str, err: &EngineError, outcome: CliOutcome) -> i32 {
454 let payload = json!({
455 "status": "error",
456 "verb": verb,
457 "code": engine_error_code(err),
458 "detail": err.to_string(),
459 });
460 println!("{payload}");
461 outcome_to_exit_code(outcome)
462}
463
464fn emit_engine_open_error(verb: &str, err: &EngineOpenError) -> i32 {
465 let outcome = engine_open_error_to_outcome(err);
466 let payload = json!({
467 "status": "error",
468 "verb": verb,
469 "code": engine_open_error_code(err),
470 "detail": err.to_string(),
471 });
472 println!("{payload}");
473 outcome_to_exit_code(outcome)
474}
475
476fn engine_error_code(err: &EngineError) -> &'static str {
477 match err {
478 EngineError::Storage => "StorageError",
479 EngineError::Projection => "ProjectionError",
480 EngineError::Vector => "VectorError",
481 EngineError::Embedder => "EmbedderError",
482 EngineError::EmbedderNotConfigured => "EmbedderNotConfiguredError",
483 EngineError::KindNotVectorIndexed => "KindNotVectorIndexedError",
484 EngineError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
485 EngineError::Scheduler => "SchedulerError",
486 EngineError::OpStore => "OpStoreError",
487 EngineError::WriteValidation => "WriteValidationError",
488 EngineError::SchemaValidation => "SchemaValidationError",
489 EngineError::Overloaded => "OverloadedError",
490 EngineError::Closing => "ClosingError",
491 }
492}
493
494fn engine_open_error_code(err: &EngineOpenError) -> &'static str {
495 match err {
496 EngineOpenError::DatabaseLocked { .. } => "DatabaseLockedError",
497 EngineOpenError::Corruption(_) => "CorruptionError",
498 EngineOpenError::IncompatibleSchemaVersion { .. } => "IncompatibleSchemaVersionError",
499 EngineOpenError::MigrationError { .. } => "MigrationError",
500 EngineOpenError::EmbedderIdentityMismatch { .. } => "EmbedderIdentityMismatchError",
501 EngineOpenError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
502 EngineOpenError::Io { .. } => "IoError",
503 }
504}
505
506fn integrity_report_outcome(report: &IntegrityReport) -> (Value, CliOutcome) {
509 let any_findings = matches!(report.physical, Section::Findings(_))
510 || matches!(report.logical, Section::Findings(_))
511 || matches!(report.semantic, Section::Findings(_));
512 let body = json!({
513 "verb": "check-integrity",
514 "physical": section_json(&report.physical),
515 "logical": section_json(&report.logical),
516 "semantic": section_json(&report.semantic),
517 });
518 let outcome = if any_findings { CliOutcome::Findings } else { CliOutcome::Clean };
519 (body, outcome)
520}
521
522fn section_json(section: &Section) -> Value {
523 match section {
524 Section::Clean => json!({ "status": "clean", "findings": [] }),
525 Section::Findings(findings) => json!({
526 "status": "findings",
527 "findings": findings.iter().map(finding_json).collect::<Vec<_>>(),
528 }),
529 }
530}
531
532fn finding_json(f: &Finding) -> Value {
533 json!({
534 "code": f.code,
535 "stage": f.stage,
536 "locator": locator_json(&f.locator),
537 "doc_anchor": f.doc_anchor,
538 "detail": f.detail,
539 })
540}
541
542fn locator_json(loc: &CorruptionLocator) -> Value {
543 match loc {
544 CorruptionLocator::FileOffset { offset } => {
545 json!({ "kind": "file_offset", "offset": offset })
546 }
547 CorruptionLocator::PageId { page } => json!({ "kind": "page_id", "page": page }),
548 CorruptionLocator::TableRow { table, rowid } => {
549 json!({ "kind": "table_row", "table": table, "rowid": rowid })
550 }
551 CorruptionLocator::Vec0ShadowRow { partition, rowid } => {
552 json!({ "kind": "vec0_shadow_row", "partition": partition, "rowid": rowid })
553 }
554 CorruptionLocator::MigrationStep { from, to } => {
555 json!({ "kind": "migration_step", "from": from, "to": to })
556 }
557 CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
558 json!({
559 "kind": "opaque_sqlite_error",
560 "sqlite_extended_code": sqlite_extended_code,
561 })
562 }
563 }
564}
565
566fn safe_export_json(a: &SafeExportArtifact) -> Value {
567 json!({
568 "verb": "safe-export",
569 "export_path": a.export_path.to_string_lossy(),
570 "manifest_path": a.manifest_path.to_string_lossy(),
571 "manifest_sha256": a.manifest_sha256,
572 })
573}
574
575fn trace_report_json(t: &TraceReport) -> Value {
576 json!({
577 "verb": "trace",
578 "source_ref": t.source_ref,
579 "events": t.events.iter().map(|e| json!({
580 "write_cursor": e.write_cursor,
581 "kind": e.kind,
582 "table": e.table,
583 })).collect::<Vec<_>>(),
584 })
585}
586
587fn rebuild_report_json(verb: &'static str, r: &RebuildReport) -> Value {
588 let kind = match r.kind {
589 RebuildKind::Projections => "projections",
590 RebuildKind::Vec0 => "vec0",
591 };
592 json!({
593 "verb": verb,
594 "kind": kind,
595 "rows_invalidated": r.rows_invalidated,
596 "rows_rebuilt": r.rows_rebuilt,
597 "projection_cursor_after": r.projection_cursor_after,
598 })
599}
600
601fn excise_report_json(r: &ExciseReport) -> Value {
602 json!({
603 "verb": "excise-source",
604 "source_ref": r.source_ref,
605 "nodes_excised": r.nodes_excised,
606 "edges_excised": r.edges_excised,
607 "projections_invalidated": r.projections_invalidated,
608 })
609}
610
611fn verify_embedder_report_json(r: &VerifyEmbedderReport) -> Value {
612 let status = match r.status {
613 VerifyEmbedderStatus::Match => "match",
614 VerifyEmbedderStatus::IdentityMismatch => "identity_mismatch",
615 VerifyEmbedderStatus::DimensionMismatch => "dimension_mismatch",
616 VerifyEmbedderStatus::BothMismatch => "both_mismatch",
617 };
618 json!({
619 "verb": "verify-embedder",
620 "stored_identity": r.stored_identity,
621 "stored_dimension": r.stored_dimension,
622 "supplied_identity": r.supplied_identity,
623 "supplied_dimension": r.supplied_dimension,
624 "status": status,
625 })
626}
627
628fn schema_object_json(o: &SchemaObject) -> Value {
629 json!({ "name": o.name, "sql": o.sql })
630}
631
632fn dump_schema_report_json(r: &DumpSchemaReport) -> Value {
633 json!({
634 "verb": "dump-schema",
635 "user_version": r.user_version,
636 "tables": r.tables.iter().map(schema_object_json).collect::<Vec<_>>(),
637 "indexes": r.indexes.iter().map(schema_object_json).collect::<Vec<_>>(),
638 })
639}
640
641fn dump_row_counts_report_json(r: &DumpRowCountsReport) -> Value {
642 json!({
643 "verb": "dump-row-counts",
644 "counts": r.counts.iter().map(|c| json!({
645 "name": c.name,
646 "rows": c.rows,
647 })).collect::<Vec<_>>(),
648 })
649}
650
651fn dump_profile_report_json(r: &DumpProfileReport) -> Value {
652 json!({
653 "verb": "dump-profile",
654 "embedder_identity": r.embedder_identity,
655 "embedder_dimension": r.embedder_dimension,
656 "vectorized_kinds": r.vectorized_kinds,
657 })
658}
659
660fn truncate_wal_report_json(r: &TruncateWalReport) -> Value {
661 let status = match r.status {
662 TruncateWalStatus::Done => "done",
663 TruncateWalStatus::Busy => "busy",
664 };
665 json!({
666 "verb": "truncate-wal",
667 "status": status,
668 "busy": r.busy,
669 "log_frames": r.log_frames,
670 "checkpointed_frames": r.checkpointed_frames,
671 })
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 #[test]
679 fn outcome_mapping_covers_cli_md_exit_classes() {
680 assert_eq!(outcome_to_exit_code(CliOutcome::Clean), 0);
681 assert_eq!(outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss), 64);
682 assert_eq!(outcome_to_exit_code(CliOutcome::Findings), 65);
683 assert_eq!(outcome_to_exit_code(CliOutcome::ExportFailure), 66);
684 assert_eq!(outcome_to_exit_code(CliOutcome::Unrecoverable), 70);
685 assert_eq!(outcome_to_exit_code(CliOutcome::LockHeld), 71);
686 }
687
688 #[test]
689 fn engine_error_storage_maps_to_unrecoverable() {
690 assert_eq!(engine_error_to_outcome(&EngineError::Storage), CliOutcome::Unrecoverable);
691 assert_eq!(engine_error_to_outcome(&EngineError::Closing), CliOutcome::LockHeld);
692 }
693
694 #[test]
695 fn engine_open_database_locked_maps_to_lock_held() {
696 let err = EngineOpenError::DatabaseLocked { holder_pid: Some(1234) };
697 assert_eq!(engine_open_error_to_outcome(&err), CliOutcome::LockHeld);
698 }
699}