fathomdb-engine 0.6.1-rc.1

FathomDB engine — embedded vector + JSON database core (storage, projection, ingest, query).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! Open-path corruption matrix (AC-035a / AC-035b / AC-035c).
//!
//! Four fixtures, one per `CorruptionKind` variant. Each fixture seeds a
//! clean database, closes it, applies a deterministic corruption via
//! `tests/support/corruption.rs`, and asserts that `Engine::open`:
//!
//!   - returns `Err(EngineOpenError::Corruption(_))` (AC-035a)
//!   - carries the documented `(kind, stage, recovery_hint.code)` tuple
//!     from `dev/design/errors.md` § OpenStage matrix (AC-035b)
//!   - releases the writer lock and retains no SQLite fd / writer thread
//!     in the calling process (AC-035c)
//!
//! Substrate notes:
//!   - `corrupt_database_header` / `corrupt_interior_page_byte` already
//!     existed for AC-006 page-corruption coverage; reused here.
//!   - `corrupt_wal_invalid_page_size` / `corrupt_embedder_profile_row`
//!     are new helpers added for the WAL-replay and embedder-identity
//!     drift fixtures.
//!   - Process introspection (AC-035c) is linux-only. The test gates on
//!     `cfg(target_os = "linux")` rather than skipping silently.

use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, OnceLock};

use fathomdb_engine::{
    CorruptionDetail, CorruptionKind, CorruptionLocator, Engine, EngineOpenError, OpenStage,
};
use tempfile::TempDir;

#[path = "support/corruption.rs"]
mod corruption;

/// Serialize every test in this binary. AC-035c inspects process-wide
/// thread names and open fds; parallel sibling tests that hold an
/// `Engine` alive would otherwise show up as `fathomdb-reader-*` threads
/// and false-fail the assertion. Even tests that do not check thread
/// names take the guard so an AC-035c test never races a sibling that
/// has just spawned reader threads.
fn serial_guard() -> MutexGuard<'static, ()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(())).lock().unwrap_or_else(|p| p.into_inner())
}

#[derive(Clone, Copy, Debug)]
struct Expected {
    kind: CorruptionKind,
    stage: OpenStage,
    code: &'static str,
    doc_anchor: &'static str,
}

const EXPECTED_WAL: Expected = Expected {
    kind: CorruptionKind::WalReplayFailure,
    stage: OpenStage::WalReplay,
    code: "E_CORRUPT_WAL_REPLAY",
    doc_anchor: "design/recovery.md#wal-replay-failures",
};
const EXPECTED_HEADER: Expected = Expected {
    kind: CorruptionKind::HeaderMalformed,
    stage: OpenStage::HeaderProbe,
    code: "E_CORRUPT_HEADER",
    doc_anchor: "design/recovery.md#header-malformed",
};
const EXPECTED_SCHEMA: Expected = Expected {
    kind: CorruptionKind::SchemaInconsistent,
    stage: OpenStage::SchemaProbe,
    code: "E_CORRUPT_SCHEMA",
    doc_anchor: "design/recovery.md#schema-inconsistent",
};
const EXPECTED_EMBEDDER: Expected = Expected {
    kind: CorruptionKind::EmbedderIdentityDrift,
    stage: OpenStage::EmbedderIdentity,
    code: "E_CORRUPT_EMBEDDER_IDENTITY",
    doc_anchor: "design/recovery.md#embedder-identity-drift",
};

fn fixture_for(kind: CorruptionKind) -> (TempDir, PathBuf) {
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join("corrupt.sqlite");
    // Seed a clean database via the engine so schema + embedder profile
    // are populated before corruption is applied.
    let opened = Engine::open(&path).expect("seed open");
    opened.engine.close().expect("seed close");
    drop(opened);
    apply_corruption(kind, &path);
    (dir, path)
}

fn apply_corruption(kind: CorruptionKind, path: &Path) {
    match kind {
        CorruptionKind::WalReplayFailure => {
            corruption::corrupt_wal_invalid_page_size(path);
        }
        CorruptionKind::HeaderMalformed => {
            corruption::corrupt_database_header(path);
        }
        CorruptionKind::SchemaInconsistent => {
            // Magic intact; XOR a byte in the b-tree header so the
            // schema probe trips during page decode.
            corruption::corrupt_interior_page_byte(path, 0, 100, 0xFF);
        }
        CorruptionKind::EmbedderIdentityDrift => {
            corruption::corrupt_embedder_profile_row(path);
        }
    }
}

fn open_and_expect_corruption(path: &Path) -> CorruptionDetail {
    let err = Engine::open(path).expect_err("Engine::open must fail on corruption fixture");
    match err {
        EngineOpenError::Corruption(detail) => detail,
        other => panic!("expected Corruption, got {other:?}"),
    }
}

