Skip to content

Commit 9ad77cb

Browse files
Merge pull request #471 from ScriptedAlchemy/codex/graph-db-recovery
fix(storage): preflight dirty graph recovery read-only
2 parents 183b63b + 09a3b32 commit 9ad77cb

3 files changed

Lines changed: 197 additions & 13 deletions

File tree

src/tracedecay/lifecycle.rs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use crate::extraction::LanguageRegistry;
1313
use crate::global_db::{GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpsert};
1414
use crate::storage::{self, StoreLayout};
1515

16-
use super::locking::{clear_dirty_sentinel_at, has_dirty_sentinel_at};
16+
use super::locking::{
17+
clear_dirty_sentinel_at, has_dirty_sentinel_at, try_acquire_graph_sync_locks,
18+
};
1719
use super::{TraceDecay, TraceDecayOpenOptions, current_timestamp};
1820

1921
impl TraceDecay {
@@ -286,7 +288,7 @@ impl TraceDecay {
286288
/// Falls back to the nearest tracked ancestor DB with a warning only when
287289
/// the live branch cannot be auto-tracked, such as detached HEAD.
288290
/// If the previous operation was interrupted (dirty sentinel exists),
289-
/// the database is integrity-checked and rebuilt if corrupted.
291+
/// the database is integrity-checked before any writable open.
290292
pub async fn open(project_root: &Path) -> Result<Self> {
291293
Self::open_with_options(project_root, TraceDecayOpenOptions::default()).await
292294
}
@@ -337,6 +339,45 @@ impl TraceDecay {
337339
);
338340
}
339341

342+
// A dirty marker can also describe a sync that is still active in a
343+
// peer process. Recovery must own both graph-local and legacy locks so
344+
// it cannot race that writer or clear its sentinel. Preflight through
345+
// the read-only connection before Database::open applies writable
346+
// pragmas or migrations to a potentially damaged recovery set.
347+
let _recovery_lock = if crashed {
348+
Some(try_acquire_graph_sync_locks(
349+
&active_graph_layout.sync_lock_path,
350+
&store_layout.sync_lock_path,
351+
)?)
352+
} else {
353+
None
354+
};
355+
if crashed {
356+
let verification = match Database::open_read_only(&db_path).await {
357+
Ok((db, _)) => db,
358+
Err(error) => {
359+
print_corruption_warning(&db_path);
360+
return Err(recovery_required_error(&db_path, error));
361+
}
362+
};
363+
let integrity = verification.quick_check().await;
364+
verification.close();
365+
match integrity {
366+
Ok(true) => {}
367+
Ok(false) => {
368+
print_corruption_warning(&db_path);
369+
return Err(recovery_required_error(
370+
&db_path,
371+
"read-only SQLite quick_check did not return ok",
372+
));
373+
}
374+
Err(error) => {
375+
print_corruption_warning(&db_path);
376+
return Err(recovery_required_error(&db_path, error));
377+
}
378+
}
379+
}
380+
340381
// Ordinary opens never replace database files. A daemon or another MCP
341382
// process may still hold the current DB/WAL/SHM inodes, and deleting
342383
// them here would split readers and writers across different stores.

src/tracedecay/locking.rs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,10 @@ pub(super) struct ActiveSyncLockGuard {
5454

5555
impl super::TraceDecay {
5656
pub(super) fn try_acquire_active_sync_lock(&self) -> Result<ActiveSyncLockGuard> {
57-
let active = try_acquire_sync_lock_at(&self.active_graph_layout.sync_lock_path)?;
58-
let legacy = if self.active_graph_layout.sync_lock_path == self.store_layout.sync_lock_path
59-
{
60-
None
61-
} else {
62-
Some(try_acquire_sync_lock_at(&self.store_layout.sync_lock_path)?)
63-
};
64-
Ok(ActiveSyncLockGuard {
65-
_active: active,
66-
_legacy: legacy,
67-
})
57+
try_acquire_graph_sync_locks(
58+
&self.active_graph_layout.sync_lock_path,
59+
&self.store_layout.sync_lock_path,
60+
)
6861
}
6962

7063
pub(super) fn write_active_dirty_sentinels(&self) {
@@ -82,6 +75,22 @@ impl super::TraceDecay {
8275
}
8376
}
8477

78+
pub(super) fn try_acquire_graph_sync_locks(
79+
active_path: &Path,
80+
legacy_path: &Path,
81+
) -> Result<ActiveSyncLockGuard> {
82+
let active = try_acquire_sync_lock_at(active_path)?;
83+
let legacy = if active_path == legacy_path {
84+
None
85+
} else {
86+
Some(try_acquire_sync_lock_at(legacy_path)?)
87+
};
88+
Ok(ActiveSyncLockGuard {
89+
_active: active,
90+
_legacy: legacy,
91+
})
92+
}
93+
8594
impl Drop for SyncLockGuard {
8695
fn drop(&mut self) {
8796
let _ = std::fs::remove_file(&self.path);

tests/storage_suite/corruption_test.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,140 @@ async fn open_preserves_corrupt_store_and_dirty_sentinel_for_offline_repair()
394394
Ok(())
395395
}
396396

397+
#[tokio::test]
398+
async fn dirty_open_checks_integrity_before_writable_migration()
399+
-> std::result::Result<(), Box<dyn std::error::Error>> {
400+
let dir = TempDir::new()?;
401+
let project_root = dir.path().join("repo");
402+
std::fs::create_dir_all(&project_root)?;
403+
let open_options = TraceDecayOpenOptions {
404+
profile_root: Some(dir.path().join("profile")),
405+
global_db_path: Some(dir.path().join("global.db")),
406+
};
407+
408+
let ts = TraceDecay::init_with_options(&project_root, open_options.clone()).await?;
409+
let layout = ts.store_layout().clone();
410+
ts.db()
411+
.conn()
412+
.execute_batch("PRAGMA user_version = 17")
413+
.await?;
414+
ts.checkpoint().await?;
415+
ts.close();
416+
417+
let mut file = std::fs::OpenOptions::new()
418+
.read(true)
419+
.write(true)
420+
.open(&layout.graph_db_path)?;
421+
let offset = std::cmp::min(file.metadata()?.len() / 2, 8192);
422+
file.seek(std::io::SeekFrom::Start(offset))?;
423+
file.write_all(&[0xFF; 256])?;
424+
file.sync_all()?;
425+
drop(file);
426+
std::fs::write(&layout.dirty_path, "pid=99999\nversion=test")?;
427+
428+
let before = std::fs::read(&layout.graph_db_path)?;
429+
let result = TraceDecay::open_with_options(&project_root, open_options).await;
430+
assert!(result.is_err(), "damaged dirty store must require recovery");
431+
assert_eq!(
432+
std::fs::read(&layout.graph_db_path)?,
433+
before,
434+
"integrity failure must be detected before writable migration"
435+
);
436+
assert!(layout.dirty_path.exists());
437+
Ok(())
438+
}
439+
440+
#[tokio::test]
441+
async fn dirty_open_does_not_race_an_active_sync_lock()
442+
-> std::result::Result<(), Box<dyn std::error::Error>> {
443+
let dir = TempDir::new()?;
444+
let project_root = dir.path().join("repo");
445+
std::fs::create_dir_all(&project_root)?;
446+
let open_options = TraceDecayOpenOptions {
447+
profile_root: Some(dir.path().join("profile")),
448+
global_db_path: Some(dir.path().join("global.db")),
449+
};
450+
451+
let ts = TraceDecay::init_with_options(&project_root, open_options.clone()).await?;
452+
let layout = ts.store_layout().clone();
453+
ts.close();
454+
let active_lock = layout.graph_db_path.with_file_name(format!(
455+
"{}.sync.lock",
456+
layout.graph_db_path.file_name().unwrap().to_string_lossy()
457+
));
458+
std::fs::write(&active_lock, std::process::id().to_string())?;
459+
std::fs::write(&layout.dirty_path, "pid=99999\nversion=test")?;
460+
let before = std::fs::read(&layout.graph_db_path)?;
461+
462+
let error = match TraceDecay::open_with_options(&project_root, open_options).await {
463+
Ok(_) => panic!("active writer lock must block recovery"),
464+
Err(error) => error,
465+
};
466+
assert!(
467+
error
468+
.to_string()
469+
.contains("another sync is already in progress")
470+
);
471+
assert_eq!(std::fs::read(&layout.graph_db_path)?, before);
472+
assert!(layout.dirty_path.exists());
473+
Ok(())
474+
}
475+
476+
#[tokio::test]
477+
async fn dirty_open_recovers_committed_rows_before_clearing_sentinel()
478+
-> std::result::Result<(), Box<dyn std::error::Error>> {
479+
let dir = TempDir::new()?;
480+
let project_root = dir.path().join("repo");
481+
std::fs::create_dir_all(&project_root)?;
482+
let open_options = TraceDecayOpenOptions {
483+
profile_root: Some(dir.path().join("profile")),
484+
global_db_path: Some(dir.path().join("global.db")),
485+
};
486+
487+
let ts = TraceDecay::init_with_options(&project_root, open_options.clone()).await?;
488+
let layout = ts.store_layout().clone();
489+
ts.db()
490+
.conn()
491+
.execute_batch("PRAGMA wal_autocheckpoint = 0")
492+
.await?;
493+
let mut journal_rows = ts.db().conn().query("PRAGMA journal_mode", ()).await?;
494+
let journal_mode = journal_rows
495+
.next()
496+
.await?
497+
.expect("journal mode row")
498+
.get::<String>(0)?;
499+
drop(journal_rows);
500+
let node = sample_node("wal-recovery-node", "wal_recovery_node");
501+
ts.db().insert_nodes(std::slice::from_ref(&node)).await?;
502+
if journal_mode.eq_ignore_ascii_case("wal") {
503+
let mut wal_path = layout.graph_db_path.as_os_str().to_os_string();
504+
wal_path.push("-wal");
505+
assert!(
506+
std::fs::metadata(std::path::PathBuf::from(wal_path))?.len() > 0,
507+
"disabled autocheckpoint must retain committed WAL frames"
508+
);
509+
} else {
510+
assert!(
511+
matches!(
512+
journal_mode.to_ascii_lowercase().as_str(),
513+
"delete" | "memory"
514+
),
515+
"production recovery fixture must use a platform-safe non-WAL journal"
516+
);
517+
}
518+
std::fs::write(&layout.dirty_path, "pid=99999\nversion=test")?;
519+
520+
let recovered = TraceDecay::open_with_options(&project_root, open_options).await?;
521+
assert!(recovered.get_node(&node.id).await?.is_some());
522+
assert!(
523+
!layout.dirty_path.exists(),
524+
"sentinel clears only after WAL-aware quick_check succeeds"
525+
);
526+
recovered.close();
527+
ts.close();
528+
Ok(())
529+
}
530+
397531
#[tokio::test]
398532
async fn corrupt_db_detected_and_repaired_on_reopen() {
399533
let dir = TempDir::new().unwrap();

0 commit comments

Comments
 (0)