Skip to main content

hayson_engine/database/
verify.rs

1use std::fs;
2use std::io::{self, BufRead, BufReader};
3use std::path::{Path, PathBuf};
4
5use serde_json::Value;
6
7use crate::catalog::{parse_catalog, Catalog, TableEntry};
8use crate::schema::physical_path_of;
9use crate::storage::{parse_entry, partition_names_in, LOG_SUFFIX};
10
11use super::database::{Database, LockMode, OpenOptions};
12use super::layout::{Layout, LayoutOptions};
13
14/// How much a finding matters to someone about to open the directory.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Severity {
17    /// The directory cannot be opened as it is, or data in it cannot be read back.
18    Error,
19    /// Opening works; the engine repairs it on the next writable open, or it is only worth a look.
20    Warning,
21}
22
23/// One finding of [`verify`]. `code` is a stable kebab-case identifier callers can match on;
24/// `message` is for people.
25#[derive(Debug, Clone, PartialEq)]
26pub struct Problem {
27    pub severity: Severity,
28    pub code: &'static str,
29    pub table: Option<String>,
30    /// Path of the file concerned, relative to the database directory.
31    pub file: Option<String>,
32    /// Byte offset of the offending line in `file`.
33    pub offset: Option<u64>,
34    pub message: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct VerifyOptions {
39    /// Also open the database read-only (no lock) and report relation orphans. Skipped when the
40    /// structural checks already found an error, because the open would only repeat it.
41    pub relations: bool,
42    /// Also check every table's image files: a referenced file that is missing, a file whose bytes
43    /// no longer hash to its name, a file no record references and, with `max_image_bytes`, a file
44    /// over that size. Reads every image file, and shares the read-only open `relations` uses.
45    pub images: bool,
46    /// Size in bytes above which an existing image file is reported as `image-oversize`.
47    pub max_image_bytes: Option<u64>,
48}
49
50impl Default for VerifyOptions {
51    fn default() -> Self {
52        VerifyOptions {
53            relations: true,
54            images: true,
55            max_image_bytes: None,
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct VerifyReport {
62    pub problems: Vec<Problem>,
63    pub tables_checked: usize,
64    pub log_lines_checked: u64,
65}
66
67impl VerifyReport {
68    pub fn has_errors(&self) -> bool {
69        self.problems.iter().any(|p| p.severity == Severity::Error)
70    }
71
72    pub fn is_clean(&self) -> bool {
73        self.problems.is_empty()
74    }
75}
76
77/// Checks a database directory without taking its lock or changing a byte, and reports what is
78/// wrong with it instead of failing on it: every damaged, missing or unreadable file becomes a
79/// [`Problem`], never an `Err` and never a panic.
80///
81/// Looks at the metadata document, each declared table's log files line by line, logs the catalog
82/// does not declare, an unfinished unit-of-work journal and (optionally) declared relations.
83pub fn verify(dir: &Path, options: &VerifyOptions) -> VerifyReport {
84    let mut report = VerifyReport::default();
85    let mut checker = Checker {
86        root: dir.to_path_buf(),
87        report: &mut report,
88    };
89    checker.run(options);
90    report
91}
92
93struct Checker<'a> {
94    root: PathBuf,
95    report: &'a mut VerifyReport,
96}
97
98impl Checker<'_> {
99    fn push(&mut self, severity: Severity, code: &'static str, message: String) -> &mut Problem {
100        self.report.problems.push(Problem {
101            severity,
102            code,
103            table: None,
104            file: None,
105            offset: None,
106            message,
107        });
108        self.report.problems.last_mut().expect("just pushed")
109    }
110
111    fn relative(&self, path: &Path) -> String {
112        path.strip_prefix(&self.root).unwrap_or(path).display().to_string()
113    }
114
115    fn run(&mut self, options: &VerifyOptions) {
116        if !self.root.is_dir() {
117            let message = format!("\"{}\" is not a directory", self.root.display());
118            self.push(Severity::Error, "directory-missing", message);
119            return;
120        }
121        let layout = match Database::resolve_layout(&self.root, &LayoutOptions::default()) {
122            Ok(layout) => layout,
123            Err(error) => {
124                self.push(Severity::Error, "layout-unresolved", error.to_string());
125                return;
126            }
127        };
128        let Some(catalog) = self.read_catalog(&layout) else {
129            return;
130        };
131
132        for (name, entry) in &catalog.tables {
133            self.check_table(&layout, name, entry);
134        }
135        self.check_orphan_logs(&layout, &catalog);
136        self.check_journal(&layout);
137
138        if (options.relations || options.images) && !self.report.has_errors() {
139            self.check_open(options);
140        }
141    }
142
143    fn read_catalog(&mut self, layout: &Layout) -> Option<Catalog> {
144        let path = layout.metadata_path();
145        let file = self.relative(path);
146        let raw = match fs::read_to_string(path) {
147            Ok(raw) => raw,
148            Err(error) if error.kind() == io::ErrorKind::NotFound => {
149                let holds_logs = !self.log_files_under(layout).is_empty();
150                let severity = if holds_logs { Severity::Error } else { Severity::Warning };
151                self.push(severity, "catalog-missing", format!("\"{file}\" does not exist"))
152                    .file = Some(file.clone());
153                return None;
154            }
155            Err(error) => {
156                self.push(Severity::Error, "catalog-unreadable", format!("\"{file}\": {error}"))
157                    .file = Some(file.clone());
158                return None;
159            }
160        };
161        let value: Value = match serde_json::from_str(&raw) {
162            Ok(value) => value,
163            Err(error) => {
164                self.push(
165                    Severity::Error,
166                    "catalog-invalid-json",
167                    format!("\"{file}\" is not valid JSON: {error}"),
168                )
169                .file = Some(file.clone());
170                return None;
171            }
172        };
173        match parse_catalog(&value) {
174            Ok(catalog) => Some(catalog),
175            Err(error) => {
176                self.push(Severity::Error, "catalog-invalid", format!("\"{file}\": {error}"))
177                    .file = Some(file.clone());
178                None
179            }
180        }
181    }
182
183    /// Every `*.jsonl` file directly in the tables directory or one level below it.
184    fn log_files_under(&self, layout: &Layout) -> Vec<PathBuf> {
185        let is_log = |path: &Path| path.is_file() && path.to_string_lossy().ends_with(LOG_SUFFIX);
186        let mut found = Vec::new();
187        let Ok(top) = fs::read_dir(layout.tables()) else {
188            return found;
189        };
190        for path in top.filter_map(Result::ok).map(|entry| entry.path()) {
191            if is_log(&path) {
192                found.push(path);
193            } else if path.is_dir() {
194                if let Ok(inner) = fs::read_dir(&path) {
195                    found.extend(
196                        inner
197                            .filter_map(Result::ok)
198                            .map(|entry| entry.path())
199                            .filter(|p| is_log(p)),
200                    );
201                }
202            }
203        }
204        found.sort();
205        found
206    }
207
208    fn logs_of(&self, layout: &Layout, name: &str, entry: &TableEntry) -> Vec<PathBuf> {
209        let flat = layout.log_path(name);
210        if entry.partition_by.is_some() {
211            let dir = layout.tables().join(name);
212            let parts = partition_names_in(&dir);
213            if !parts.is_empty() {
214                return parts
215                    .iter()
216                    .map(|part| dir.join(format!("{part}{LOG_SUFFIX}")))
217                    .collect();
218            }
219        }
220        if fs::symlink_metadata(&flat).is_ok() {
221            vec![flat]
222        } else {
223            Vec::new()
224        }
225    }
226
227    fn check_table(&mut self, layout: &Layout, name: &str, entry: &TableEntry) {
228        self.report.tables_checked += 1;
229        for path in self.logs_of(layout, name, entry) {
230            self.check_log(name, &path);
231        }
232    }
233
234    fn check_log(&mut self, table: &str, path: &Path) {
235        let file = self.relative(path);
236        let opened = match fs::File::open(path) {
237            Ok(opened) => opened,
238            Err(error) => {
239                let problem = self.push(Severity::Error, "log-unreadable", format!("\"{file}\": {error}"));
240                problem.table = Some(table.to_string());
241                problem.file = Some(file.clone());
242                return;
243            }
244        };
245        let mut reader = BufReader::new(opened);
246        let mut buffer = Vec::new();
247        let mut offset = 0u64;
248        loop {
249            buffer.clear();
250            let read = match reader.read_until(b'\n', &mut buffer) {
251                Ok(0) => break,
252                Ok(read) => read,
253                Err(error) => {
254                    let problem = self.push(
255                        Severity::Error,
256                        "log-unreadable",
257                        format!("\"{file}\" stopped being readable at byte {offset}: {error}"),
258                    );
259                    problem.table = Some(table.to_string());
260                    problem.file = Some(file.clone());
261                    problem.offset = Some(offset);
262                    return;
263                }
264            };
265            let terminated = buffer.last() == Some(&b'\n');
266            if !terminated {
267                let problem = self.push(
268                    Severity::Warning,
269                    "log-truncated-tail",
270                    format!("\"{file}\" ends with {read} bytes of an unfinished line at byte {offset}; a writable open cuts it off"),
271                );
272                problem.table = Some(table.to_string());
273                problem.file = Some(file.clone());
274                problem.offset = Some(offset);
275                return;
276            }
277            let content = &buffer[..buffer.len() - 1];
278            if !content.is_empty() {
279                self.report.log_lines_checked += 1;
280                let reason = match std::str::from_utf8(content) {
281                    Ok(line) => parse_entry(line, &file, offset).err().map(|error| error.reason),
282                    Err(error) => Some(format!("line is not valid UTF-8 ({error})")),
283                };
284                if let Some(reason) = reason {
285                    let problem = self.push(
286                        Severity::Error,
287                        "log-corrupt-line",
288                        format!("\"{file}\" at byte {offset}: {reason}"),
289                    );
290                    problem.table = Some(table.to_string());
291                    problem.file = Some(file.clone());
292                    problem.offset = Some(offset);
293                }
294            }
295            offset += read as u64;
296        }
297    }
298
299    fn check_orphan_logs(&mut self, layout: &Layout, catalog: &Catalog) {
300        for path in self.log_files_under(layout) {
301            let Some(table) = table_of_log(layout, &path) else {
302                continue;
303            };
304            if !catalog.tables.contains_key(&table) {
305                let file = self.relative(&path);
306                let problem = self.push(
307                    Severity::Warning,
308                    "log-undeclared",
309                    format!("\"{file}\" belongs to table \"{table}\", which the catalog does not declare"),
310                );
311                problem.table = Some(table);
312                problem.file = Some(file.clone());
313            }
314        }
315    }
316
317    fn check_journal(&mut self, layout: &Layout) {
318        let path = layout.journal_path();
319        if fs::symlink_metadata(&path).is_ok() {
320            let file = self.relative(&path);
321            self.push(
322                Severity::Warning,
323                "journal-unfinished",
324                format!(
325                    "\"{file}\" is a unit of work that never finished; the next writable open completes or discards it"
326                ),
327            )
328            .file = Some(file.clone());
329        }
330    }
331
332    fn check_images(&mut self, database: &Database, verify: &VerifyOptions) {
333        for name in database.table_names() {
334            let Some(table) = database.table(name) else {
335                continue;
336            };
337            let images = match table.list_images() {
338                Ok(images) => images,
339                Err(error) => {
340                    let problem = self.push(
341                        Severity::Error,
342                        "image-unreadable",
343                        format!("table \"{name}\": {error}"),
344                    );
345                    problem.table = Some(name.to_string());
346                    continue;
347                }
348            };
349            for image in images {
350                let file = self.relative(&table.image_dir().join(&image.physical_path));
351                let mut report = |checker: &mut Self, severity, code, message: String| {
352                    let problem = checker.push(severity, code, message);
353                    problem.table = Some(name.to_string());
354                    problem.file = Some(file.clone());
355                };
356                if image.is_missing() {
357                    let who = &image.referenced_by[0];
358                    report(
359                        self,
360                        Severity::Error,
361                        "image-missing",
362                        format!(
363                            "record \"{}\" (field \"{}\") references \"{}\", which is not on disk",
364                            who.key, who.field, image.physical_path
365                        ),
366                    );
367                    continue;
368                }
369                match table.read_image(&image.physical_path) {
370                    Ok(Some(bytes)) if physical_path_of(&bytes) != image.physical_path => report(
371                        self,
372                        Severity::Error,
373                        "image-corrupt",
374                        format!("\"{}\" no longer hashes to its own name", image.physical_path),
375                    ),
376                    Ok(_) => {}
377                    Err(error) => report(
378                        self,
379                        Severity::Error,
380                        "image-unreadable",
381                        format!("\"{}\": {error}", image.physical_path),
382                    ),
383                }
384                if image.referenced_by.is_empty() {
385                    report(
386                        self,
387                        Severity::Warning,
388                        "image-orphan",
389                        format!("\"{}\" is referenced by no record", image.physical_path),
390                    );
391                }
392                if verify.max_image_bytes.is_some_and(|max| image.bytes > max) {
393                    report(
394                        self,
395                        Severity::Warning,
396                        "image-oversize",
397                        format!(
398                            "\"{}\" is {} bytes, over the configured maximum",
399                            image.physical_path, image.bytes
400                        ),
401                    );
402                }
403            }
404        }
405    }
406
407    fn check_open(&mut self, verify: &VerifyOptions) {
408        let options = OpenOptions {
409            readonly: true,
410            lock: LockMode::Skip,
411            ..OpenOptions::default()
412        };
413        let database = match Database::open_with(&self.root, options) {
414            Ok(database) => database,
415            Err(error) => {
416                self.push(Severity::Error, "open-failed", error.to_string());
417                return;
418            }
419        };
420        if verify.images {
421            self.check_images(&database, verify);
422        }
423        if !verify.relations {
424            return;
425        }
426        match database.check_integrity() {
427            Ok(integrity) => {
428                for orphan in integrity.orphans {
429                    let problem = self.push(
430                        Severity::Warning,
431                        "relation-orphan",
432                        format!(
433                            "relation \"{}\": {}.{} = {} of record {} points at nothing",
434                            orphan.relation, orphan.table, orphan.field, orphan.value, orphan.primary_key
435                        ),
436                    );
437                    problem.table = Some(orphan.table);
438                }
439            }
440            Err(error) => {
441                self.push(Severity::Error, "integrity-check-failed", error.to_string());
442            }
443        }
444    }
445}
446
447/// The table a log file belongs to: `<table>.jsonl`, or `<table>/<part>.jsonl` one level down.
448fn table_of_log(layout: &Layout, path: &Path) -> Option<String> {
449    let relative = path.strip_prefix(layout.tables()).ok()?;
450    let mut parts = relative.components();
451    let first = parts.next()?.as_os_str().to_string_lossy().to_string();
452    if parts.next().is_some() {
453        Some(first)
454    } else {
455        first.strip_suffix(LOG_SUFFIX).map(str::to_string)
456    }
457}