Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
c8a1eff
fix(graph): reconcile verified retirement and retry state
ScriptedAlchemy Nov 8, 2026
e304bfe
style(graph): apply Bazel rustfmt to sealed retirement
ScriptedAlchemy Nov 8, 2026
3c66144
fix(graph): preserve unrelated directories during store recovery
ScriptedAlchemy Nov 8, 2026
65bd98f
fix(graph): refuse unverified sealed paths during rebuild
ScriptedAlchemy Nov 8, 2026
3d98921
style(graph): format sealed generation retirement tests
ScriptedAlchemy Nov 8, 2026
c5b8980
test(automation): bind scheduler fixtures to backend identities
ScriptedAlchemy Nov 8, 2026
c22406e
fix(graph): verify failed-adoption cleanup before unlink
ScriptedAlchemy Nov 8, 2026
7c9a6f4
style(graph): format failed adoption cleanup
ScriptedAlchemy Nov 8, 2026
882d642
fix(graph): refuse occupied sealed generation installs
ScriptedAlchemy Nov 8, 2026
dacd4a0
style(storage): format no-replace directory installation
ScriptedAlchemy Nov 9, 2026
e2f3371
test(graph): preserve unverified sealed artifacts on recovery
ScriptedAlchemy Nov 9, 2026
b568770
fix(graph): refuse occupied sealed staging on rebuild
ScriptedAlchemy Nov 9, 2026
5de9d92
fix(graph): require receipts for abandoned staging cleanup
ScriptedAlchemy Nov 9, 2026
0bf8a86
fix(graph): bind abandoned receipts to canonical generation identity
ScriptedAlchemy Nov 9, 2026
bd4f39e
test(graph): preserve unreceipted staging in crash regression
ScriptedAlchemy Nov 9, 2026
95e8a8b
Merge master into fix/4616-verified-sealed-retirement-clean-history
ScriptedAlchemy Nov 9, 2026
35abfc8
test(graph-db): import the canonical sealed receipt filename
ScriptedAlchemy Nov 9, 2026
f3d8f4e
fix(graph): verify row spill ownership before reclaim
ScriptedAlchemy Nov 9, 2026
05c18a3
fix(graph): publish row spills only with a complete owner receipt
ScriptedAlchemy Nov 9, 2026
d13622c
fix(graph): recover pending row spills after producer exit
ScriptedAlchemy Nov 9, 2026
ded88e8
fix(graph): distinguish spill owners across reused process IDs
ScriptedAlchemy Nov 9, 2026
59b5cd3
fix(graph): keep row-spill sweep bound to one sealed-root handle
ScriptedAlchemy Nov 9, 2026
552b5c7
fix(storage): continue cleanup after a verified child unlink fails
ScriptedAlchemy Nov 9, 2026
6eadebb
fix(graph): verify logical owner before old-format cleanup
ScriptedAlchemy Nov 9, 2026
53457e4
fix(graph): bind pending spill receipts to canonical names
ScriptedAlchemy Nov 9, 2026
e60c894
test(storage): use genuine spill and seal ownership identities
ScriptedAlchemy Nov 9, 2026
3d99237
Merge master into fix/4616-verified-sealed-retirement-clean-history
ScriptedAlchemy Nov 9, 2026
6287274
fix(graph): reclaim crashed sealed stages with owner receipts
ScriptedAlchemy Nov 9, 2026
76ac5bd
test(graph): import sealed staging recovery helpers
ScriptedAlchemy Nov 9, 2026
95a6696
fix(graph): preserve live sealed staging across remounts
ScriptedAlchemy Nov 9, 2026
8d97999
fix(graph): reject unowned receipts in sealed census
ScriptedAlchemy Nov 9, 2026
c87aa2f
test(graph): keep sparse census regression Unix-only
ScriptedAlchemy Nov 9, 2026
54b3b0a
fix(graph): exclude unproven staging from sealed census
ScriptedAlchemy Nov 9, 2026
f192a01
style(graph): format sealed census and recovery regressions
ScriptedAlchemy Nov 9, 2026
e006c62
test(graph): retire five sealed replay generations
ScriptedAlchemy Nov 9, 2026
79c53bb
fix(graph): anchor sealed proof marker writes to held directories
ScriptedAlchemy Nov 9, 2026
43b1ac4
style(fs): normalize sealed proof regression formatting
ScriptedAlchemy Nov 9, 2026
fa9ae40
fix(graph): preserve foreign sealed proof marker entries
ScriptedAlchemy Nov 9, 2026
280928f
style(fs): format sealed marker preservation regression
ScriptedAlchemy Nov 9, 2026
261682f
style(graph): fix Bazel rustfmt failures in sealed regressions
ScriptedAlchemy Nov 9, 2026
424ef24
fix(graph): bind sealed census size walks to held directories
ScriptedAlchemy Nov 9, 2026
db26ea9
fix(graph): bound sealed census directory descriptors
ScriptedAlchemy Nov 9, 2026
02a570b
style(graph): satisfy Bazel rustfmt for sealed census regression
ScriptedAlchemy Nov 9, 2026
66d70ee
fix(graph): remove unowned sealed cleanup after checked retirement
ScriptedAlchemy Nov 9, 2026
807a738
fix(graph): preserve sealed artifacts after uncertain reader close
ScriptedAlchemy Nov 9, 2026
44f9220
fix(graph): retain poisoned close state across retirement retries
ScriptedAlchemy Nov 9, 2026
b6f3ed5
fix(graph): retain failed-close readers across retirement retries
ScriptedAlchemy Nov 9, 2026
0b7c055
test(graph): prove failed retirement cannot delete sealed bytes
ScriptedAlchemy Nov 9, 2026
e50180b
fix(graph): preserve poisoned close with an absent engine
ScriptedAlchemy Nov 9, 2026
9dfe06d
fix(graph): fence lazy reopen against physical close
ScriptedAlchemy Nov 10, 2026
4af6755
style(graph): format lazy-close regression setup
ScriptedAlchemy Nov 10, 2026
a59921d
fix(graph): verify failed spill publication cleanup
ScriptedAlchemy Nov 10, 2026
9f04bed
style(graph): keep spill install result explicit
ScriptedAlchemy Nov 10, 2026
f1f880f
fix(graph): keep pending receipt failure cleanup owner-bound
ScriptedAlchemy Nov 10, 2026
72d885b
fix(graph): attribute sealed hardlinks to serving heads
ScriptedAlchemy Nov 10, 2026
8b1b062
style(graph): normalize sealed hardlink regression wrapping
ScriptedAlchemy Nov 10, 2026
94b5d9f
docs(graph): clarify shared-inode census accounting bounds
ScriptedAlchemy Nov 10, 2026
b23f36f
style(graph): align native tests with pinned rustfmt
ScriptedAlchemy Nov 10, 2026
bca1e23
fix(graph): bound sealed census hard-link bookkeeping
ScriptedAlchemy Nov 10, 2026
3c4fab3
fix(graph): keep shared census budget portable
ScriptedAlchemy Nov 10, 2026
d7bbee1
docs(graph): retain public sealed census API documentation
ScriptedAlchemy Nov 10, 2026
6027cba
fix(graph): refuse missing registered sealed retirement
ScriptedAlchemy Nov 10, 2026
9c3dfc4
style(graph): align retirement error mapping with rustfmt
ScriptedAlchemy Nov 10, 2026
b9798d7
test(graph): exercise relocated sealed paths on Unix
ScriptedAlchemy Nov 10, 2026
1f3e336
fix(graph): import sealed retirement verifier in native test
ScriptedAlchemy Nov 10, 2026
7367793
test(graph): align graph-db unit tests with sealed retirement contracts
ScriptedAlchemy Nov 10, 2026
6939336
fix(graph): release fully observed hard-link census identities
ScriptedAlchemy Nov 10, 2026
b1a9acd
test(graph): fix canonical corruption paths and formatting
ScriptedAlchemy Nov 10, 2026
580c546
fix(fs): retain census identities across sealed publication
ScriptedAlchemy Nov 10, 2026
cc772b3
test(graph): keep census scheduling private and preserve layered proof
ScriptedAlchemy Nov 10, 2026
80977b3
test(fs): import Unix inode metadata in census test module
ScriptedAlchemy Nov 10, 2026
6ac1861
test(graph): assert layered census generation counts
ScriptedAlchemy Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/tracedecay-graph-db/src/generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ pub use replay::{
pub(crate) use replay::{
checked_decode_replay_source, metadata_manifest_from_source, validate_supplied_rows_binding,
};
pub(crate) use spill::{ENTITIES_FILE, read_spilled_entity};
pub(crate) use spill::{
AbandonedRowSpill, ENTITIES_FILE, MAX_ROW_SPILL_OWNER_RECEIPT_BYTES,
ROW_SPILL_OWNER_RECEIPT_FILE, ROW_SPILL_PREFIX, read_spilled_entity, row_spill_owner,
};
pub use spill::{
GRAPH_ROW_SPILL_RUN_BYTES, GraphGenerationRowSpill, GraphGenerationRows,
GraphSpillRowFootprint, SpilledGraphGeneration,
Expand Down
538 changes: 533 additions & 5 deletions crates/tracedecay-graph-db/src/generation/spill.rs

Large diffs are not rendered by default.

65 changes: 61 additions & 4 deletions crates/tracedecay-graph-db/src/generation_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1840,6 +1840,11 @@ impl GraphDb {
self.retire_sealed_generation_store(locator)?;
return Ok(GenerationContentsDeletion::RetentionPending);
}
// Native row deletion may already have committed, but the generation
// is not collected until its independently durable sealed artifact is
// gone. If unlink refuses, keep the in-memory retirement state retryable
// instead of advertising collection while bytes still exist on disk.
self.retire_sealed_generation_store(locator)?;
let mut state = self.wait_verified_generations_write()?;
state.known.remove(locator);
state.quarantined.remove(locator);
Expand All @@ -1858,7 +1863,6 @@ impl GraphDb {
state.heads.remove(&locator.projection);
}
drop(state);
self.retire_sealed_generation_store(locator)?;
Ok(GenerationContentsDeletion::Deleted)
}

Expand Down Expand Up @@ -4001,20 +4005,73 @@ mod tests {
.delete_generation_contents(&locator, &|| Ok(()))
.expect_err("a failed sealed unlink must not complete retention");
assert!(
error
.to_string()
.contains("sealed directory removal failed")
matches!(
&error,
GraphDbError::Unavailable { message }
if message.starts_with("sealed generation store sealed directory removal failed:")
|| message.starts_with("sealed generation store sealed generation retirement refused:")
),
"an unremovable artifact must fail at the sealed-directory unlink boundary: {error}"
);
assert_eq!(std::fs::read(&directory).unwrap(), b"not a directory");
assert!(
!database
.wait_verified_generations_write()
.unwrap()
.collected
.contains(&locator),
"a failed sealed unlink must not mark its generation collected"
);

std::fs::remove_file(&directory).unwrap();
std::fs::create_dir(&directory).unwrap();
// Retirement requires an exact durable identity from the directory
// being removed. A receipt-free synthetic directory must never confer
// deletion authority when the strict collector is integrated.
let identity = generation.identity();
let receipt = serde_json::json!({
"version": 1,
"form": "compact",
"namespace": locator.projection.namespace.as_str(),
"projection": locator.projection.projection.as_str(),
"generation": locator.generation.as_str(),
"physical_namespace": namespace.as_str(),
"recovered_digest": sealed_digest(&generation).as_str(),
"entities": 0,
"relations": 0,
"graph_format": GraphFormatVersion::current().get(),
"row_sum": null,
"base": null,
"source_generation": identity.source_generation.as_str(),
"watermark": identity.watermark.as_str()
});
std::fs::write(
directory.join("sealed.json"),
serde_json::to_vec(&receipt).unwrap(),
)
.unwrap();
std::fs::write(directory.join("derived-rows"), b"retired").unwrap();
let unrelated = directory.parent().unwrap().join("unrelated-entry");
std::fs::create_dir(&unrelated).unwrap();
std::fs::write(unrelated.join("sentinel"), b"untouched").unwrap();
assert!(matches!(
database.delete_generation_contents(&locator, &|| Ok(())),
Ok(GenerationContentsDeletion::Deleted)
));
assert_eq!(
std::fs::read(unrelated.join("sentinel")).unwrap(),
b"untouched",
"retiring one sealed generation must not traverse another child"
);
assert!(!directory.exists());
assert!(
database
.wait_verified_generations_write()
.unwrap()
.collected
.contains(&locator),
"only successful physical retirement may mark the generation collected"
);
// Idempotent replay after the directory is gone still succeeds.
assert!(matches!(
database.delete_generation_contents(&locator, &|| Ok(())),
Expand Down
4 changes: 3 additions & 1 deletion crates/tracedecay-graph-db/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2806,7 +2806,9 @@ mod tests {
registry
.retain_verification_fault_for_lease(&lease, &fault)
.unwrap();
let path = corrupt_registration.canonical_path().to_path_buf();
// TempDir may live under a symlinked temporary root. The registry
// reports the physical canonical path selected at admission.
let path = std::fs::canonicalize(corrupt_registration.canonical_path()).unwrap();
let bytes = std::fs::read(&path).unwrap();

for _ in 0..2 {
Expand Down
30 changes: 24 additions & 6 deletions crates/tracedecay-graph-db/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1015,8 +1015,8 @@ impl GraphDb {
/// management, still checkpoints in full before this returns.
#[tracing::instrument(name = "graph_db.runtime.close", level = "trace", skip_all)]
pub(crate) fn close(&self) -> Result<(), GraphDbError> {
let engine_is_open = match self.inner.database.read() {
Ok(database) => database.is_some(),
let engine = match self.inner.database.read() {
Ok(database) => database,
Err(_) => {
self.inner.closed.store(true, Ordering::Release);
self.inner.poisoned.store(true, Ordering::Release);
Expand All @@ -1027,12 +1027,23 @@ impl GraphDb {
});
}
};
if !engine_is_open {
if self.inner.closed.swap(true, Ordering::AcqRel) {
return Err(GraphDbError::Closed);
if engine.is_none() {
// Keep the absence observation locked until close is published.
// A lazy opener must not install an engine in that interval.
let previously_closed = self.inner.closed.swap(true, Ordering::AcqRel);
// A failed physical close can already have consumed the engine.
// Its absent handle must not turn an uncertain result into a
// successful close (or an idempotent Closed on the next retry).
if self.inner.poisoned.load(Ordering::Acquire) {
return Err(durability_uncertain());
}
return Ok(());
return if previously_closed {
Err(GraphDbError::Closed)
} else {
Ok(())
};
}
drop(engine);
let _snapshot_gate = self.wait_snapshot_gate_write();
let mut guard =
match crate::observe::wait_lock(crate::observe::LOCK_WAIT_DATABASE_WRITE, || {
Expand Down Expand Up @@ -1713,11 +1724,16 @@ impl GraphDb {
return Ok(());
}
self.ensure_available()?;
#[cfg(test)]
test_seams::fire(test_seams::Seam::LazyOpenAdmission);
let mut database =
crate::observe::wait_lock(crate::observe::LOCK_WAIT_DATABASE_WRITE, || {
self.inner.database.write()
})
.map_err(|_| GraphDbError::unavailable("graph database write lock is poisoned"))?;
// Close or a failed hibernation may have won while this opener
// waited. Revalidate before publishing another native engine.
self.ensure_available()?;
if database.is_some() {
return Ok(());
}
Expand Down Expand Up @@ -1965,6 +1981,8 @@ pub(crate) mod test_seams {

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Seam {
/// After lazy-open admission, before acquiring the database write lock.
LazyOpenAdmission,
/// Immediately before grafeo opens the container at `site`.
EngineOpen(GraphEngineOpenSite),
/// After the engine has closed and synced the container, immediately
Expand Down
84 changes: 82 additions & 2 deletions crates/tracedecay-graph-db/src/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::time::Duration;

use grafeo_common::types::Value;

use super::test_seams::{self, Seam};
use super::{GraphDb, sync_wal};
use crate::location::PersistentGraphStoreState;
use crate::recovery::set_projection_quarantine;
Expand All @@ -29,6 +30,78 @@ fn memory_db() -> GraphDbLeaseV1 {
.unwrap()
}

#[test]
fn lazy_open_rechecks_close_before_materializing_an_engine() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("graph.grafeo");
let options = || GraphDbOpenOptions {
location: GraphDbLocation::Persistent(path.clone()),
expected_format: GraphFormatVersion::current(),
durability: GraphDurability::WalSync,
cancellation: Arc::new(NeverCancelled),
};
let seed = GraphDb::open(options()).unwrap();
seed.apply_unverified(scalar_batch("retained")).unwrap();
seed.close().unwrap();
drop(seed);

// Both a never-materialized mount and a hibernated engine can be absent
// when close races a lazy reader's admission.
for hibernated in [false, true] {
let database =
GraphDb::open_lazy_with_store_state(options(), PersistentGraphStoreState::Existing)
.unwrap();
if hibernated {
database.ensure_opened().unwrap();
database.hibernate_if_lazy().unwrap();
}
assert!(database.inner.database.read().unwrap().is_none());

let closing = Arc::clone(&database);
let admissions = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&admissions);
let hook = test_seams::install(move |seam| {
if seam == Seam::LazyOpenAdmission {
observed.fetch_add(1, Ordering::Relaxed);
// The opener has already observed Ready. Complete the real
// close before it takes its database write lock.
closing.close().unwrap();
}
});
let result = database.ensure_opened();
drop(hook);

assert_eq!(admissions.load(Ordering::Relaxed), 1);
assert_eq!(result, Err(GraphDbError::Closed));
assert_eq!(database.runtime_state(), GraphDbRuntimeState::Closed);
assert!(
database.inner.database.read().unwrap().is_none(),
"a completed close must not be followed by a resurrected engine"
);
}

// Refusing the stale opener must not corrupt the durable store or block
// a new, independently authorized mount from reading the committed row.
let reopened = GraphDb::open(options()).unwrap();
let snapshot = reopened.snapshot().unwrap();
let entity = snapshot
.entity(
&GraphNamespace::new("project").unwrap(),
&GraphEntityId::new("a").unwrap(),
Arc::new(NeverCancelled),
)
.unwrap()
.unwrap();
assert_eq!(
entity
.properties
.get(&GraphPropertyName::new("name").unwrap()),
Some(&GraphProperty::String("retained".to_owned()))
);
drop(snapshot);
reopened.close().unwrap();
}

#[test]
fn runtime_state_distinguishes_ready_closed_and_durability_uncertain() {
let ready = memory_db();
Expand Down Expand Up @@ -63,10 +136,17 @@ fn owner_close_releases_the_physical_database_after_durability_uncertainty() {
.to_owned(),
})
);
assert!(
!owner.engine_is_open().unwrap(),
"the poisoned close must release the physical database"
);
assert_eq!(
owner.close(),
Err(GraphDbError::Closed),
"the poisoned close must release the physical database before a second close"
Err(GraphDbError::DurabilityUncertain {
message: "the handle was poisoned after an observed post-commit persistence failure"
.to_owned(),
}),
"a retry after a poisoned close must stay uncertain, never idempotent Closed"
);
}

Expand Down
Loading
Loading