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() && !path.join(layout.metadata_file()).is_file() {
194                // NOTE: a folder with its own metadata document is another database, not a partition directory.
195                if let Ok(inner) = fs::read_dir(&path) {
196                    found.extend(
197                        inner
198                            .filter_map(Result::ok)
199                            .map(|entry| entry.path())
200                            .filter(|p| is_log(p)),
201                    );
202                }
203            }
204        }
205        found.sort();
206        found
207    }
208
209    fn logs_of(&self, layout: &Layout, name: &str, entry: &TableEntry) -> Vec<PathBuf> {
210        let flat = layout.log_path(name);
211        if entry.partition_by.is_some() {
212            let dir = layout.tables().join(name);
213            let parts = partition_names_in(&dir);
214            if !parts.is_empty() {
215                return parts
216                    .iter()
217                    .map(|part| dir.join(format!("{part}{LOG_SUFFIX}")))
218                    .collect();
219            }
220        }
221        if fs::symlink_metadata(&flat).is_ok() {
222            vec![flat]
223        } else {
224            Vec::new()
225        }
226    }
227
228    fn check_table(&mut self, layout: &Layout, name: &str, entry: &TableEntry) {
229        self.report.tables_checked += 1;
230        for path in self.logs_of(layout, name, entry) {
231            self.check_log(name, &path);
232        }
233    }
234
235    fn check_log(&mut self, table: &str, path: &Path) {
236        let file = self.relative(path);
237        let opened = match fs::File::open(path) {
238            Ok(opened) => opened,
239            Err(error) => {
240                let problem = self.push(Severity::Error, "log-unreadable", format!("\"{file}\": {error}"));
241                problem.table = Some(table.to_string());
242                problem.file = Some(file.clone());
243                return;
244            }
245        };
246        let mut reader = BufReader::new(opened);
247        let mut buffer = Vec::new();
248        let mut offset = 0u64;
249        loop {
250            buffer.clear();
251            let read = match reader.read_until(b'\n', &mut buffer) {
252                Ok(0) => break,
253                Ok(read) => read,
254                Err(error) => {
255                    let problem = self.push(
256                        Severity::Error,
257                        "log-unreadable",
258                        format!("\"{file}\" stopped being readable at byte {offset}: {error}"),
259                    );
260                    problem.table = Some(table.to_string());
261                    problem.file = Some(file.clone());
262                    problem.offset = Some(offset);
263                    return;
264                }
265            };
266            let terminated = buffer.last() == Some(&b'\n');
267            if !terminated {
268                let problem = self.push(
269                    Severity::Warning,
270                    "log-truncated-tail",
271                    format!("\"{file}\" ends with {read} bytes of an unfinished line at byte {offset}; a writable open cuts it off"),
272                );
273                problem.table = Some(table.to_string());
274                problem.file = Some(file.clone());
275                problem.offset = Some(offset);
276                return;
277            }
278            let content = &buffer[..buffer.len() - 1];
279            if !content.is_empty() {
280                self.report.log_lines_checked += 1;
281                let reason = match std::str::from_utf8(content) {
282                    Ok(line) => parse_entry(line, &file, offset).err().map(|error| error.reason),
283                    Err(error) => Some(format!("line is not valid UTF-8 ({error})")),
284                };
285                if let Some(reason) = reason {
286                    let problem = self.push(
287                        Severity::Error,
288                        "log-corrupt-line",
289                        format!("\"{file}\" at byte {offset}: {reason}"),
290                    );
291                    problem.table = Some(table.to_string());
292                    problem.file = Some(file.clone());
293                    problem.offset = Some(offset);
294                }
295            }
296            offset += read as u64;
297        }
298    }
299
300    fn check_orphan_logs(&mut self, layout: &Layout, catalog: &Catalog) {
301        for path in self.log_files_under(layout) {
302            let Some(table) = table_of_log(layout, &path) else {
303                continue;
304            };
305            if !catalog.tables.contains_key(&table) {
306                let file = self.relative(&path);
307                let problem = self.push(
308                    Severity::Warning,
309                    "log-undeclared",
310                    format!("\"{file}\" belongs to table \"{table}\", which the catalog does not declare"),
311                );
312                problem.table = Some(table);
313                problem.file = Some(file.clone());
314            }
315        }
316    }
317
318    fn check_journal(&mut self, layout: &Layout) {
319        let path = layout.journal_path();
320        if fs::symlink_metadata(&path).is_ok() {
321            let file = self.relative(&path);
322            self.push(
323                Severity::Warning,
324                "journal-unfinished",
325                format!(
326                    "\"{file}\" is a unit of work that never finished; the next writable open completes or discards it"
327                ),
328            )
329            .file = Some(file.clone());
330        }
331    }
332
333    fn check_images(&mut self, database: &Database, verify: &VerifyOptions) {
334        for name in database.table_names() {
335            let Some(table) = database.table(name) else {
336                continue;
337            };
338            let images = match table.list_images() {
339                Ok(images) => images,
340                Err(error) => {
341                    let problem = self.push(
342                        Severity::Error,
343                        "image-unreadable",
344                        format!("table \"{name}\": {error}"),
345                    );
346                    problem.table = Some(name.to_string());
347                    continue;
348                }
349            };
350            for image in images {
351                let file = self.relative(&table.image_dir().join(&image.physical_path));
352                let mut report = |checker: &mut Self, severity, code, message: String| {
353                    let problem = checker.push(severity, code, message);
354                    problem.table = Some(name.to_string());
355                    problem.file = Some(file.clone());
356                };
357                if image.is_missing() {
358                    let who = &image.referenced_by[0];
359                    report(
360                        self,
361                        Severity::Error,
362                        "image-missing",
363                        format!(
364                            "record \"{}\" (field \"{}\") references \"{}\", which is not on disk",
365                            who.key, who.field, image.physical_path
366                        ),
367                    );
368                    continue;
369                }
370                match table.read_image(&image.physical_path) {
371                    Ok(Some(bytes)) if physical_path_of(&bytes) != image.physical_path => report(
372                        self,
373                        Severity::Error,
374                        "image-corrupt",
375                        format!("\"{}\" no longer hashes to its own name", image.physical_path),
376                    ),
377                    Ok(_) => {}
378                    Err(error) => report(
379                        self,
380                        Severity::Error,
381                        "image-unreadable",
382                        format!("\"{}\": {error}", image.physical_path),
383                    ),
384                }
385                if image.referenced_by.is_empty() {
386                    report(
387                        self,
388                        Severity::Warning,
389                        "image-orphan",
390                        format!("\"{}\" is referenced by no record", image.physical_path),
391                    );
392                }
393                if verify.max_image_bytes.is_some_and(|max| image.bytes > max) {
394                    report(
395                        self,
396                        Severity::Warning,
397                        "image-oversize",
398                        format!(
399                            "\"{}\" is {} bytes, over the configured maximum",
400                            image.physical_path, image.bytes
401                        ),
402                    );
403                }
404            }
405        }
406    }
407
408    fn check_open(&mut self, verify: &VerifyOptions) {
409        let options = OpenOptions {
410            readonly: true,
411            lock: LockMode::Skip,
412            ..OpenOptions::default()
413        };
414        let database = match Database::open_with(&self.root, options) {
415            Ok(database) => database,
416            Err(error) => {
417                self.push(Severity::Error, "open-failed", error.to_string());
418                return;
419            }
420        };
421        if verify.images {
422            self.check_images(&database, verify);
423        }
424        if !verify.relations {
425            return;
426        }
427        match database.check_integrity() {
428            Ok(integrity) => {
429                for orphan in integrity.orphans {
430                    let problem = self.push(
431                        Severity::Warning,
432                        "relation-orphan",
433                        format!(
434                            "relation \"{}\": {}.{} = {} of record {} points at nothing",
435                            orphan.relation, orphan.table, orphan.field, orphan.value, orphan.primary_key
436                        ),
437                    );
438                    problem.table = Some(orphan.table);
439                }
440            }
441            Err(error) => {
442                self.push(Severity::Error, "integrity-check-failed", error.to_string());
443            }
444        }
445    }
446}
447
448/// The table a log file belongs to: `<table>.jsonl`, or `<table>/<part>.jsonl` one level down.
449fn table_of_log(layout: &Layout, path: &Path) -> Option<String> {
450    let relative = path.strip_prefix(layout.tables()).ok()?;
451    let mut parts = relative.components();
452    let first = parts.next()?.as_os_str().to_string_lossy().to_string();
453    if parts.next().is_some() {
454        Some(first)
455    } else {
456        first.strip_suffix(LOG_SUFFIX).map(str::to_string)
457    }
458}