feat(artifacts): add fenced reconciliation recovery

This commit is contained in:
2026-08-28 00:44:27 +03:00
parent 8784964fb2
commit d2849ea3fe
21 changed files with 3322 additions and 97 deletions
+208 -2
View File
@@ -3,14 +3,15 @@ use std::{
os::unix::fs::{MetadataExt, PermissionsExt},
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime},
};
#[cfg(debug_assertions)]
use std::{process::Command, sync::Arc, thread, time::Duration};
use std::{process::Command, sync::Arc, thread};
use crank_artifacts::{
ArtifactError, ArtifactStore, ReconciliationMutation, ReconciliationNamespace,
ReconciliationScanStop,
ReconciliationPresence, ReconciliationRegistration, ReconciliationScanStop,
};
#[cfg(debug_assertions)]
@@ -769,3 +770,208 @@ fn crash_windows_are_recoverable() {
);
}
}
#[test]
fn registration_revalidates_content_and_enforces_grace_without_candidate_identity_access() {
let root = TestRoot::new("registration");
let store = ArtifactStore::open(&root.0).unwrap();
let stored = store.put(b"reconciliation registration").unwrap();
let (_, mut candidates) = store
.scan_reconciliation(None, FULL_SCAN_BUDGET, 8)
.unwrap();
let candidate = candidates.pop().unwrap();
assert_eq!(format!("{candidate:?}"), "ReconciliationCandidate(..)");
assert!(!candidate.is_grace_eligible(Duration::from_secs(3600), SystemTime::now()));
assert_eq!(
format!(
"{:?}",
store
.register_reconciliation(&candidate, Duration::from_secs(3600), SystemTime::now())
.unwrap()
),
"ReconciliationRegistration(..)"
);
let registration = store
.register_reconciliation(
&candidate,
Duration::ZERO,
SystemTime::now() + Duration::from_secs(3600),
)
.unwrap();
let registered = match registration {
ReconciliationRegistration::Registered(registered) => registered,
ReconciliationRegistration::NotEligible => panic!("zero grace must be eligible"),
};
assert_eq!(registered.artifact_ref(), &stored.artifact_ref);
assert_eq!(
registered.size_bytes(),
b"reconciliation registration".len()
);
// The candidate remains usable for the physical mutation after the
// registration bridge borrowed it.
assert_eq!(
store.quarantine_reconciliation(candidate).unwrap(),
ReconciliationMutation::Quarantined
);
}
#[test]
fn namespace_scan_can_finish_quarantine_recovery_before_final_sweep() {
let root = TestRoot::new("namespace-order");
let store = ArtifactStore::open(&root.0).unwrap();
store.put(b"quarantine-first recovery").unwrap();
let (_, candidates) = store
.scan_reconciliation_namespace(ReconciliationNamespace::Final, None, FULL_SCAN_BUDGET, 8)
.unwrap();
store
.quarantine_reconciliation(candidates.into_iter().next().unwrap())
.unwrap();
store.put(b"quarantine-first recovery").unwrap();
let (quarantine_report, quarantine_candidates) = store
.scan_reconciliation_namespace(
ReconciliationNamespace::Quarantine,
None,
FULL_SCAN_BUDGET,
8,
)
.unwrap();
assert_eq!(quarantine_report.stop, ReconciliationScanStop::Complete);
assert_eq!(quarantine_report.final_entries, 0);
assert_eq!(quarantine_report.quarantined_entries, 1);
assert_eq!(quarantine_candidates.len(), 1);
assert!(quarantine_report.continuation.is_none());
let (final_report, final_candidates) = store
.scan_reconciliation_namespace(ReconciliationNamespace::Final, None, FULL_SCAN_BUDGET, 8)
.unwrap();
assert_eq!(final_report.stop, ReconciliationScanStop::Complete);
assert_eq!(final_report.final_entries, 1);
assert_eq!(final_report.quarantined_entries, 0);
assert_eq!(final_candidates.len(), 1);
}
#[test]
fn namespace_scan_rejects_a_cursor_from_a_broader_scan() {
let root = TestRoot::new("namespace-cursor-scope");
let store = ArtifactStore::open(&root.0).unwrap();
let (report, _) = store.scan_reconciliation(None, 0, 1).unwrap();
let cursor = report
.continuation
.expect("zero-budget scan retains cursor");
assert!(matches!(
store.scan_reconciliation_namespace(
ReconciliationNamespace::Quarantine,
Some(cursor),
FULL_SCAN_BUDGET,
1,
),
Err(ArtifactError::UnsafeRoot)
));
}
#[test]
fn full_scan_rejects_a_quarantine_only_cursor() {
let root = TestRoot::new("namespace-cursor-narrow");
let store = ArtifactStore::open(&root.0).unwrap();
let (report, _) = store
.scan_reconciliation_namespace(ReconciliationNamespace::Quarantine, None, 0, 1)
.unwrap();
let cursor = report
.continuation
.expect("zero-budget namespace scan retains cursor");
assert!(matches!(
store.scan_reconciliation(Some(cursor), FULL_SCAN_BUDGET, 1),
Err(ArtifactError::UnsafeRoot)
));
}
#[test]
fn presence_probe_distinguishes_final_quarantine_and_absent_without_disclosure() {
let root = TestRoot::new("presence");
let store = ArtifactStore::open(&root.0).unwrap();
let stored = store.put(b"presence probe").unwrap();
assert_eq!(
store.reconciliation_presence(&stored.artifact_ref).unwrap(),
ReconciliationPresence::Final
);
let (_, candidates) = store
.scan_reconciliation(None, FULL_SCAN_BUDGET, 8)
.unwrap();
store
.quarantine_reconciliation(candidates.into_iter().next().unwrap())
.unwrap();
assert_eq!(
store.reconciliation_presence(&stored.artifact_ref).unwrap(),
ReconciliationPresence::Quarantine
);
// A republish can leave both locations during recovery. Quarantine takes
// precedence so the stale inode is still scheduled for deletion.
store.put(b"presence probe").unwrap();
assert_eq!(
store.reconciliation_presence(&stored.artifact_ref).unwrap(),
ReconciliationPresence::Quarantine
);
let (_, candidates) = store
.scan_reconciliation_namespace(
ReconciliationNamespace::Quarantine,
None,
FULL_SCAN_BUDGET,
8,
)
.unwrap();
store
.delete_quarantined_reconciliation(candidates.into_iter().next().unwrap())
.unwrap();
assert_eq!(
store.reconciliation_presence(&stored.artifact_ref).unwrap(),
ReconciliationPresence::Final
);
let (_, candidates) = store
.scan_reconciliation_namespace(ReconciliationNamespace::Final, None, FULL_SCAN_BUDGET, 8)
.unwrap();
store
.quarantine_reconciliation(candidates.into_iter().next().unwrap())
.unwrap();
let (_, candidates) = store
.scan_reconciliation_namespace(
ReconciliationNamespace::Quarantine,
None,
FULL_SCAN_BUDGET,
8,
)
.unwrap();
store
.delete_quarantined_reconciliation(candidates.into_iter().next().unwrap())
.unwrap();
assert_eq!(
store.reconciliation_presence(&stored.artifact_ref).unwrap(),
ReconciliationPresence::Absent
);
}
#[cfg(debug_assertions)]
#[test]
fn presence_probe_classifies_storage_failure_as_retryable() {
let _guard = fault_guard();
let root = TestRoot::new("presence-retryable");
let store = ArtifactStore::open(&root.0).unwrap();
let stored = store.put(b"presence retryable").unwrap();
set_checkpoint("reconciliation_presence_open", FaultAction::Fail);
assert_eq!(
store.reconciliation_presence(&stored.artifact_ref).unwrap(),
ReconciliationPresence::Retryable
);
clear_checkpoint();
assert_eq!(
store.reconciliation_presence(&stored.artifact_ref).unwrap(),
ReconciliationPresence::Final
);
}