// ── AC-035a: refusal + no handle + no side effect ───────────────────────────

#[test]
fn ac_035a_wal_replay_corruption_refuses_open() {
    assert_refuses_with_no_side_effect(CorruptionKind::WalReplayFailure);
}

#[test]
fn ac_035a_header_corruption_refuses_open() {
    assert_refuses_with_no_side_effect(CorruptionKind::HeaderMalformed);
}

#[test]
fn ac_035a_schema_corruption_refuses_open() {
    assert_refuses_with_no_side_effect(CorruptionKind::SchemaInconsistent);
}

#[test]
fn ac_035a_embedder_identity_corruption_refuses_open() {
    assert_refuses_with_no_side_effect(CorruptionKind::EmbedderIdentityDrift);
}

fn assert_refuses_with_no_side_effect(kind: CorruptionKind) {
    let _guard = serial_guard();
    let (_dir, path) = fixture_for(kind);
    let mtime_before = fs::metadata(&path).expect("stat before").modified().expect("mtime before");
    let len_before = fs::metadata(&path).expect("stat before len").len();

    let result = Engine::open(&path);
    assert!(
        matches!(result, Err(EngineOpenError::Corruption(_))),
        "expected Err(Corruption) for {kind:?}, got {result:?}"
    );

    let mtime_after = fs::metadata(&path).expect("stat after").modified().expect("mtime after");
    let len_after = fs::metadata(&path).expect("stat after len").len();
    assert_eq!(
        mtime_before, mtime_after,
        "{kind:?}: failed open must not modify the DB file (mtime drift)"
    );
    assert_eq!(
        len_before, len_after,
        "{kind:?}: failed open must not modify the DB file (len drift)"
    );
}

// ── AC-035b: CorruptionDetail shape per kind ────────────────────────────────

#[test]
fn ac_035b_wal_replay_shape() {
    let _guard = serial_guard();
    let (_dir, path) = fixture_for(CorruptionKind::WalReplayFailure);
    let detail = open_and_expect_corruption(&path);
    assert_shape(&detail, &EXPECTED_WAL);
}

#[test]
fn ac_035b_header_shape() {
    let _guard = serial_guard();
    let (_dir, path) = fixture_for(CorruptionKind::HeaderMalformed);
    let detail = open_and_expect_corruption(&path);
    assert_shape(&detail, &EXPECTED_HEADER);
}

#[test]
fn ac_035b_schema_shape() {
    let _guard = serial_guard();
    let (_dir, path) = fixture_for(CorruptionKind::SchemaInconsistent);
    let detail = open_and_expect_corruption(&path);
    assert_shape(&detail, &EXPECTED_SCHEMA);
}

#[test]
fn ac_035b_embedder_identity_shape() {
    let _guard = serial_guard();
    let (_dir, path) = fixture_for(CorruptionKind::EmbedderIdentityDrift);
    let detail = open_and_expect_corruption(&path);
    assert_shape(&detail, &EXPECTED_EMBEDDER);
}

fn assert_shape(detail: &CorruptionDetail, expected: &Expected) {
    assert_eq!(detail.kind, expected.kind, "kind mismatch");
    assert_eq!(detail.stage, expected.stage, "stage mismatch");
    // AC-035b: `OpenStage` must remain in `{WalReplay, HeaderProbe,
    // SchemaProbe, EmbedderIdentity}`. Compile-time enforced by
    // exhaustive match below; any future LockAcquisition-style variant
    // would cause this match to non-exhaustively fail to compile.
    match detail.stage {
        OpenStage::WalReplay
        | OpenStage::HeaderProbe
        | OpenStage::SchemaProbe
        | OpenStage::EmbedderIdentity => {}
    }
    // AC-035b: opaque SQLite paths must surface as OpaqueSqliteError with
    // a typed extended_code; CorruptionLocator must never expose a
    // free-form Unspecified variant.
    match detail.locator {
        CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: _ }
        | CorruptionLocator::FileOffset { .. }
        | CorruptionLocator::PageId { .. }
        | CorruptionLocator::TableRow { .. }
        | CorruptionLocator::Vec0ShadowRow { .. }
        | CorruptionLocator::MigrationStep { .. } => {}
    }
    assert_eq!(
        detail.recovery_hint.code, expected.code,
        "recovery_hint.code mismatch for {:?}",
        expected.kind
    );
    assert_eq!(
        detail.recovery_hint.doc_anchor, expected.doc_anchor,
        "recovery_hint.doc_anchor mismatch for {:?}",
        expected.kind
    );
}

