Bind prepared artifact selection against reclaim

This commit is contained in:
ms2k 2026-07-22 01:51:46 +09:00
commit 1bd1d395d9

View file

@ -14,7 +14,9 @@ use lazaret_core::identity::{
SelectedManifestDigest, SourceIdentity,
};
use lazaret_core::records::{ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact};
use lazaret_linux::filesystem::{ExclusiveFileLock, try_exclusive_flock};
use lazaret_linux::filesystem::{
ExclusiveFileLock, SharedFileLock, try_exclusive_flock, try_shared_flock,
};
use lazaret_storage_catalog::{Catalog, CatalogError, CatalogRow};
use rustix::fs::{
AtFlags, Mode, OFlags, RenameFlags, ResolveFlags, mkdirat, openat2, renameat, renameat_with,
@ -56,6 +58,7 @@ pub enum PrepareRecovery {
pub enum ReclaimOutcome {
Removed,
ReplacementPreserved,
RetainedBySelection,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@ -64,6 +67,25 @@ pub struct PreparedArtifactGcResult {
pub outcome: ReclaimOutcome,
}
#[derive(Debug)]
pub struct PreparedArtifactSelection {
artifact: PreparedArtifact,
authority: File,
claim: SharedFileLock,
}
impl PreparedArtifactSelection {
#[must_use]
pub fn artifact(&self) -> &PreparedArtifact {
&self.artifact
}
#[must_use]
pub fn into_parts(self) -> (PreparedArtifact, File, SharedFileLock) {
(self.artifact, self.authority, self.claim)
}
}
#[derive(Clone, Debug)]
pub struct PreparedLowerStore {
root: Arc<File>,
@ -178,6 +200,52 @@ impl PreparedLowerStore {
.context("prepared artifact path is absent")
}
/// Selects the current equivalent artifact and retains a claim that excludes reclaim.
///
/// # Errors
/// Refuses semantic identity drift, catalog inconsistency, or physical identity drift.
pub fn select_artifact(
&self,
expected: &PreparedArtifact,
) -> PreparedLowerResult<Option<PreparedArtifactSelection>> {
let physical_lock = self.physical_lock()?;
self.select_artifact_inner(expected, &physical_lock)
.map_err(PreparedLowerError::confinement)
}
fn select_artifact_inner(
&self,
expected: &PreparedArtifact,
_physical_lock: &ExclusiveFileLock,
) -> Result<Option<PreparedArtifactSelection>> {
let Some(artifact) = self.available_artifact(expected.prepared_artifact_id)? else {
return Ok(None);
};
require_equivalent_artifact(expected, &artifact)?;
let path = artifact_path(artifact.prepared_artifact_id);
let authority = self
.open_relative(&path)?
.context("selected artifact path is absent")?;
require_identity(&authority, artifact.device, artifact.inode)?;
let claim_file = self
.open_relative(&path)?
.context("selected artifact claim path is absent")?;
require_identity(&claim_file, artifact.device, artifact.inode)?;
let claim = try_shared_flock(claim_file)?;
if self
.catalog_artifact(artifact.prepared_artifact_id)?
.as_ref()
!= Some(&artifact)
{
bail!("selected artifact catalog ownership changed");
}
Ok(Some(PreparedArtifactSelection {
artifact,
authority,
claim,
}))
}
/// Materializes and atomically publishes one verified immutable prepared lower.
///
/// # Errors
@ -498,11 +566,26 @@ impl PreparedLowerStore {
/// Refuses leases, forged catalog rows, physical identity mismatch, foreign paths, and
/// filesystem or catalog failures.
pub fn reclaim(&self, expected: &PreparedArtifact) -> PreparedLowerResult<ReclaimOutcome> {
let _physical_lock = self.physical_lock()?;
let artifact = self
.open_relative(&artifact_path(expected.prepared_artifact_id))
.map_err(PreparedLowerError::reclaim)?
.ok_or_else(|| PreparedLowerError::reclaim(anyhow::anyhow!("artifact is absent")))?;
require_identity(&artifact, expected.device, expected.inode)
.map_err(PreparedLowerError::reclaim)?;
let _claim = match try_exclusive_flock(artifact) {
Ok(claim) => claim,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
return Ok(ReclaimOutcome::RetainedBySelection);
}
Err(error) => return Err(PreparedLowerError::reclaim(error)),
};
let reclaiming = self
.catalog
.begin_reclaim(expected)
.map_err(PreparedLowerError::catalog)?;
self.resume_reclaim(&reclaiming)
self.resume_reclaim_inner(&reclaiming)
.map_err(PreparedLowerError::reclaim)
}
/// Resumes an exact durable reclaim after interruption.
@ -516,10 +599,28 @@ impl PreparedLowerStore {
reclaiming: &PreparedArtifact,
) -> PreparedLowerResult<ReclaimOutcome> {
let _physical_lock = self.physical_lock()?;
let _claim = self
.lock_reclaiming_artifact(reclaiming)
.map_err(PreparedLowerError::reclaim)?;
self.resume_reclaim_inner(reclaiming)
.map_err(PreparedLowerError::reclaim)
}
fn lock_reclaiming_artifact(&self, reclaiming: &PreparedArtifact) -> Result<ExclusiveFileLock> {
for path in [
artifact_path(reclaiming.prepared_artifact_id),
quarantine_path(reclaiming),
] {
let Some(file) = self.open_relative(&path)? else {
continue;
};
if file_identity(&file)? == (reclaiming.device, reclaiming.inode) {
return Ok(try_exclusive_flock(file)?);
}
}
bail!("reclaiming artifact and quarantine are both absent")
}
fn resume_reclaim_inner(&self, reclaiming: &PreparedArtifact) -> Result<ReclaimOutcome> {
if reclaiming.state != ArtifactState::Reclaiming {
bail!("artifact is not reclaiming");
@ -903,6 +1004,24 @@ fn artifact_from_operation(
}
}
fn require_equivalent_artifact(
expected: &PreparedArtifact,
observed: &PreparedArtifact,
) -> Result<()> {
if expected.state != ArtifactState::Available
|| expected.prepared_artifact_id != observed.prepared_artifact_id
|| expected.source_identity != observed.source_identity
|| expected.selected_manifest_digest != observed.selected_manifest_digest
|| expected.policy_id != observed.policy_id
|| expected.id_map_digest != observed.id_map_digest
|| expected.path != observed.path
|| observed.state != ArtifactState::Available
{
bail!("selected prepared artifact identity inputs differ");
}
Ok(())
}
fn file_identity(file: &File) -> Result<(DeviceId, InodeId)> {
let stat = rustix::fs::fstat(file)?;
Ok((DeviceId::new(stat.st_dev), InodeId::new(stat.st_ino)))