feat(artifacts): add fenced reconciliation recovery
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user