// AC-035b: `code` is a `&'static str`; re-running the fixture twice must
// yield bit-equal pointers (and obviously bit-equal contents).
#[test]
fn ac_035b_code_is_bit_stable_across_runs() {
    let _guard = serial_guard();
    for expected in [&EXPECTED_WAL, &EXPECTED_HEADER, &EXPECTED_SCHEMA, &EXPECTED_EMBEDDER] {
        let (_dir1, path1) = fixture_for(expected.kind);
        let (_dir2, path2) = fixture_for(expected.kind);
        let detail1 = open_and_expect_corruption(&path1);
        let detail2 = open_and_expect_corruption(&path2);
        assert_eq!(detail1.recovery_hint.code, detail2.recovery_hint.code);
        assert_eq!(detail1.recovery_hint.code, expected.code);
        // The `&'static str` from two separate calls within the same
        // process must point at the same backing storage — string-
        // literal interning gives bit-equal pointer comparison and
        // proves the engine isn't allocating a fresh `String` per
        // failure.
        assert!(
            std::ptr::eq(detail1.recovery_hint.code, detail2.recovery_hint.code),
            "recovery_hint.code must be a static literal, not an allocated string"
        );
    }
}

// ── AC-035c: lock released, fd not retained, no writer/scheduler thread ─────

#[cfg(target_os = "linux")]
#[test]
fn ac_035c_lock_released_after_corruption_header() {
    assert_lock_released_and_no_engine_residue(CorruptionKind::HeaderMalformed);
}

#[cfg(target_os = "linux")]
#[test]
fn ac_035c_lock_released_after_corruption_schema() {
    assert_lock_released_and_no_engine_residue(CorruptionKind::SchemaInconsistent);
}

#[cfg(target_os = "linux")]
fn assert_lock_released_and_no_engine_residue(kind: CorruptionKind) {
    let _guard = serial_guard();
    let (_dir, path) = fixture_for(kind);
    // Trigger the failed open; the lock must be released by the time
    // the call returns.
    let err = Engine::open(&path).expect_err("must fail");
    assert!(matches!(err, EngineOpenError::Corruption(_)));

    let lock_path = lock_path_for(&path);
    assert!(lock_path.exists(), "engine should have created the lockfile before releasing it");

    // True sibling-process check (AC-035c contract): a separate
    // process B must observe the lock as acquirable. Re-entering the
    // test binary with `FATHOMDB_TEST_LOCK_PROBE` routes to
    // `_lock_acquire_probe_entry`, which exits 0 on `flock(LOCK_EX |
    // LOCK_NB)` success and 1 on EWOULDBLOCK. Same-process fd
    // reacquire is useful signal but is not the documented contract.
    let exe = std::env::current_exe().expect("current_exe");
    let probe_status = std::process::Command::new(&exe)
        .args(["--exact", "--ignored", "_lock_acquire_probe_entry"])
        .env("FATHOMDB_TEST_LOCK_PROBE", &lock_path)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .expect("spawn lock-probe sibling");
    assert!(
        probe_status.success(),
        "AC-035c: sibling process could not acquire .lock after failed \
         open ({}): probe exit {:?}",
        lock_path.display(),
        probe_status.code(),
    );

    // Same-process fd reacquire kept as a sanity check on top of the
    // sibling-process contract.
    let probe = File::options().read(true).write(true).open(&lock_path).expect("open lock file");
    let probe_fd: i32 = std::os::unix::io::AsRawFd::as_raw_fd(&probe);
    let rc = unsafe { libc::flock(probe_fd, libc::LOCK_EX | libc::LOCK_NB) };
    assert_eq!(
        rc,
        0,
        "AC-035c: lock must be releasable after failed open ({}): errno = {}",
        lock_path.display(),
        std::io::Error::last_os_error()
    );
    let _ = unsafe { libc::flock(probe_fd, libc::LOCK_UN) };

    // /proc/self/fd: assert no fd points at the database file. The
    // canonical absolute path of `path` must not appear as the
    // destination of any open fd in this process.
    let canonical = path.canonicalize().expect("canonicalize db path");
    let fd_dir = PathBuf::from("/proc/self/fd");
    for entry in fs::read_dir(&fd_dir).expect("read /proc/self/fd") {
        let entry = entry.expect("dirent");
        let link = match fs::read_link(entry.path()) {
            Ok(target) => target,
            // The fd may have been closed between readdir and readlink;
            // race is fine for this assertion.
            Err(_) => continue,
        };
        assert_ne!(
            link,
            canonical,
            "AC-035c: /proc/self/fd/{} still points at the database \
             file after a failed open",
            entry.file_name().to_string_lossy()
        );
    }

    // /proc/self/task: no thread named per fathomdb conventions
    // (`fathomdb-reader-N`, etc.) must be live in this process.
    let task_dir = PathBuf::from("/proc/self/task");
    for entry in fs::read_dir(&task_dir).expect("read /proc/self/task") {
        let entry = entry.expect("task dirent");
        let comm_path = entry.path().join("comm");
        let Ok(comm_bytes) = fs::read(&comm_path) else { continue };
        let comm = String::from_utf8_lossy(&comm_bytes).trim().to_string();
        assert!(
            !comm.starts_with("fathomdb-"),
            "AC-035c: thread named '{comm}' is still live after a failed open"
        );
    }
}

