Files
rustfs/crates/ecstore/src/bucket/lifecycle/durable_namespace.rs
T
cxymds 4d226998e2 fix(ilm): chunk large tier-delete dispatches (#7123)
* fix(ilm): chunk large tier-delete dispatches

* fix(ilm): bound tier-delete chunk dispatch stack use
2026-09-04 10:30:32 +00:00

1609 lines
70 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::BTreeMap;
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{
bucket_lifecycle_ops::{
ManualTransitionQueueSnapshot, ManualTransitionRunReport, decode_manual_transition_continuation_token,
},
manual_transition_job, tier_delete_journal, transition_transaction,
};
use crate::error::{Error, Result};
pub(crate) const ILM_META_PREFIX: &str = "ilm";
const ILM_META_OBJECT_PREFIX: &str = "ilm/";
const MANUAL_TRANSITION_CURSOR_MARKER_PROOF_MAX_SIZE: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DurableIlmRecordKind {
TierDeleteJournal,
TierDeleteDispatchManifest,
TransitionTransaction,
ManualTransitionJob,
ManualTransitionScope,
ManualTransitionTask,
ManualTransitionWorkerResult,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DurableIlmNamespace {
pub(crate) name: &'static str,
pub(crate) prefix: &'static str,
pub(crate) max_record_size: usize,
kind: DurableIlmRecordKind,
}
pub(crate) const TIER_DELETE_JOURNAL_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "tier-delete-journal",
prefix: "ilm/tier-delete-journal/",
max_record_size: 64 * 1024,
kind: DurableIlmRecordKind::TierDeleteJournal,
};
pub(crate) const TIER_DELETE_JOURNAL_V6_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "tier-delete-journal-v6",
prefix: "ilm/tier-delete-journal-v6/",
max_record_size: 64 * 1024,
kind: DurableIlmRecordKind::TierDeleteJournal,
};
pub(crate) const TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "tier-delete-dispatch-manifest",
prefix: tier_delete_journal::TIER_DELETE_DISPATCH_MANIFEST_PREFIX,
max_record_size: tier_delete_journal::MAX_TIER_DELETE_DISPATCH_MANIFEST_SIZE,
kind: DurableIlmRecordKind::TierDeleteDispatchManifest,
};
pub(crate) const TRANSITION_TRANSACTION_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "transition-transaction",
prefix: "ilm/transition-transactions/records",
max_record_size: transition_transaction::MAX_TRANSITION_TRANSACTION_SIZE,
kind: DurableIlmRecordKind::TransitionTransaction,
};
pub(crate) const MANUAL_TRANSITION_JOB_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "manual-transition-job",
prefix: "ilm/manual-transition/jobs",
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_JOB_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionJob,
};
pub(crate) const MANUAL_TRANSITION_SCOPE_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "manual-transition-scope",
prefix: "ilm/manual-transition/scopes",
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_JOB_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionScope,
};
pub(crate) const MANUAL_TRANSITION_TASK_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "manual-transition-task",
prefix: "ilm/manual-transition/tasks",
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_TASK_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionTask,
};
pub(crate) const MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "manual-transition-worker-result",
prefix: "ilm/manual-transition/results",
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionWorkerResult,
};
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 8] = [
TIER_DELETE_JOURNAL_NAMESPACE,
TIER_DELETE_JOURNAL_V6_NAMESPACE,
TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE,
TRANSITION_TRANSACTION_NAMESPACE,
MANUAL_TRANSITION_JOB_NAMESPACE,
MANUAL_TRANSITION_SCOPE_NAMESPACE,
MANUAL_TRANSITION_TASK_NAMESPACE,
MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ValidatedDurableIlmRecord {
pub(crate) namespace: &'static str,
pub(crate) id_kind: &'static str,
pub(crate) id: String,
pub(crate) checkpoint: DurableIlmRecordCheckpoint,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ManualTransitionJobProgressCheckpoint {
report: ManualTransitionRunReport,
queue_snapshot: ManualTransitionQueueSnapshot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ManualTransitionJobProgressProof {
scope_sha256: String,
scanned: u64,
eligible: u64,
enqueued: u64,
dry_run_eligible: u64,
skipped_not_transition: u64,
skipped_tier: u64,
skipped_delete_marker: u64,
skipped_directory: u64,
skipped_replication: u64,
skipped_already_transitioned: u64,
skipped_already_in_flight: u64,
skipped_queue_full: u64,
skipped_queue_closed: u64,
skipped_queue_timeout: u64,
transition_completed: u64,
transition_failed: u64,
tier_failure: u64,
tier_failure_by_reason: BTreeMap<manual_transition_job::ManualTransitionWorkerFailureReason, u64>,
lifecycle_config_found: bool,
truncated_by_limit: bool,
truncated_by_duration: bool,
cancelled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
continuation_token_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cursor_marker: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cursor_revision: Option<u64>,
queue_snapshot: ManualTransitionQueueSnapshot,
}
impl ValidatedDurableIlmRecord {
pub(crate) fn context(&self) -> String {
format!("namespace `{}` {} `{}`", self.namespace, self.id_kind, self.id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub(crate) enum DurableIlmRecordCheckpoint {
TierDeleteJournal {
content_sha256: String,
identity_sha256: String,
committed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
dispatch_identity_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
state: Option<super::tier_sweeper::TierDeleteJournalState>,
},
TierDeleteDispatchManifest {
content_sha256: String,
identity_sha256: String,
state: tier_delete_journal::TierDeleteDispatchManifestState,
},
TierDeleteDispatchParent {
content_sha256: String,
identity_sha256: String,
revision: u64,
next_chunk_sequence: u64,
completed_journal_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
active_chunk_identity_sha256: Option<String>,
completed: bool,
},
TransitionTransaction {
content_sha256: String,
identity_sha256: String,
remote_version_sha256: String,
remote_version_known: bool,
revision: u64,
state: transition_transaction::TransitionTransactionState,
},
ManualTransitionJob {
content_sha256: String,
identity_sha256: String,
updated_at_unix_nanos: i64,
state: manual_transition_job::ManualTransitionJobState,
scan_completed: bool,
cancel_requested: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
progress: Option<Box<ManualTransitionJobProgressCheckpoint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
progress_proof: Option<Box<ManualTransitionJobProgressProof>>,
},
ManualTransitionScope {
content_sha256: String,
identity_sha256: String,
updated_at_unix_nanos: i64,
},
ManualTransitionTask {
content_sha256: String,
},
ManualTransitionWorkerResult {
content_sha256: String,
},
}
impl DurableIlmRecordCheckpoint {
pub(crate) fn content_sha256(&self) -> &str {
match self {
Self::TierDeleteJournal { content_sha256, .. }
| Self::TierDeleteDispatchManifest { content_sha256, .. }
| Self::TierDeleteDispatchParent { content_sha256, .. }
| Self::TransitionTransaction { content_sha256, .. }
| Self::ManualTransitionJob { content_sha256, .. }
| Self::ManualTransitionScope { content_sha256, .. }
| Self::ManualTransitionTask { content_sha256 }
| Self::ManualTransitionWorkerResult { content_sha256 } => content_sha256,
}
}
pub(crate) fn compacted(&self) -> Result<Self> {
let mut checkpoint = self.clone();
if let Self::ManualTransitionJob {
progress,
progress_proof,
..
} = &mut checkpoint
{
match (progress.take(), progress_proof.take()) {
(Some(progress), None) => {
*progress_proof = Some(Box::new(ManualTransitionJobProgressProof::new(
&progress.report,
&progress.queue_snapshot,
None,
)?));
}
(None, Some(proof)) if proof.is_valid() => *progress_proof = Some(proof),
(None, None) => {}
_ => return Err(Error::other("durable ILM manual transition checkpoint is invalid")),
}
}
Ok(checkpoint)
}
pub(crate) fn validate_successor(&self, next: &Self) -> Result<()> {
for checkpoint in [self, next] {
if let Self::TierDeleteJournal {
committed,
dispatch_identity_sha256,
state,
..
} = checkpoint
&& (state.is_some() != dispatch_identity_sha256.is_some()
|| state.is_some_and(|state| *committed != (state == super::tier_sweeper::TierDeleteJournalState::Committed)))
{
return Err(Error::other("durable ILM tier delete journal checkpoint is invalid"));
}
}
if self == next {
if let Self::ManualTransitionJob {
progress,
progress_proof,
..
} = self
&& !manual_job_progress_checkpoint_is_valid(progress.as_deref(), progress_proof.as_deref())
{
return Err(Error::other("durable ILM manual transition checkpoint is invalid"));
}
return Ok(());
}
let valid = match (self, next) {
(
Self::TierDeleteJournal {
content_sha256: previous_content,
identity_sha256: previous_identity,
committed: previous_committed,
dispatch_identity_sha256: previous_dispatch_identity,
state: previous_state,
..
},
Self::TierDeleteJournal {
content_sha256: next_content,
identity_sha256: next_identity,
committed: next_committed,
dispatch_identity_sha256: next_dispatch_identity,
state: next_state,
..
},
) => {
use super::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
let dispatch_identity_is_monotonic = match (previous_dispatch_identity, next_dispatch_identity) {
(Some(previous), Some(next)) => previous == next,
(None, None) => true,
// Old receipts did not record the v6 dispatch binding. A
// byte-identical observation may adopt the stronger proof,
// but an in-flight mutation must fail closed instead of
// guessing which operation owned the journal.
(None, Some(_)) => previous_content == next_content,
(Some(_), None) => false,
};
let state_is_monotonic = match (previous_state, next_state) {
(Some(previous), Some(next)) => {
previous == next || matches!((previous, next), (Prepared, Dispatched) | (Dispatched, Committed))
}
(None, None) => previous_committed == next_committed || (!previous_committed && *next_committed),
(None, Some(_)) => previous_content == next_content,
(Some(_), None) => false,
};
previous_identity == next_identity && dispatch_identity_is_monotonic && state_is_monotonic
}
(
Self::TierDeleteDispatchManifest {
identity_sha256: previous_identity,
state: previous_state,
..
},
Self::TierDeleteDispatchManifest {
identity_sha256: next_identity,
state: next_state,
..
},
) => {
use tier_delete_journal::TierDeleteDispatchManifestState::{
Aborted, Aborting, Completed, DispatchAuthorized, Preparing,
};
previous_identity == next_identity
&& matches!(
(previous_state, next_state),
(Preparing, DispatchAuthorized | Aborting) | (Aborting, Aborted) | (DispatchAuthorized, Completed)
)
}
(
Self::TierDeleteDispatchParent {
identity_sha256: previous_identity,
revision: previous_revision,
next_chunk_sequence: previous_sequence,
completed_journal_count: previous_completed_journals,
active_chunk_identity_sha256: previous_active,
completed: previous_completed,
..
},
Self::TierDeleteDispatchParent {
identity_sha256: next_identity,
revision: next_revision,
next_chunk_sequence: next_sequence,
completed_journal_count: next_completed_journals,
active_chunk_identity_sha256: next_active,
completed: next_completed,
..
},
) => {
let Some((sequence_delta, completed_journal_delta)) = tier_delete_dispatch_parent_progress_delta(
*previous_sequence,
*previous_completed_journals,
*next_sequence,
*next_completed_journals,
) else {
return Err(Error::other("durable ILM record generation is not a monotonic successor"));
};
let same_position_transition = sequence_delta == 0
&& completed_journal_delta == 0
&& matches!(
(previous_active.as_ref(), next_active.as_ref(), previous_completed, next_completed),
(None, Some(_), false, false) | (Some(_), None, false, false) | (None, None, false, true)
);
let progress_transition = sequence_delta > 0
&& completed_journal_delta > 0
&& !matches!(
(previous_active.as_ref(), next_active.as_ref()),
(Some(previous), Some(next)) if previous == next
);
previous_identity == next_identity
&& !previous_completed
&& next_revision > previous_revision
&& (!next_completed || next_active.is_none())
&& (same_position_transition || progress_transition)
}
(
Self::TransitionTransaction {
identity_sha256: previous_identity,
remote_version_sha256: previous_remote_version,
remote_version_known: previous_remote_version_known,
revision: previous_revision,
state: previous_state,
..
},
Self::TransitionTransaction {
identity_sha256: next_identity,
remote_version_sha256: next_remote_version,
revision: next_revision,
state: next_state,
..
},
) => {
previous_identity == next_identity
&& transition_state_distance(*previous_state, *next_state)
.and_then(|distance| previous_revision.checked_add(distance))
.is_some_and(|expected_revision| *next_revision == expected_revision)
&& (!previous_remote_version_known || previous_remote_version == next_remote_version)
}
(
Self::ManualTransitionJob {
content_sha256: previous_content,
identity_sha256: previous_identity,
updated_at_unix_nanos: previous_updated_at,
state: previous_state,
scan_completed: previous_scan_completed,
cancel_requested: previous_cancel_requested,
progress: previous_progress,
progress_proof: previous_progress_proof,
..
},
Self::ManualTransitionJob {
content_sha256: next_content,
identity_sha256: next_identity,
updated_at_unix_nanos: next_updated_at,
state: next_state,
scan_completed: next_scan_completed,
cancel_requested: next_cancel_requested,
progress: next_progress,
progress_proof: next_progress_proof,
..
},
) => {
let same_generation = previous_content == next_content
&& previous_identity == next_identity
&& previous_updated_at == next_updated_at
&& previous_state == next_state
&& previous_scan_completed == next_scan_completed
&& previous_cancel_requested == next_cancel_requested
&& manual_job_progress_equivalent(
previous_progress.as_deref(),
previous_progress_proof.as_deref(),
next_progress.as_deref(),
next_progress_proof.as_deref(),
);
same_generation
|| (previous_identity == next_identity
&& next_updated_at > previous_updated_at
&& manual_job_state_reaches(*previous_state, *next_state)
&& (!previous_scan_completed || *next_scan_completed)
&& (!previous_cancel_requested || *next_cancel_requested)
&& manual_job_progress_reaches(
previous_progress.as_deref(),
previous_progress_proof.as_deref(),
next_progress.as_deref(),
next_progress_proof.as_deref(),
*next_scan_completed,
))
}
(
Self::ManualTransitionScope {
identity_sha256: previous_identity,
updated_at_unix_nanos: previous_updated_at,
..
},
Self::ManualTransitionScope {
identity_sha256: next_identity,
updated_at_unix_nanos: next_updated_at,
..
},
) => previous_identity == next_identity && next_updated_at > previous_updated_at,
_ => false,
};
if valid {
Ok(())
} else {
Err(Error::other("durable ILM record generation is not a monotonic successor"))
}
}
/// Whether `self` is an older generation of the same immutable record
/// that can reach `terminal` through one or more valid state transitions.
/// This is deliberately broader than `validate_successor`, which remains
/// adjacent-only for receipt advancement. Terminal cleanup uses this only
/// after the exact terminal ETag and terminal receipt were committed, to
/// purge older object versions exposed by that deletion.
pub(crate) fn is_predecessor_of_terminal(&self, terminal: &Self) -> bool {
if self == terminal || self.validate_successor(terminal).is_ok() {
return true;
}
match (self, terminal) {
(
Self::TierDeleteJournal {
identity_sha256: previous_identity,
dispatch_identity_sha256: previous_dispatch,
state: Some(super::tier_sweeper::TierDeleteJournalState::Prepared),
..
},
Self::TierDeleteJournal {
identity_sha256: terminal_identity,
dispatch_identity_sha256: terminal_dispatch,
state: Some(super::tier_sweeper::TierDeleteJournalState::Committed),
..
},
) => previous_identity == terminal_identity && previous_dispatch == terminal_dispatch,
(
Self::TierDeleteDispatchManifest {
identity_sha256: previous_identity,
state: tier_delete_journal::TierDeleteDispatchManifestState::Preparing,
..
},
Self::TierDeleteDispatchManifest {
identity_sha256: terminal_identity,
state:
tier_delete_journal::TierDeleteDispatchManifestState::Aborted
| tier_delete_journal::TierDeleteDispatchManifestState::Completed,
..
},
) => previous_identity == terminal_identity,
(
Self::TierDeleteDispatchParent {
identity_sha256: previous_identity,
revision: previous_revision,
next_chunk_sequence: previous_sequence,
completed_journal_count: previous_completed_journals,
active_chunk_identity_sha256: previous_active,
completed: false,
..
},
Self::TierDeleteDispatchParent {
identity_sha256: terminal_identity,
revision: terminal_revision,
next_chunk_sequence: terminal_sequence,
completed_journal_count: terminal_completed_journals,
active_chunk_identity_sha256: None,
completed: true,
..
},
) => {
previous_identity == terminal_identity
&& terminal_revision > previous_revision
&& tier_delete_dispatch_parent_progress_delta(
*previous_sequence,
*previous_completed_journals,
*terminal_sequence,
*terminal_completed_journals,
)
.is_some_and(|(sequence_delta, completed_journal_delta)| {
if sequence_delta == 0 && completed_journal_delta == 0 {
previous_active.is_none()
} else {
sequence_delta > 0 && completed_journal_delta > 0
}
})
}
_ => false,
}
}
}
fn tier_delete_dispatch_parent_progress_delta(
previous_sequence: u64,
previous_completed_journals: u64,
next_sequence: u64,
next_completed_journals: u64,
) -> Option<(u64, u64)> {
let sequence_delta = next_sequence.checked_sub(previous_sequence)?;
let completed_journal_delta = next_completed_journals.checked_sub(previous_completed_journals)?;
(sequence_delta <= completed_journal_delta).then_some((sequence_delta, completed_journal_delta))
}
fn transition_state_distance(
from: transition_transaction::TransitionTransactionState,
to: transition_transaction::TransitionTransactionState,
) -> Option<u64> {
use transition_transaction::TransitionTransactionState::{
AbortedNoRemote, CleanupPending, Committed, LocalCommitStarted, UploadOutcomeUnknown, UploadStarted, Uploaded,
};
match (from, to) {
(UploadStarted, UploadOutcomeUnknown | AbortedNoRemote | Uploaded) => Some(1),
(UploadStarted, LocalCommitStarted | CleanupPending) => Some(2),
(UploadStarted, Committed) => Some(3),
(UploadOutcomeUnknown, Uploaded | CleanupPending) => Some(1),
(UploadOutcomeUnknown, LocalCommitStarted) => Some(2),
(UploadOutcomeUnknown, Committed) => Some(3),
(Uploaded, LocalCommitStarted | CleanupPending) => Some(1),
(Uploaded, Committed) => Some(2),
(LocalCommitStarted, Committed | CleanupPending) => Some(1),
_ => None,
}
}
fn manual_job_state_reaches(
from: manual_transition_job::ManualTransitionJobState,
to: manual_transition_job::ManualTransitionJobState,
) -> bool {
from == to || from == manual_transition_job::ManualTransitionJobState::Running
}
impl ManualTransitionJobProgressProof {
fn new(
report: &ManualTransitionRunReport,
queue_snapshot: &ManualTransitionQueueSnapshot,
cursor_revision: Option<u64>,
) -> Result<Self> {
let cursor_revision = cursor_revision.or_else(|| manual_transition_job::manual_transition_cursor_revision(report));
let cursor_marker = match report.continuation_token.as_deref() {
Some(token) => {
let marker = decode_manual_transition_continuation_token(token)?
.0
.ok_or_else(|| Error::other("durable ILM manual transition cursor marker is missing"))?;
(marker.len() <= MANUAL_TRANSITION_CURSOR_MARKER_PROOF_MAX_SIZE).then_some(marker)
}
None => None,
};
if !manual_job_worker_results_are_valid(report)
|| !manual_job_queue_snapshot_is_valid(queue_snapshot)
|| (report.continuation_token.is_some() && cursor_revision == Some(0))
{
return Err(Error::other("durable ILM manual transition progress is invalid"));
}
Ok(Self {
scope_sha256: checkpoint_hash(&(report.bucket.as_str(), report.prefix.as_str(), &report.tier, report.dry_run))?,
scanned: report.scanned,
eligible: report.eligible,
enqueued: report.enqueued,
dry_run_eligible: report.dry_run_eligible,
skipped_not_transition: report.skipped_not_transition,
skipped_tier: report.skipped_tier,
skipped_delete_marker: report.skipped_delete_marker,
skipped_directory: report.skipped_directory,
skipped_replication: report.skipped_replication,
skipped_already_transitioned: report.skipped_already_transitioned,
skipped_already_in_flight: report.skipped_already_in_flight,
skipped_queue_full: report.skipped_queue_full,
skipped_queue_closed: report.skipped_queue_closed,
skipped_queue_timeout: report.skipped_queue_timeout,
transition_completed: report.transition_completed,
transition_failed: report.transition_failed,
tier_failure: report.tier_failure,
tier_failure_by_reason: report.tier_failure_by_reason.clone(),
lifecycle_config_found: report.lifecycle_config_found,
truncated_by_limit: report.truncated_by_limit,
truncated_by_duration: report.truncated_by_duration,
cancelled: report.cancelled,
continuation_token_sha256: report
.continuation_token
.as_deref()
.map(|token| hex_sha256(token.as_bytes(), ToOwned::to_owned)),
cursor_marker,
cursor_revision,
queue_snapshot: *queue_snapshot,
})
}
fn is_valid(&self) -> bool {
let reason_total = self
.tier_failure_by_reason
.values()
.try_fold(0u64, |total, count| total.checked_add(*count));
is_sha256_checksum(&self.scope_sha256)
&& self.continuation_token_sha256.as_deref().is_none_or(is_sha256_checksum)
&& match (&self.continuation_token_sha256, &self.cursor_marker) {
(None, None) | (Some(_), None) => true,
(Some(_), Some(marker)) => !marker.is_empty() && marker.len() <= MANUAL_TRANSITION_CURSOR_MARKER_PROOF_MAX_SIZE,
(None, Some(_)) => false,
}
&& !(self.continuation_token_sha256.is_some() && self.cursor_revision == Some(0))
&& self
.transition_completed
.checked_add(self.transition_failed)
.is_some_and(|total| total <= self.enqueued)
&& self.transition_failed <= self.tier_failure
&& reason_total.is_some_and(|total| total <= self.tier_failure)
&& manual_job_queue_snapshot_is_valid(&self.queue_snapshot)
}
}
fn manual_job_progress_checkpoint_is_valid(
progress: Option<&ManualTransitionJobProgressCheckpoint>,
proof: Option<&ManualTransitionJobProgressProof>,
) -> bool {
match (progress, proof) {
(Some(progress), None) => manual_job_progress_is_valid(progress),
(None, Some(proof)) => proof.is_valid(),
(None, None) => true,
(Some(_), Some(_)) => false,
}
}
fn manual_job_progress_proof(
progress: Option<&ManualTransitionJobProgressCheckpoint>,
proof: Option<&ManualTransitionJobProgressProof>,
) -> Option<ManualTransitionJobProgressProof> {
match (progress, proof) {
(Some(progress), None) => ManualTransitionJobProgressProof::new(&progress.report, &progress.queue_snapshot, None).ok(),
(None, Some(proof)) if proof.is_valid() => Some(proof.clone()),
_ => None,
}
}
fn manual_job_progress_equivalent(
previous: Option<&ManualTransitionJobProgressCheckpoint>,
previous_proof: Option<&ManualTransitionJobProgressProof>,
next: Option<&ManualTransitionJobProgressCheckpoint>,
next_proof: Option<&ManualTransitionJobProgressProof>,
) -> bool {
if !manual_job_progress_checkpoint_is_valid(previous, previous_proof)
|| !manual_job_progress_checkpoint_is_valid(next, next_proof)
{
return false;
}
match (
manual_job_progress_proof(previous, previous_proof),
manual_job_progress_proof(next, next_proof),
) {
(Some(previous), Some(next)) => previous == next,
(None, None) => true,
_ => false,
}
}
fn manual_job_progress_reaches(
previous: Option<&ManualTransitionJobProgressCheckpoint>,
previous_proof: Option<&ManualTransitionJobProgressProof>,
next: Option<&ManualTransitionJobProgressCheckpoint>,
next_proof: Option<&ManualTransitionJobProgressProof>,
next_scan_completed: bool,
) -> bool {
if previous.is_none() && previous_proof.is_none() {
return (next.is_some() || next_proof.is_some()) && manual_job_progress_checkpoint_is_valid(next, next_proof);
}
let (Some(previous_compact), Some(next_compact)) = (
manual_job_progress_proof(previous, previous_proof),
manual_job_progress_proof(next, next_proof),
) else {
return false;
};
macro_rules! counters_do_not_regress {
($($field:ident),+ $(,)?) => {
$(previous_compact.$field <= next_compact.$field)&&+
};
}
let counters_monotonic = counters_do_not_regress!(
scanned,
eligible,
enqueued,
dry_run_eligible,
skipped_not_transition,
skipped_tier,
skipped_delete_marker,
skipped_directory,
skipped_replication,
skipped_already_transitioned,
skipped_already_in_flight,
skipped_queue_full,
skipped_queue_closed,
skipped_queue_timeout,
transition_completed,
transition_failed,
tier_failure,
);
let failure_reasons_monotonic = previous_compact
.tier_failure_by_reason
.iter()
.all(|(reason, previous_count)| {
next_compact
.tier_failure_by_reason
.get(reason)
.is_some_and(|next_count| next_count >= previous_count)
});
let flags_monotonic = (!previous_compact.lifecycle_config_found || next_compact.lifecycle_config_found)
&& (!previous_compact.truncated_by_limit || next_compact.truncated_by_limit)
&& (!previous_compact.truncated_by_duration || next_compact.truncated_by_duration)
&& (!previous_compact.cancelled || next_compact.cancelled);
let cursor_monotonic = manual_job_cursor_reaches(
&previous_compact,
&next_compact,
previous.map(|progress| &progress.report),
next.map(|progress| &progress.report),
next_scan_completed,
);
previous_compact.scope_sha256 == next_compact.scope_sha256
&& counters_monotonic
&& failure_reasons_monotonic
&& flags_monotonic
&& cursor_monotonic
&& previous_compact.is_valid()
&& next_compact.is_valid()
}
fn manual_job_progress_is_valid(progress: &ManualTransitionJobProgressCheckpoint) -> bool {
manual_job_worker_results_are_valid(&progress.report)
&& manual_job_queue_snapshot_is_valid(&progress.queue_snapshot)
&& manual_job_cursor_is_valid(progress.report.continuation_token.as_deref())
}
fn manual_job_worker_results_are_valid(report: &ManualTransitionRunReport) -> bool {
let reason_total = report
.tier_failure_by_reason
.values()
.try_fold(0u64, |total, count| total.checked_add(*count));
report
.transition_completed
.checked_add(report.transition_failed)
.is_some_and(|total| total <= report.enqueued)
&& report.transition_failed <= report.tier_failure
&& reason_total.is_some_and(|total| total <= report.tier_failure)
}
fn manual_job_cursor_reaches(
previous: &ManualTransitionJobProgressProof,
next: &ManualTransitionJobProgressProof,
previous_legacy: Option<&ManualTransitionRunReport>,
next_legacy: Option<&ManualTransitionRunReport>,
next_scan_completed: bool,
) -> bool {
if previous.continuation_token_sha256 == next.continuation_token_sha256 {
return previous.cursor_marker == next.cursor_marker && previous.cursor_revision == next.cursor_revision;
}
if let (Some(previous_marker), Some(next_marker)) = (&previous.cursor_marker, &next.cursor_marker)
&& previous_marker != next_marker
{
return next.scanned > previous.scanned && next_marker > previous_marker;
}
match (&previous.continuation_token_sha256, &next.continuation_token_sha256) {
(None, Some(_)) => {
next.scanned > previous.scanned
&& (manual_job_cursor_revision_advances(previous.cursor_revision, next.cursor_revision)
|| (previous.cursor_revision.is_none() && next.cursor_revision.is_none()))
}
(Some(_), None) => next_scan_completed,
(Some(_), Some(_)) if next.scanned > previous.scanned => {
manual_job_cursor_revision_advances(previous.cursor_revision, next.cursor_revision)
|| manual_job_legacy_cursor_reaches(previous, next, previous_legacy, next_legacy)
}
_ => false,
}
}
fn manual_job_cursor_revision_advances(previous: Option<u64>, next: Option<u64>) -> bool {
match (previous, next) {
(Some(previous), Some(next)) => next > previous,
(None, Some(next)) => next > 0,
_ => false,
}
}
fn manual_job_legacy_cursor_reaches(
previous_proof: &ManualTransitionJobProgressProof,
next_proof: &ManualTransitionJobProgressProof,
previous_legacy: Option<&ManualTransitionRunReport>,
next_legacy: Option<&ManualTransitionRunReport>,
) -> bool {
if let (Some(previous_marker), Some(next_marker)) = (&previous_proof.cursor_marker, &next_proof.cursor_marker) {
return next_marker > previous_marker;
}
let (Some(previous_token), Some(next_token)) = (
previous_legacy.and_then(|report| report.continuation_token.as_deref()),
next_legacy.and_then(|report| report.continuation_token.as_deref()),
) else {
return false;
};
let (Ok((Some(previous_marker), _)), Ok((Some(next_marker), _))) = (
decode_manual_transition_continuation_token(previous_token),
decode_manual_transition_continuation_token(next_token),
) else {
return false;
};
next_marker > previous_marker
}
fn manual_job_cursor_is_valid(token: Option<&str>) -> bool {
let Some(token) = token else {
return true;
};
matches!(decode_manual_transition_continuation_token(token), Ok((Some(_), _)))
}
fn manual_job_queue_snapshot_is_valid(snapshot: &ManualTransitionQueueSnapshot) -> bool {
(snapshot.queue_capacity > 0 || snapshot.queued == 0)
&& (snapshot.queue_capacity == 0 || snapshot.queued <= snapshot.queue_capacity)
&& (snapshot.workers > 0 || snapshot.active == 0)
&& (snapshot.workers == 0 || snapshot.active <= snapshot.workers)
}
fn checkpoint_hash<T: Serialize>(value: &T) -> Result<String> {
let encoded = serde_json::to_vec(value).map_err(Error::other)?;
Ok(hex_sha256(&encoded, ToOwned::to_owned))
}
fn path_is_in_namespace(path: &str, namespace: &DurableIlmNamespace) -> bool {
let Some(suffix) = path.strip_prefix(namespace.prefix) else {
return false;
};
if namespace.prefix.ends_with('/') {
!suffix.is_empty()
} else {
suffix.starts_with('/') && suffix.len() > 1
}
}
pub(crate) fn classify_durable_ilm_record(path: &str) -> Result<Option<&'static DurableIlmNamespace>> {
if path != ILM_META_PREFIX && !path.starts_with(ILM_META_OBJECT_PREFIX) {
return Ok(None);
}
DURABLE_ILM_NAMESPACES
.iter()
.find(|namespace| path_is_in_namespace(path, namespace))
.map(Some)
.ok_or_else(|| Error::other(format!("unregistered durable ILM namespace for path `{path}`")))
}
fn parse_manual_sharded_record(path: &str, prefix: &str) -> Result<(Uuid, String)> {
let suffix = path
.strip_prefix(prefix)
.and_then(|suffix| suffix.strip_prefix('/'))
.ok_or_else(|| Error::other("manual transition record path has wrong prefix"))?;
let mut parts = suffix.split('/');
let first = parts
.next()
.ok_or_else(|| Error::other("manual transition record first shard is missing"))?;
let second = parts
.next()
.ok_or_else(|| Error::other("manual transition record second shard is missing"))?;
let job_key = parts
.next()
.ok_or_else(|| Error::other("manual transition record job id is missing"))?;
let task_key = parts
.next()
.and_then(|file| file.strip_suffix(".json"))
.ok_or_else(|| Error::other("manual transition record task key is missing"))?;
if parts.next().is_some()
|| job_key.len() != 32
|| first != &job_key[..2]
|| second != &job_key[2..4]
|| !job_key
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
{
return Err(Error::other("manual transition record job id or shards are invalid"));
}
let job_id = Uuid::parse_str(job_key).map_err(|_| Error::other("manual transition record job id is invalid"))?;
Ok((job_id, task_key.to_string()))
}
pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<ValidatedDurableIlmRecord> {
let namespace =
classify_durable_ilm_record(path)?.ok_or_else(|| Error::other(format!("path `{path}` is not a durable ILM record")))?;
if data.len() > namespace.max_record_size {
return Err(Error::other(format!(
"durable ILM record exceeds {} byte limit",
namespace.max_record_size
)));
}
let content_sha256 = hex_sha256(data, ToOwned::to_owned);
let (id_kind, id, checkpoint) = match namespace.kind {
DurableIlmRecordKind::TierDeleteJournal => {
let entry = tier_delete_journal::decode_tier_delete_journal_entry(data)?;
if tier_delete_journal::tier_delete_journal_object_name(&entry) != path {
return Err(Error::other("tier delete journal content does not match its path"));
}
let legacy_operation_id = path
.strip_prefix(namespace.prefix)
.and_then(|suffix| suffix.strip_suffix(".json"))
.ok_or_else(|| Error::other("tier delete journal path is invalid"))?;
// Legacy v1-v5 paths already expose a 64-hex operation id and
// must remain receipt-compatible. V6 uses an operation-scoped
// nested path, so derive a fixed, path-unique receipt id instead
// of embedding slashes in the receipt locator.
let operation_id = if entry.persisted_version == 6 {
hex_sha256(path.as_bytes(), ToOwned::to_owned)
} else {
legacy_operation_id.to_string()
};
let identity_sha256 = checkpoint_hash(&(
&entry.obj_name,
&entry.version_id,
&entry.tier_name,
entry.backend_identity,
entry.version_id_exact,
entry.version_state,
&entry.source,
))?;
let dispatch_identity_sha256 = entry.dispatch.as_ref().map(checkpoint_hash).transpose()?;
(
"operation_id",
operation_id,
DurableIlmRecordCheckpoint::TierDeleteJournal {
content_sha256,
identity_sha256,
committed: entry.state == super::tier_sweeper::TierDeleteJournalState::Committed,
dispatch_identity_sha256,
state: (entry.persisted_version == 6).then_some(entry.state),
},
)
}
DurableIlmRecordKind::TierDeleteDispatchManifest => {
match tier_delete_journal::validate_tier_delete_dispatch_manifest_record(path, data)? {
tier_delete_journal::TierDeleteDispatchDurableRecord::Manifest {
operation_id,
identity_sha256,
state,
} => (
"operation_id",
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
DurableIlmRecordCheckpoint::TierDeleteDispatchManifest {
content_sha256,
identity_sha256,
state,
},
),
tier_delete_journal::TierDeleteDispatchDurableRecord::Parent {
operation_id,
identity_sha256,
revision,
next_chunk_sequence,
completed_journal_count,
active_chunk_identity_sha256,
completed,
} => (
"operation_id",
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
content_sha256,
identity_sha256,
revision,
next_chunk_sequence,
completed_journal_count,
active_chunk_identity_sha256,
completed,
},
),
}
}
DurableIlmRecordKind::TransitionTransaction => {
let transaction = transition_transaction::decode_transition_transaction_record(path, data)
.map_err(|err| Error::other(err.to_string()))?;
let identity_sha256 = checkpoint_hash(&(
transaction.deployment_id,
transaction.transaction_id,
transaction.owner_epoch,
transaction.write_id,
&transaction.source,
&transaction.tier_name,
transaction.backend_fingerprint,
&transaction.remote_object,
transaction.not_after_unix_nanos,
))?;
let remote_version_sha256 = checkpoint_hash(&transaction.remote_version)?;
(
"transaction_id",
transaction.transaction_id.to_string(),
DurableIlmRecordCheckpoint::TransitionTransaction {
content_sha256,
identity_sha256,
remote_version_sha256,
remote_version_known: !transaction.remote_version.is_unknown(),
revision: transaction.revision,
state: transaction.state,
},
)
}
DurableIlmRecordKind::ManualTransitionJob => {
let job_id = manual_transition_job::manual_transition_job_id_from_record_object_name(path)
.map_err(|err| Error::other(err.to_string()))?;
let canonical = manual_transition_job::manual_transition_job_record_object_name(job_id)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path {
return Err(Error::other("manual transition job path is not canonical"));
}
let job = manual_transition_job::ManualTransitionJobRecord::decode(job_id, data)
.map_err(|err| Error::other(err.to_string()))?;
let identity_sha256 = checkpoint_hash(&(
job.job_id,
&job.scope_key,
&job.bucket,
&job.prefix,
&job.tier,
job.dry_run,
job.max_objects,
job.max_duration,
job.created_at_unix_nanos,
))?;
let progress_proof = ManualTransitionJobProgressProof::new(&job.report, &job.queue_snapshot, job.cursor_revision)?;
let updated_at_unix_nanos = i64::try_from(job.updated_at_unix_nanos)
.map_err(|_| Error::other("manual transition job updated_at exceeds durable ILM checkpoint range"))?;
(
"job_id",
job_id.to_string(),
DurableIlmRecordCheckpoint::ManualTransitionJob {
content_sha256,
identity_sha256,
updated_at_unix_nanos,
state: job.state,
scan_completed: job.scan_completed,
cancel_requested: job.cancel_requested,
progress: None,
progress_proof: Some(Box::new(progress_proof)),
},
)
}
DurableIlmRecordKind::ManualTransitionScope => {
let admission: manual_transition_job::ManualTransitionScopeAdmission =
serde_json::from_slice(data).map_err(Error::other)?;
admission.validate().map_err(|err| Error::other(err.to_string()))?;
let canonical = manual_transition_job::manual_transition_scope_record_object_name(&admission.scope_key)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path {
return Err(Error::other("manual transition scope content does not match its path"));
}
let identity_sha256 = checkpoint_hash(&(
&admission.schema,
&admission.scope_key,
admission.job_id,
&admission.bucket,
&admission.prefix,
&admission.tier,
admission.dry_run,
))?;
let updated_at_unix_nanos = i64::try_from(admission.updated_at_unix_nanos)
.map_err(|_| Error::other("manual transition scope updated_at exceeds durable ILM checkpoint range"))?;
(
"job_id",
admission.job_id.to_string(),
DurableIlmRecordCheckpoint::ManualTransitionScope {
content_sha256,
identity_sha256,
updated_at_unix_nanos,
},
)
}
DurableIlmRecordKind::ManualTransitionTask => {
let (job_id, task_key) = parse_manual_sharded_record(path, namespace.prefix)?;
let canonical = manual_transition_job::manual_transition_task_object_name(job_id, &task_key)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path {
return Err(Error::other("manual transition task path is not canonical"));
}
manual_transition_job::ManualTransitionTaskRecord::decode(job_id, &task_key, data)
.map_err(|err| Error::other(err.to_string()))?;
(
"job_id",
job_id.to_string(),
DurableIlmRecordCheckpoint::ManualTransitionTask { content_sha256 },
)
}
DurableIlmRecordKind::ManualTransitionWorkerResult => {
let (job_id, task_key) = parse_manual_sharded_record(path, namespace.prefix)?;
let canonical = manual_transition_job::manual_transition_worker_result_object_name(job_id, &task_key)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path {
return Err(Error::other("manual transition worker result path is not canonical"));
}
manual_transition_job::ManualTransitionWorkerResultRecord::decode(job_id, &task_key, data)
.map_err(|err| Error::other(err.to_string()))?;
(
"job_id",
job_id.to_string(),
DurableIlmRecordCheckpoint::ManualTransitionWorkerResult { content_sha256 },
)
}
};
Ok(ValidatedDurableIlmRecord {
namespace: namespace.name,
id_kind,
id,
checkpoint,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn try_manual_job_checkpoint(job: &manual_transition_job::ManualTransitionJobRecord) -> Result<DurableIlmRecordCheckpoint> {
let path =
manual_transition_job::manual_transition_job_record_object_name(job.job_id).expect("manual job path should build");
let encoded = job.encode().map_err(|err| Error::other(err.to_string()))?;
Ok(validate_durable_ilm_record(&path, &encoded)?.checkpoint)
}
fn manual_job_checkpoint(job: &manual_transition_job::ManualTransitionJobRecord) -> DurableIlmRecordCheckpoint {
try_manual_job_checkpoint(job).expect("manual job checkpoint should validate")
}
fn continuation_token_with_version(marker: &str, version_marker: Option<&str>) -> String {
let encoded = serde_json::to_vec(&serde_json::json!({ "marker": marker, "version_marker": version_marker }))
.expect("continuation token should encode");
base64_simd::URL_SAFE_NO_PAD.encode_to_string(&encoded)
}
fn continuation_token(marker: &str) -> String {
continuation_token_with_version(marker, None)
}
#[test]
fn unknown_ilm_record_requires_namespace_registration() {
let err = classify_durable_ilm_record("ilm/future-durable/jobs/one.json")
.expect_err("unknown durable ILM path must fail closed");
assert!(err.to_string().contains("ilm/future-durable/jobs/one.json"));
}
#[test]
fn durable_ilm_registry_has_unique_non_overlapping_prefixes() {
for (index, namespace) in DURABLE_ILM_NAMESPACES.iter().enumerate() {
assert!(namespace.prefix.starts_with(ILM_META_OBJECT_PREFIX));
assert!(namespace.max_record_size > 0);
for other in DURABLE_ILM_NAMESPACES.iter().skip(index + 1) {
assert_ne!(namespace.prefix, other.prefix);
assert!(!path_is_in_namespace(namespace.prefix, other));
assert!(!path_is_in_namespace(other.prefix, namespace));
}
}
}
#[test]
fn tier_delete_dispatch_manifest_namespace_validates_monotonic_branches() {
use tier_delete_journal::TierDeleteDispatchManifestState::{Aborted, Aborting, Completed, DispatchAuthorized, Preparing};
let operation_id = Uuid::new_v4();
let checkpoint = |state| {
let (path, data) = tier_delete_journal::test_tier_delete_dispatch_manifest_record(operation_id, state);
let namespace = classify_durable_ilm_record(&path)
.expect("dispatch manifest namespace should classify")
.expect("dispatch manifest should be durable");
assert_eq!(namespace, &TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE);
validate_durable_ilm_record(&path, &data)
.expect("dispatch manifest should validate")
.checkpoint
};
let preparing = checkpoint(Preparing);
let authorized = checkpoint(DispatchAuthorized);
let completed = checkpoint(Completed);
let aborting = checkpoint(Aborting);
let aborted = checkpoint(Aborted);
preparing
.validate_successor(&authorized)
.expect("Preparing may become DispatchAuthorized");
authorized
.validate_successor(&completed)
.expect("DispatchAuthorized may become Completed");
preparing.validate_successor(&aborting).expect("Preparing may enter rollback");
aborting.validate_successor(&aborted).expect("Aborting may become Aborted");
assert!(authorized.validate_successor(&aborting).is_err());
assert!(completed.validate_successor(&authorized).is_err());
assert!(aborted.validate_successor(&preparing).is_err());
}
#[test]
fn tier_delete_dispatch_parent_checkpoint_is_monotonic_across_chunks() {
let identity = "a".repeat(64);
let checkpoint = |revision, sequence, completed_journals, active: Option<&str>, completed| {
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
content_sha256: format!("{revision:064x}"),
identity_sha256: identity.clone(),
revision,
next_chunk_sequence: sequence,
completed_journal_count: completed_journals,
active_chunk_identity_sha256: active.map(ToOwned::to_owned),
completed,
}
};
let idle = checkpoint(0, 0, 0, None, false);
let first_child = "b".repeat(64);
let second_child = "c".repeat(64);
let bound = checkpoint(1, 0, 0, Some(&first_child), false);
let advanced = checkpoint(2, 1, 2, None, false);
let next_bound = checkpoint(3, 1, 2, Some(&second_child), false);
let completed = checkpoint(4, 2, 3, None, true);
let terminal_after_more_chunks = checkpoint(6, 4, 7, None, true);
idle.validate_successor(&bound).expect("an idle parent may bind one child");
bound
.validate_successor(&advanced)
.expect("a completed child may advance the parent sequence");
advanced
.validate_successor(&next_bound)
.expect("the next sequence may bind a new immutable child");
next_bound
.validate_successor(&completed)
.expect("receipt progress may skip directly to a later terminal checkpoint");
assert!(
bound.is_predecessor_of_terminal(&terminal_after_more_chunks),
"terminal cleanup may still recognize a valid multi-chunk predecessor"
);
assert!(
advanced.is_predecessor_of_terminal(&terminal_after_more_chunks),
"terminal cleanup may still skip over later valid parent generations"
);
assert!(
idle.validate_successor(&checkpoint(1, 0, 1, Some(&first_child), false))
.is_err()
);
assert!(bound.validate_successor(&checkpoint(2, 1, 0, None, false)).is_err());
assert!(
bound.validate_successor(&checkpoint(2, 2, 1, None, false)).is_err(),
"sequence cannot advance beyond completed journal evidence"
);
assert!(
advanced.validate_successor(&checkpoint(3, 1, 3, None, false)).is_err(),
"completed journal count cannot grow without a completed child sequence"
);
assert!(
bound.validate_successor(&checkpoint(2, 0, 0, None, true)).is_err(),
"an active child cannot be marked completed without completion evidence"
);
assert!(
bound
.validate_successor(&checkpoint(2, 0, 0, Some(&second_child), false))
.is_err(),
"an active child cannot be replaced at the same parent sequence"
);
assert!(
bound
.validate_successor(&checkpoint(2, 1, 1, Some(&first_child), false))
.is_err(),
"sequence growth cannot retain the same active child identity"
);
assert!(
!bound.is_predecessor_of_terminal(&checkpoint(2, 0, 0, None, true)),
"terminal cleanup must not treat an active child as completed without count evidence"
);
assert!(completed.validate_successor(&checkpoint(5, 3, 4, None, true)).is_err());
assert!(completed.validate_successor(&advanced).is_err());
assert!(advanced.validate_successor(&idle).is_err());
}
#[test]
fn tier_delete_journal_checkpoint_binds_dispatch_and_full_state_monotonically() {
use crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
let checkpoint = |content: &str, dispatch: Option<&str>, state| DurableIlmRecordCheckpoint::TierDeleteJournal {
content_sha256: content.repeat(64),
identity_sha256: "i".repeat(64),
committed: state == Some(Committed),
dispatch_identity_sha256: dispatch.map(|value| value.repeat(64)),
state,
};
let prepared = checkpoint("a", Some("d"), Some(Prepared));
let dispatched = checkpoint("b", Some("d"), Some(Dispatched));
let committed = checkpoint("c", Some("d"), Some(Committed));
prepared
.validate_successor(&dispatched)
.expect("Prepared may advance to Dispatched");
dispatched
.validate_successor(&committed)
.expect("Dispatched may advance to Committed");
assert!(prepared.validate_successor(&committed).is_err());
assert!(dispatched.validate_successor(&prepared).is_err());
let rebound = checkpoint("b", Some("e"), Some(Dispatched));
assert!(dispatched.validate_successor(&rebound).is_err());
let legacy: DurableIlmRecordCheckpoint = serde_json::from_value(serde_json::json!({
"kind": "tier_delete_journal",
"content_sha256": "a".repeat(64),
"identity_sha256": "i".repeat(64),
"committed": false
}))
.expect("legacy tier-delete checkpoint should remain decodable");
legacy
.validate_successor(&prepared)
.expect("byte-identical legacy receipt may adopt the stronger v6 proof");
let changed_legacy = DurableIlmRecordCheckpoint::TierDeleteJournal {
content_sha256: "z".repeat(64),
identity_sha256: "i".repeat(64),
committed: false,
dispatch_identity_sha256: None,
state: None,
};
assert!(changed_legacy.validate_successor(&prepared).is_err());
}
#[test]
fn manual_transition_job_checkpoint_compacts_legacy_progress_compatibly() {
let options = super::super::bucket_lifecycle_ops::ManualTransitionRunOptions::default();
let mut job =
manual_transition_job::ManualTransitionJobRecord::new(Uuid::new_v4(), "legacy-checkpoint-bucket", &options, "owner");
let mut report = job.report.clone();
report.scanned = 1;
report.continuation_token = Some(continuation_token("logs/a"));
job.update_running_progress(report, ManualTransitionQueueSnapshot::default());
let compact = manual_job_checkpoint(&job);
let mut legacy = compact.clone();
let DurableIlmRecordCheckpoint::ManualTransitionJob {
progress,
progress_proof,
..
} = &mut legacy
else {
panic!("manual job should produce a manual checkpoint");
};
*progress = Some(Box::new(ManualTransitionJobProgressCheckpoint {
report: job.report.clone(),
queue_snapshot: job.queue_snapshot,
}));
*progress_proof = None;
compact
.validate_successor(&legacy)
.expect("bounded checkpoints should accept the same legacy generation");
legacy
.validate_successor(&compact)
.expect("legacy checkpoints should accept the same bounded generation");
assert_eq!(legacy.compacted().expect("legacy checkpoint should compact"), compact);
}
#[test]
fn manual_transition_job_checkpoint_rejects_timestamp_outside_wire_range() {
let options = super::super::bucket_lifecycle_ops::ManualTransitionRunOptions::default();
let mut job = manual_transition_job::ManualTransitionJobRecord::new(
Uuid::new_v4(),
"checkpoint-timestamp-bucket",
&options,
"owner",
);
job.updated_at_unix_nanos = i128::from(i64::MAX) + 1;
let err = try_manual_job_checkpoint(&job).expect_err("out-of-range checkpoint timestamp must fail closed");
assert!(err.to_string().contains("updated_at exceeds durable ILM checkpoint range"));
}
#[test]
fn manual_transition_scope_checkpoint_rejects_timestamp_outside_wire_range() {
let options = super::super::bucket_lifecycle_ops::ManualTransitionRunOptions::default();
let job = manual_transition_job::ManualTransitionJobRecord::new(
Uuid::new_v4(),
"scope-checkpoint-timestamp-bucket",
&options,
"owner",
);
let mut admission = manual_transition_job::ManualTransitionScopeAdmission::from_job(&job);
admission.updated_at_unix_nanos = i128::from(i64::MAX) + 1;
let path = manual_transition_job::manual_transition_scope_record_object_name(&admission.scope_key)
.expect("manual transition scope path should build");
let encoded = serde_json::to_vec(&admission).expect("manual transition scope should encode");
let err =
validate_durable_ilm_record(&path, &encoded).expect_err("out-of-range scope checkpoint timestamp must fail closed");
assert!(err.to_string().contains("updated_at exceeds durable ILM checkpoint range"));
}
#[test]
fn manual_transition_job_checkpoint_rejects_progress_poison() {
let options = super::super::bucket_lifecycle_ops::ManualTransitionRunOptions::default();
let mut initial =
manual_transition_job::ManualTransitionJobRecord::new(Uuid::new_v4(), "manual-checkpoint-bucket", &options, "owner");
let initial_checkpoint = manual_job_checkpoint(&initial);
let mut first_page = initial.report.clone();
first_page.scanned = 1;
first_page.continuation_token = Some(continuation_token("logs/a"));
initial.update_running_progress(first_page, ManualTransitionQueueSnapshot::default());
let first_page_checkpoint = manual_job_checkpoint(&initial);
initial_checkpoint
.validate_successor(&first_page_checkpoint)
.expect("the first durable cursor should advance from no cursor");
let mut legacy_checkpoint = initial_checkpoint;
let DurableIlmRecordCheckpoint::ManualTransitionJob {
progress,
progress_proof,
..
} = &mut legacy_checkpoint
else {
panic!("manual job should produce a manual checkpoint");
};
*progress = None;
*progress_proof = None;
legacy_checkpoint
.validate_successor(&first_page_checkpoint)
.expect("legacy checkpoints should upgrade to validated progress");
let mut previous = initial;
let mut previous_report = previous.report.clone();
previous_report.scanned = 10;
previous_report.eligible = 8;
previous_report.enqueued = 2;
previous_report.continuation_token = Some(continuation_token("logs/b"));
let previous_queue = ManualTransitionQueueSnapshot {
queue_capacity: 10,
queued: 1,
active: 1,
workers: 2,
queue_full: 2,
queue_send_timeout: 1,
..Default::default()
};
previous.update_running_progress(previous_report, previous_queue);
previous.report.transition_completed = 1;
let previous_checkpoint = manual_job_checkpoint(&previous);
let mut next = previous.clone();
let mut next_report = next.report.clone();
next_report.scanned = 11;
next_report.eligible = 9;
next_report.continuation_token = Some(continuation_token("logs/c"));
let mut next_queue = next.queue_snapshot;
next_queue.queued = 0;
next_queue.active = 0;
next_queue.queue_full = 3;
next.update_running_progress(next_report, next_queue);
next.report.transition_completed = 2;
let next_checkpoint = manual_job_checkpoint(&next);
previous_checkpoint
.validate_successor(&next_checkpoint)
.expect("forward job progress should validate");
let mut counter_rollback = next.clone();
counter_rollback.updated_at_unix_nanos += 1;
counter_rollback.report.scanned = 9;
assert!(
try_manual_job_checkpoint(&counter_rollback)
.and_then(|checkpoint| previous_checkpoint.validate_successor(&checkpoint))
.is_err()
);
let mut cursor_rollback = previous.clone();
cursor_rollback.updated_at_unix_nanos += 1;
cursor_rollback.report.scanned += 1;
cursor_rollback.report.continuation_token = Some(continuation_token("logs/a"));
assert!(
try_manual_job_checkpoint(&cursor_rollback)
.and_then(|checkpoint| previous_checkpoint.validate_successor(&checkpoint))
.is_err()
);
let mut same_marker_version_previous = previous.clone();
let mut same_marker_report = same_marker_version_previous.report.clone();
same_marker_report.continuation_token = Some(continuation_token_with_version("logs/b", Some("opaque-z-version")));
same_marker_version_previous.update_running_progress(same_marker_report, same_marker_version_previous.queue_snapshot);
let same_marker_version_previous_checkpoint = manual_job_checkpoint(&same_marker_version_previous);
let mut same_marker_version_next = same_marker_version_previous.clone();
let mut same_marker_next_report = same_marker_version_next.report.clone();
same_marker_next_report.scanned += 1;
same_marker_next_report.continuation_token = Some(continuation_token_with_version("logs/b", Some("opaque-a-version")));
same_marker_version_next.update_running_progress(same_marker_next_report, same_marker_version_next.queue_snapshot);
same_marker_version_previous_checkpoint
.validate_successor(&manual_job_checkpoint(&same_marker_version_next))
.expect("producer cursor revision should prove same-marker version progress");
let mut same_marker_version_rollback = same_marker_version_previous.clone();
same_marker_version_rollback.updated_at_unix_nanos += 1;
same_marker_version_rollback.report.scanned += 1;
same_marker_version_rollback.report.continuation_token =
Some(continuation_token_with_version("logs/b", Some("opaque-arbitrary-version")));
assert!(
try_manual_job_checkpoint(&same_marker_version_rollback)
.and_then(|checkpoint| same_marker_version_previous_checkpoint.validate_successor(&checkpoint))
.is_err(),
"a different opaque version marker without producer evidence must fail closed"
);
let mut worker_result_rollback = next.clone();
worker_result_rollback.updated_at_unix_nanos += 1;
worker_result_rollback.report.transition_completed = 0;
assert!(
previous_checkpoint
.validate_successor(&manual_job_checkpoint(&worker_result_rollback))
.is_err()
);
let mut worker_result_overflow = next.clone();
worker_result_overflow.updated_at_unix_nanos += 1;
worker_result_overflow.report.enqueued = u64::MAX;
worker_result_overflow.report.transition_completed = u64::MAX;
worker_result_overflow.report.transition_failed = 1;
worker_result_overflow.report.tier_failure = 1;
assert!(try_manual_job_checkpoint(&worker_result_overflow).is_err());
let mut invalid_cursor = next.clone();
invalid_cursor.updated_at_unix_nanos += 1;
invalid_cursor.report.continuation_token = Some("not-base64".to_string());
assert!(try_manual_job_checkpoint(&invalid_cursor).is_err());
let mut queue_state_poison = next;
queue_state_poison.updated_at_unix_nanos += 1;
queue_state_poison.queue_snapshot.queued = queue_state_poison.queue_snapshot.queue_capacity + 1;
assert!(try_manual_job_checkpoint(&queue_state_poison).is_err());
}
}