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
+41 -44
View File
@@ -8,21 +8,21 @@ use crate::temp_scan::{list_names, valid_temp_name};
use crate::{
ArtifactError, ArtifactStore, ReconciliationMutation, ReconciliationNamespace,
ReconciliationScan, ReconciliationScanStop,
recovery::{FINAL_NAMESPACE, namespace_name, namespace_number},
store::{
QUARANTINE_DIR, RootLock, check_file, checkpointed, ensure_root_unchanged, fsync_fd,
open_existing_dir, open_or_create_dir, rename_no_replace_at, stat_fd, unlinkat,
},
};
const FINAL_NAMESPACE: u8 = 0;
const QUARANTINE_NAMESPACE: u8 = 1;
/// Opaque bounded-scan continuation, valid only for an unchanged namespace.
/// Discard it after any put, quarantine, delete, or external mutation.
pub struct ReconciliationCursor {
root_dev: u64,
root_ino: u64,
namespace_start: u8,
namespace: u8,
namespace_end: u8,
shard: u8,
state: ReconciliationCursorState,
}
@@ -67,29 +67,17 @@ impl fmt::Debug for ReconciliationCursor {
/// Opaque inode-bound evidence that exposes neither digest nor filesystem path.
#[derive(Clone)]
pub struct ReconciliationCandidate {
root_dev: u64,
root_ino: u64,
namespace: ReconciliationNamespace,
shard: String,
shard_dev: u64,
shard_ino: u64,
name: String,
dev: u64,
ino: u64,
modified_seconds: i64,
modified_nanoseconds: i64,
}
impl fmt::Debug for ReconciliationCandidate {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ReconciliationCandidate(..)")
}
}
impl ReconciliationCandidate {
pub fn namespace(&self) -> ReconciliationNamespace {
self.namespace
}
pub(crate) root_dev: u64,
pub(crate) root_ino: u64,
pub(crate) namespace: ReconciliationNamespace,
pub(crate) shard: String,
pub(crate) shard_dev: u64,
pub(crate) shard_ino: u64,
pub(crate) name: String,
pub(crate) dev: u64,
pub(crate) ino: u64,
pub(crate) modified_seconds: i64,
pub(crate) modified_nanoseconds: i64,
}
/// Opaque single-use evidence for a stale temporary inode under the pinned root.
@@ -113,25 +101,31 @@ pub struct TempScan {
}
impl ArtifactStore {
/// Streams entries without disclosing paths/digests, with opaque continuation.
pub fn scan_reconciliation(
pub(crate) fn scan_reconciliation_bounded(
&self,
start_namespace: ReconciliationNamespace,
end_namespace: ReconciliationNamespace,
continuation: Option<ReconciliationCursor>,
scan_budget: usize,
result_limit: usize,
) -> Result<(ReconciliationScan, Vec<ReconciliationCandidate>), ArtifactError> {
let root = self.root()?;
let namespace = namespace_number(start_namespace);
let namespace_end = namespace_number(end_namespace);
let mut cursor = match continuation {
Some(cursor) => {
if cursor.root_dev != root.dev
|| cursor.root_ino != root.ino
|| cursor.namespace > QUARANTINE_NAMESPACE
|| cursor.namespace_start != namespace
|| cursor.namespace < namespace
|| cursor.namespace > cursor.namespace_end
|| cursor.namespace_end != namespace_end
{
return Err(ArtifactError::UnsafeRoot);
}
cursor
}
None => reconciliation_cursor(root.dev, root.ino),
None => reconciliation_cursor(root.dev, root.ino, namespace, namespace_end),
};
let report = ReconciliationScan::empty(None);
let _lock = match RootLock::shared(root) {
@@ -177,7 +171,7 @@ impl ArtifactStore {
}
let mut entries = Vec::new();
while cursor.namespace <= QUARANTINE_NAMESPACE {
while cursor.namespace <= cursor.namespace_end {
if entries.len() == result_limit {
return Ok(reconciliation_page(
report,
@@ -438,11 +432,18 @@ impl ArtifactStore {
}
}
fn reconciliation_cursor(root_dev: u64, root_ino: u64) -> ReconciliationCursor {
fn reconciliation_cursor(
root_dev: u64,
root_ino: u64,
namespace: u8,
namespace_end: u8,
) -> ReconciliationCursor {
ReconciliationCursor {
root_dev,
root_ino,
namespace: FINAL_NAMESPACE,
namespace_start: namespace,
namespace,
namespace_end,
shard: 0,
state: ReconciliationCursorState::OpenNamespace,
}
@@ -865,14 +866,6 @@ fn parse_dirent(buffer: &[u8], offset: usize) -> Result<(Vec<u8>, usize, i64), A
Ok((raw_name[..end].to_vec(), after, cookie))
}
fn namespace_name(namespace: u8) -> &'static [u8] {
match namespace {
FINAL_NAMESPACE => b"sha256",
QUARANTINE_NAMESPACE => QUARANTINE_DIR,
_ => unreachable!("validated reconciliation namespace"),
}
}
fn advance_reconciliation_shard(cursor: &mut ReconciliationCursor, namespace: OwnedFd) {
if cursor.shard == u8::MAX {
advance_namespace(cursor);
@@ -883,12 +876,16 @@ fn advance_reconciliation_shard(cursor: &mut ReconciliationCursor, namespace: Ow
}
fn advance_namespace(cursor: &mut ReconciliationCursor) {
cursor.namespace = cursor.namespace.saturating_add(1);
cursor.namespace = if cursor.namespace >= cursor.namespace_end {
cursor.namespace_end.saturating_add(1)
} else {
cursor.namespace + 1
};
cursor.shard = 0;
cursor.state = ReconciliationCursorState::OpenNamespace;
}
fn ensure_reconciliation_root(
pub(crate) fn ensure_reconciliation_root(
root: &crate::store::Root,
candidate: &ReconciliationCandidate,
) -> Result<(), ArtifactError> {
@@ -904,7 +901,7 @@ fn ensure_reconciliation_root(
Ok(())
}
fn revalidate_candidate(
pub(crate) fn revalidate_candidate(
candidate: &ReconciliationCandidate,
shard: i32,
) -> Result<(), ArtifactError> {