/// Test-binary-as-sibling entry. Re-invoked from `assert_lock_released_*`
/// with `FATHOMDB_TEST_LOCK_PROBE` set to the engine's `.lock` path.
/// Attempts `flock(LOCK_EX | LOCK_NB)` and exits 0 on success / 1 on
/// `EWOULDBLOCK`, giving the parent a true sibling-process check
/// (AC-035c contract) instead of the in-process fd reacquire.
/// Without the env var the test is a no-op so plain
/// `cargo test --test durability_open_path -- --ignored` is safe.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "test-binary-as-sibling entry-point for the AC-035c lock-released harness"]
fn _lock_acquire_probe_entry() {
    let Some(lock_path) = std::env::var_os("FATHOMDB_TEST_LOCK_PROBE") else {
        return;
    };
    let path = PathBuf::from(lock_path);
    let probe =
        File::options().read(true).write(true).open(&path).expect("sibling: open lock file");
    let probe_fd: i32 = std::os::unix::io::AsRawFd::as_raw_fd(&probe);
    let rc = unsafe { libc::flock(probe_fd, libc::LOCK_EX | libc::LOCK_NB) };
    if rc == 0 {
        let _ = unsafe { libc::flock(probe_fd, libc::LOCK_UN) };
        std::process::exit(0);
    }
    std::process::exit(1);
}

#[cfg(target_os = "linux")]
fn lock_path_for(db_path: &Path) -> PathBuf {
    let mut lock = db_path.as_os_str().to_owned();
    lock.push(".lock");
    PathBuf::from(lock)
}

// ── Finding 1: bounded WAL sidecar probe ────────────────────────────────────
//
// `probe_wal_sidecar` must only read the 32-byte WAL header, never the
// full sidecar. Without this guard, an unclean shutdown that leaves a
// large -wal file behind would force `Engine::open` to slurp the whole
// file into memory before SQLite itself touches recovery — measurable
// latency + RSS regression on the open path. Test makes the WAL sidecar
// 256 MiB; bounded read returns in ~ms, a full read pays ~hundreds of
// ms (alloc + copy) and would flunk the wall-clock bound.
#[test]
fn probe_wal_sidecar_bounded_read() {
    let _guard = serial_guard();
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join("bounded.sqlite");
    // Seed a real DB so the open path reaches `probe_wal_sidecar`.
    let opened = Engine::open(&path).expect("seed open");
    opened.engine.close().expect("seed close");
    drop(opened);

    let wal_path = corruption::wal_sidecar_path(&path);
    let mut file = std::fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open(&wal_path)
        .expect("create large wal");
    // Valid WAL magic + version, invalid page size so the probe still
    // rejects; the rest of the sidecar is sparse zeros. A full-file
    // read pays for the entire 256 MiB.
    let mut header = [0u8; 32];
    header[0..4].copy_from_slice(&0x377f_0683_u32.to_be_bytes());
    header[4..8].copy_from_slice(&3_007_000_u32.to_be_bytes());
    header[8..12].copy_from_slice(&0x0080_0000_u32.to_be_bytes());
    use std::io::Write;
    file.write_all(&header).expect("wal header");
    const WAL_LEN: u64 = 256 * 1024 * 1024;
    file.set_len(WAL_LEN).expect("set_len 256 MiB sparse");
    file.sync_all().expect("fsync large wal");
    drop(file);

    let started = std::time::Instant::now();
    let result = Engine::open(&path);
    let elapsed = started.elapsed();

    assert!(
        matches!(result, Err(EngineOpenError::Corruption(_))),
        "expected Corruption on invalid page size, got {result:?}"
    );
    // Bounded read is sub-ms; an unbounded `std::fs::read` of 256 MiB
    // measurably loses this margin. 200 ms is generous for slow CI
    // hosts while still flagging a regression to full-file read.
    assert!(
        elapsed < std::time::Duration::from_millis(200),
        "probe_wal_sidecar took {elapsed:?} on a 256 MiB sidecar; \
         must read only the 32-byte header"
    );
}