diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index d2fdd35dd58..a514bbc76de 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -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); @@ -1858,7 +1863,6 @@ impl GraphDb { state.heads.remove(&locator.projection); } drop(state); - self.retire_sealed_generation_store(locator)?; Ok(GenerationContentsDeletion::Deleted) } @@ -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(())),