Files
rustfs/crates/ecstore/src/bucket/lifecycle/transition_transaction.rs
T
Zhengchao An 6cf9cf7bb5 chore(ecstore): drop the bucket dead_code blanket (#6147)
* chore(ecstore): drop the bucket dead_code blanket

The last blanket of the backlog#1823 burn-down, and the largest: 71 items across lifecycle, replication, metadata, quota, object lock and bucket utils. Four are deleted.

Deleted, all trivial:

- check_valid_object_name and check_valid_object_name_prefix, a pair that only calls into each other with no external caller. Worth stating plainly so nobody reads this as a validation gap: object names are validated through check_object_name_for_length_and_slash, which is live; this pair is a second, unwired entry point.
- DEFAULT_HEALTH_CHECK_RELOAD_DURATION, a lone unused constant.
- The LifecycleReplicationConfig alias, which orphaned a re-export in replication/mod.rs that goes with it.

Everything else is kept, in four groups, because the blanket here was hiding structure rather than rot:

Windows platform gating. WINDOWS_RESERVED_NAMES, the two reason constants and object_name_has_windows_incompatible_segment are called from inside the #[cfg(target_os = "windows")] block in check_object_name_for_length_and_slash (utils.rs:228-255), so they only read as dead on non-Windows hosts. As with the Linux gating in the disk root, this cannot be adjudicated locally: cargo check for both x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu fails in the aws-lc-sys build script for want of a cross C toolchain. CI covers both.

Declared boundary surface. The *_boundary.rs and *_bridge.rs files carry the replication split plan's contracts, which scripts/check_architecture_migration_rules.sh pins through the EcstoreReplicationBoundaryImports section of the split-plan doc. Their unused items are declarations, not leftovers.

test-util seams. ConfigWriteLockProbe with install/wait_until_attempted follows the same pattern as the barriers in the services and set_disk roots.

MinIO-parity tier/lifecycle entry points that this port never wired: apply_lifecycle_action, get_transitioned_object_reader, recover_tier_free_versions, delete_object_from_remote_tier, abort_tier_delete_journal_entry and the replication pool's worker-management surface. These are complete, substantial machinery with no caller — the same shape as data_usage's local_snapshot feature. Removing them is a product decision, so they are made explicit here rather than deleted.

Verification, four lanes warning-free: default, --tests, --features rio-v2 --tests, --features test-util --tests. cargo nextest run -p rustfs-ecstore 4096 passed; clippy --lib --tests -D warnings clean; make pre-commit exit 0. Note that clippy is what caught the orphaned re-export above: cargo check and pre-commit both treat unused_imports as a warning.

Ref rustfs/backlog#1823 (step 2, final root).

* chore(ecstore): correct inaccurate dead_code reasons in the bucket root

Six items were labelled 'asserted by this file's tests' or as MinIO-parity
entry points while having no caller at all - free get_bucket_acl_config and
created_at only reach their own live methods (production goes through
created_at_in), BucketVersioningSys::get_in, utils::serialize_content and
ServiceType have no reference anywhere, and with_transition_queue_env_async
is an unused test fixture, not a tier entry point. Name what each one is so
the next reader does not assume coverage that is not there.

Ref rustfs/backlog#1823.
2026-08-16 21:39:04 +08:00

1611 lines
66 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::{future::Future, sync::Arc, time::Duration};
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use uuid::Uuid;
use crate::bucket::lifecycle::config_boundary;
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
use crate::bucket::lifecycle::tier_sweeper::{
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
};
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result as EcstoreResult};
use crate::object_api::ObjectOptions;
use crate::services::tier::{tier::TierConfigMgr, warm_backend::TransitionCandidateProbe};
use crate::storage_api_contracts::{list::ListOperations as _, object::ObjectOperations as _};
use crate::store::ECStore;
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
const EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY: &str = "lifecycle_transition_transaction_recovery";
pub const DEFAULT_TRANSITION_TRANSACTION_RECOVERY_LIMIT: usize = 1_000;
const TRANSITION_TRANSACTION_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
const TRANSITION_TRANSACTION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
pub const TRANSITION_TRANSACTION_SCHEMA: &str = "rustfs-transition-transaction-v1";
pub const TRANSITION_TRANSACTION_PREFIX: &str = "ilm/transition-transactions";
pub const TRANSITION_TRANSACTION_RECORD_PREFIX: &str = "ilm/transition-transactions/records";
pub const MAX_TRANSITION_TRANSACTION_SIZE: usize = 64 * 1024;
pub type Result<T> = std::result::Result<T, TransitionTransactionError>;
#[derive(Debug, thiserror::Error)]
pub enum TransitionTransactionError {
#[error("transition transaction already exists")]
#[allow(
dead_code,
reason = "MinIO-parity tier/lifecycle entry point that this port never wired (backlog#1823)"
)]
AlreadyExists,
#[error("transition transaction is not found")]
#[allow(
dead_code,
reason = "MinIO-parity tier/lifecycle entry point that this port never wired (backlog#1823)"
)]
NotFound,
#[error("transition transaction is corrupt: {0}")]
Corrupt(&'static str),
#[error("transition transaction schema is unsupported: {0}")]
UnsupportedSchema(String),
#[error("transition transaction checksum mismatch")]
ChecksumMismatch,
#[error("transition transaction owner or revision fence is stale")]
Fenced,
#[error("invalid transition transaction state change from {from:?} to {to:?}")]
InvalidStateChange {
from: TransitionTransactionState,
to: TransitionTransactionState,
},
#[error("transition transaction json error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransitionTransactionState {
UploadStarted,
UploadOutcomeUnknown,
AbortedNoRemote,
Uploaded,
LocalCommitStarted,
Committed,
CleanupPending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransitionRemoteVersionKind {
Unknown,
Unversioned,
Versioned,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionRemoteVersion {
pub kind: TransitionRemoteVersionKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
}
impl TransitionRemoteVersion {
pub fn known_from_put_response(version_id: impl Into<String>) -> Self {
let version_id = version_id.into();
if version_id.is_empty() || Uuid::parse_str(&version_id).is_ok_and(|parsed| parsed.is_nil()) {
Self::unversioned()
} else {
Self::versioned(version_id)
}
}
pub fn unknown() -> Self {
Self {
kind: TransitionRemoteVersionKind::Unknown,
version_id: None,
}
}
pub fn unversioned() -> Self {
Self {
kind: TransitionRemoteVersionKind::Unversioned,
version_id: None,
}
}
pub fn versioned(version_id: impl Into<String>) -> Self {
Self {
kind: TransitionRemoteVersionKind::Versioned,
version_id: Some(version_id.into()),
}
}
pub fn is_unknown(&self) -> bool {
self.kind == TransitionRemoteVersionKind::Unknown
}
pub fn tier_delete_version_id(&self) -> Option<&str> {
match self.kind {
TransitionRemoteVersionKind::Unknown | TransitionRemoteVersionKind::Unversioned => None,
TransitionRemoteVersionKind::Versioned => self.version_id.as_deref(),
}
}
fn validate(&self) -> Result<()> {
match (&self.kind, self.version_id.as_deref()) {
(TransitionRemoteVersionKind::Unknown | TransitionRemoteVersionKind::Unversioned, None) => Ok(()),
(TransitionRemoteVersionKind::Unknown | TransitionRemoteVersionKind::Unversioned, Some(_)) => Err(
TransitionTransactionError::Corrupt("remote version must be absent for non-versioned state"),
),
(TransitionRemoteVersionKind::Versioned, Some(version_id)) => {
if version_id.is_empty() {
return Err(TransitionTransactionError::Corrupt("versioned remote version is empty"));
}
if Uuid::parse_str(version_id).is_ok_and(|parsed| parsed.is_nil()) {
return Err(TransitionTransactionError::Corrupt("versioned remote version is nil uuid"));
}
Ok(())
}
(TransitionRemoteVersionKind::Versioned, None) => {
Err(TransitionTransactionError::Corrupt("versioned remote version is missing"))
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionSourceIdentity {
pub bucket: String,
pub object: String,
pub version_id: Option<Uuid>,
pub data_dir: Uuid,
pub mod_time_unix_nanos: i64,
pub size: i64,
pub etag: String,
pub version_mode: TransitionSourceVersionMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransitionSourceVersionMode {
Unversioned,
Versioned,
VersionSuspended,
}
impl TransitionSourceIdentity {
pub fn validate(&self) -> Result<()> {
if self.bucket.is_empty() {
return Err(TransitionTransactionError::Corrupt("source bucket is empty"));
}
if self.object.is_empty() {
return Err(TransitionTransactionError::Corrupt("source object is empty"));
}
if self.data_dir.is_nil() {
return Err(TransitionTransactionError::Corrupt("source data_dir is nil"));
}
if self.version_id.is_some_and(|version_id| version_id.is_nil()) {
return Err(TransitionTransactionError::Corrupt("source version_id is nil"));
}
if self.version_mode == TransitionSourceVersionMode::Versioned && self.version_id.is_none() {
return Err(TransitionTransactionError::Corrupt("versioned source is missing version_id"));
}
if matches!(
self.version_mode,
TransitionSourceVersionMode::Unversioned | TransitionSourceVersionMode::VersionSuspended
) && self.version_id.is_some()
{
return Err(TransitionTransactionError::Corrupt("non-versioned source mode must not carry version_id"));
}
if self.size < 0 {
return Err(TransitionTransactionError::Corrupt("source size is negative"));
}
if self.etag.is_empty() {
return Err(TransitionTransactionError::Corrupt("source etag is empty"));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionTransactionFence {
pub revision: u64,
pub owner_epoch: Uuid,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionTransaction {
pub deployment_id: Uuid,
pub transaction_id: Uuid,
pub revision: u64,
pub owner_epoch: Uuid,
pub write_id: Uuid,
pub source: TransitionSourceIdentity,
pub tier_name: String,
pub backend_fingerprint: [u8; 32],
pub remote_object: String,
pub remote_version: TransitionRemoteVersion,
pub state: TransitionTransactionState,
pub not_after_unix_nanos: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransitionTransactionInit {
pub deployment_id: Uuid,
pub transaction_id: Uuid,
pub owner_epoch: Uuid,
pub write_id: Uuid,
pub source: TransitionSourceIdentity,
pub tier_name: String,
pub backend_fingerprint: [u8; 32],
pub not_after_unix_nanos: i64,
}
impl TransitionTransaction {
pub fn new(init: TransitionTransactionInit) -> Result<Self> {
let remote_object =
canonical_transition_remote_object(init.deployment_id, &init.source.bucket, init.transaction_id, init.write_id)?;
let transaction = Self {
deployment_id: init.deployment_id,
transaction_id: init.transaction_id,
revision: 1,
owner_epoch: init.owner_epoch,
write_id: init.write_id,
source: init.source,
tier_name: init.tier_name,
backend_fingerprint: init.backend_fingerprint,
remote_object,
remote_version: TransitionRemoteVersion::unknown(),
state: TransitionTransactionState::UploadStarted,
not_after_unix_nanos: init.not_after_unix_nanos,
};
transaction.validate()?;
Ok(transaction)
}
pub fn fence(&self) -> TransitionTransactionFence {
TransitionTransactionFence {
revision: self.revision,
owner_epoch: self.owner_epoch,
}
}
pub fn validate(&self) -> Result<()> {
if self.deployment_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("deployment_id is nil"));
}
if self.transaction_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("transaction_id is nil"));
}
if self.revision == 0 {
return Err(TransitionTransactionError::Corrupt("revision is zero"));
}
if self.owner_epoch.is_nil() {
return Err(TransitionTransactionError::Corrupt("owner_epoch is nil"));
}
if self.write_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("write_id is nil"));
}
self.source.validate()?;
if self.tier_name.is_empty() {
return Err(TransitionTransactionError::Corrupt("tier name is empty"));
}
if self.remote_object
!= canonical_transition_remote_object(self.deployment_id, &self.source.bucket, self.transaction_id, self.write_id)?
{
return Err(TransitionTransactionError::Corrupt("remote object is not canonical"));
}
self.remote_version.validate()?;
if state_requires_known_remote_version(self.state) && self.remote_version.is_unknown() {
return Err(TransitionTransactionError::Corrupt("state requires known remote version"));
}
if matches!(
self.state,
TransitionTransactionState::UploadStarted
| TransitionTransactionState::UploadOutcomeUnknown
| TransitionTransactionState::AbortedNoRemote
) && !self.remote_version.is_unknown()
{
return Err(TransitionTransactionError::Corrupt(
"pre-upload-completion state must not carry a known remote version",
));
}
Ok(())
}
pub fn advance(
&mut self,
fence: TransitionTransactionFence,
next: TransitionTransactionState,
remote_version: Option<TransitionRemoteVersion>,
) -> Result<TransitionTransactionFence> {
self.check_fence(fence)?;
if !state_change_allowed(self.state, next) {
return Err(TransitionTransactionError::InvalidStateChange {
from: self.state,
to: next,
});
}
match next {
TransitionTransactionState::Uploaded => {
let remote_version =
remote_version.ok_or(TransitionTransactionError::Corrupt("uploaded state requires remote version"))?;
if remote_version.is_unknown() {
return Err(TransitionTransactionError::Corrupt("uploaded state requires known remote version"));
}
self.remote_version = remote_version;
}
TransitionTransactionState::AbortedNoRemote => {
if remote_version.is_some() {
return Err(TransitionTransactionError::Corrupt(
"aborted no remote state must not carry remote version",
));
}
self.remote_version = TransitionRemoteVersion::unknown();
}
TransitionTransactionState::UploadOutcomeUnknown => {
if remote_version.is_some() {
return Err(TransitionTransactionError::Corrupt(
"unknown upload outcome state must not carry remote version",
));
}
self.remote_version = TransitionRemoteVersion::unknown();
}
TransitionTransactionState::LocalCommitStarted | TransitionTransactionState::Committed => {
if let Some(remote_version) = remote_version
&& remote_version != self.remote_version
{
return Err(TransitionTransactionError::Corrupt("remote version changed after upload"));
}
if self.remote_version.is_unknown() {
return Err(TransitionTransactionError::Corrupt("local commit requires known remote version"));
}
}
TransitionTransactionState::CleanupPending | TransitionTransactionState::UploadStarted => {
return Err(TransitionTransactionError::InvalidStateChange {
from: self.state,
to: next,
});
}
}
self.state = next;
self.bump_revision()?;
self.validate()?;
Ok(self.fence())
}
pub fn mark_cleanup_pending(
&mut self,
fence: TransitionTransactionFence,
proof: TransitionCleanupProof,
) -> Result<TransitionTransactionFence> {
self.check_fence(fence)?;
self.validate_cleanup_proof(&proof)?;
match (&self.state, &proof.decision) {
(TransitionTransactionState::Uploaded, TransitionCleanupDecision::UploadAbortedBeforeLocalCommit)
| (
TransitionTransactionState::UploadOutcomeUnknown,
TransitionCleanupDecision::RemoteVersionRecoveredAfterCancellation,
) => {}
(
TransitionTransactionState::LocalCommitStarted,
TransitionCleanupDecision::SourceReconciledUnchanged { observed_source },
) if observed_source == &self.source => {}
_ => {
return Err(TransitionTransactionError::InvalidStateChange {
from: self.state,
to: TransitionTransactionState::CleanupPending,
});
}
}
self.state = TransitionTransactionState::CleanupPending;
self.remote_version = proof.remote_version;
self.bump_revision()?;
self.validate()?;
Ok(self.fence())
}
pub fn encode(&self) -> Result<Vec<u8>> {
self.validate()?;
let transaction_bytes = serde_json::to_vec(self)?;
let content_sha256 = hex_sha256(&transaction_bytes, ToOwned::to_owned);
let persisted = PersistedTransitionTransaction {
schema: TRANSITION_TRANSACTION_SCHEMA.to_string(),
content_sha256,
transaction: self.clone(),
};
let encoded = serde_json::to_vec(&persisted)?;
if encoded.len() > MAX_TRANSITION_TRANSACTION_SIZE {
return Err(TransitionTransactionError::Corrupt("encoded transaction exceeds maximum size"));
}
Ok(encoded)
}
pub fn decode(expected_transaction_id: Uuid, data: &[u8]) -> Result<Self> {
if data.len() > MAX_TRANSITION_TRANSACTION_SIZE {
return Err(TransitionTransactionError::Corrupt("encoded transaction exceeds maximum size"));
}
let persisted: PersistedTransitionTransaction = serde_json::from_slice(data)?;
if persisted.schema != TRANSITION_TRANSACTION_SCHEMA {
return Err(TransitionTransactionError::UnsupportedSchema(persisted.schema));
}
if !is_sha256_checksum(&persisted.content_sha256) {
return Err(TransitionTransactionError::Corrupt("content checksum is not a sha256 checksum"));
}
let transaction_bytes = serde_json::to_vec(&persisted.transaction)?;
let actual_checksum = hex_sha256(&transaction_bytes, ToOwned::to_owned);
if persisted.content_sha256 != actual_checksum {
return Err(TransitionTransactionError::ChecksumMismatch);
}
if persisted.transaction.transaction_id != expected_transaction_id {
return Err(TransitionTransactionError::Corrupt("transaction_id does not match record key"));
}
persisted.transaction.validate()?;
Ok(persisted.transaction)
}
fn check_fence(&self, fence: TransitionTransactionFence) -> Result<()> {
if self.revision != fence.revision || self.owner_epoch != fence.owner_epoch {
return Err(TransitionTransactionError::Fenced);
}
Ok(())
}
fn validate_cleanup_proof(&self, proof: &TransitionCleanupProof) -> Result<()> {
if proof.transaction_id != self.transaction_id
|| proof.write_id != self.write_id
|| proof.remote_object != self.remote_object
|| proof.backend_fingerprint != self.backend_fingerprint
{
return Err(TransitionTransactionError::Fenced);
}
proof.remote_version.validate()?;
if proof.remote_version.is_unknown() {
return Err(TransitionTransactionError::Corrupt("cleanup proof requires known remote version"));
}
if state_requires_known_remote_version(self.state) && proof.remote_version != self.remote_version {
return Err(TransitionTransactionError::Fenced);
}
Ok(())
}
fn bump_revision(&mut self) -> Result<()> {
self.revision = self
.revision
.checked_add(1)
.ok_or(TransitionTransactionError::Corrupt("revision overflow"))?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionCleanupProof {
pub transaction_id: Uuid,
pub write_id: Uuid,
pub remote_object: String,
pub remote_version: TransitionRemoteVersion,
pub backend_fingerprint: [u8; 32],
pub decision: TransitionCleanupDecision,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum TransitionCleanupDecision {
UploadAbortedBeforeLocalCommit,
RemoteVersionRecoveredAfterCancellation,
SourceReconciledUnchanged { observed_source: TransitionSourceIdentity },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PersistedTransitionTransaction {
schema: String,
content_sha256: String,
transaction: TransitionTransaction,
}
pub fn canonical_transition_remote_object(
deployment_id: Uuid,
source_bucket: &str,
transaction_id: Uuid,
write_id: Uuid,
) -> Result<String> {
if deployment_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("deployment_id is nil"));
}
if source_bucket.is_empty() {
return Err(TransitionTransactionError::Corrupt("source bucket is empty"));
}
if transaction_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("transaction_id is nil"));
}
if write_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("write_id is nil"));
}
let bucket_scope = format!("{deployment_id}:{source_bucket}");
let bucket_hash = hex_sha256(bucket_scope.as_bytes(), ToOwned::to_owned);
let transaction_key = transaction_id.simple().to_string();
let write_key = write_id.simple().to_string();
Ok(format!(
"{}/{}/{}/{}/{}/{}",
TRANSITION_TRANSACTION_PREFIX,
&bucket_hash[..16],
&transaction_key[..2],
&transaction_key[2..4],
transaction_key,
write_key
))
}
pub fn transition_transaction_record_object_name(transaction_id: Uuid) -> Result<String> {
if transaction_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("transaction_id is nil"));
}
let transaction_key = transaction_id.simple().to_string();
Ok(format!(
"{}/{}/{}/{}.json",
TRANSITION_TRANSACTION_RECORD_PREFIX,
&transaction_key[..2],
&transaction_key[2..4],
transaction_key
))
}
pub(crate) async fn save_transition_transaction_record(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
) -> EcstoreResult<()> {
let object =
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
let data = transaction.encode().map_err(transition_transaction_store_error)?;
config_boundary::save_config(api, &object, data).await
}
pub(crate) async fn load_transition_transaction_record(
api: Arc<ECStore>,
transaction_id: Uuid,
) -> EcstoreResult<TransitionTransaction> {
let object = transition_transaction_record_object_name(transaction_id).map_err(transition_transaction_store_error)?;
let data = config_boundary::read_config(api, &object).await?;
TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)
}
pub(crate) async fn delete_transition_transaction_record(api: Arc<ECStore>, transaction_id: Uuid) -> EcstoreResult<()> {
let object = transition_transaction_record_object_name(transaction_id).map_err(transition_transaction_store_error)?;
match config_boundary::delete_config(api, &object).await {
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
Err(err) => Err(err),
}
}
fn transition_transaction_store_error(err: TransitionTransactionError) -> Error {
Error::other(err)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransitionTransactionRecoveryStats {
pub scanned: usize,
pub recovered: usize,
pub retained: usize,
pub failed: usize,
pub next_marker: Option<String>,
pub truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransitionTransactionRecoveryOutcome {
RemoteCandidateDeleted,
RecordDeleted,
Retained,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TransitionOperatorProbe {
Missing,
UnversionedPresent,
VersionedPresent(String),
Ambiguous,
Unsupported,
}
impl From<TransitionCandidateProbe> for TransitionOperatorProbe {
fn from(value: TransitionCandidateProbe) -> Self {
match value {
TransitionCandidateProbe::Missing => Self::Missing,
TransitionCandidateProbe::UnversionedPresent => Self::UnversionedPresent,
TransitionCandidateProbe::VersionedPresent(version_id) => Self::VersionedPresent(version_id),
TransitionCandidateProbe::Ambiguous => Self::Ambiguous,
TransitionCandidateProbe::Unsupported => Self::Unsupported,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TransitionOperatorStatus {
pub transaction_id: Uuid,
pub state: TransitionTransactionState,
pub tier_name: String,
pub remote_object: String,
pub not_after_unix_nanos: i64,
pub probe: TransitionOperatorProbe,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TransitionOperatorDeleteResult {
pub status: TransitionOperatorStatus,
pub journal_observed_after_delete: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum TransitionOperatorError {
#[error("transition transaction was not found")]
NotFound,
#[error("transition transaction is still inside its active ownership window")]
NotExpired,
#[error("transition transaction state is not eligible for operator reconciliation: {0:?}")]
InvalidState(TransitionTransactionState),
#[error("an exact non-empty remote version is required")]
RemoteVersionRequired,
#[error("remote candidate is not proven missing: {0:?}")]
CandidateNotMissing(TransitionOperatorProbe),
#[error("remote candidate version does not match requested exact version: expected {expected}, observed {actual:?}")]
CandidateVersionMismatch {
expected: String,
actual: TransitionOperatorProbe,
},
#[error("transition transaction store failed: {0}")]
Store(#[source] Error),
#[error("remote tier reconciliation failed: {0}")]
Remote(#[source] std::io::Error),
}
type TransitionOperatorResult<T> = std::result::Result<T, TransitionOperatorError>;
fn validate_operator_reconcile_transaction(
transaction: &TransitionTransaction,
now_unix_nanos: i128,
) -> TransitionOperatorResult<()> {
transaction
.validate()
.map_err(|err| TransitionOperatorError::Store(Error::other(err)))?;
if transaction.state != TransitionTransactionState::UploadOutcomeUnknown {
return Err(TransitionOperatorError::InvalidState(transaction.state));
}
if now_unix_nanos < i128::from(transaction.not_after_unix_nanos) {
return Err(TransitionOperatorError::NotExpired);
}
Ok(())
}
async fn load_operator_reconcile_transaction(
api: Arc<ECStore>,
transaction_id: Uuid,
) -> TransitionOperatorResult<TransitionTransaction> {
match load_transition_transaction_record(api, transaction_id).await {
Ok(transaction) => Ok(transaction),
Err(Error::ConfigNotFound) => Err(TransitionOperatorError::NotFound),
Err(err) => Err(TransitionOperatorError::Store(err)),
}
}
async fn operator_probe_transition_candidate(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
) -> TransitionOperatorResult<TransitionOperatorProbe> {
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(
&api.tier_config_mgr(),
&transaction.tier_name,
transaction.backend_fingerprint,
)
.await
.map_err(|err| TransitionOperatorError::Remote(std::io::Error::other(err)))?;
lease
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
.await
.map(TransitionOperatorProbe::from)
.map_err(TransitionOperatorError::Remote)
}
pub async fn inspect_transition_transaction_for_operator(
api: Arc<ECStore>,
transaction_id: Uuid,
) -> TransitionOperatorResult<TransitionOperatorStatus> {
let transaction = load_operator_reconcile_transaction(api.clone(), transaction_id).await?;
validate_operator_reconcile_transaction(&transaction, time::OffsetDateTime::now_utc().unix_timestamp_nanos())?;
let probe = operator_probe_transition_candidate(api, &transaction).await?;
Ok(TransitionOperatorStatus {
transaction_id,
state: transaction.state,
tier_name: transaction.tier_name,
remote_object: transaction.remote_object,
not_after_unix_nanos: transaction.not_after_unix_nanos,
probe,
})
}
pub async fn delete_transition_candidate_for_operator(
api: Arc<ECStore>,
transaction_id: Uuid,
remote_version_id: &str,
) -> TransitionOperatorResult<TransitionOperatorDeleteResult> {
if remote_version_id.is_empty() {
return Err(TransitionOperatorError::RemoteVersionRequired);
}
let transaction = load_operator_reconcile_transaction(api.clone(), transaction_id).await?;
validate_operator_reconcile_transaction(&transaction, time::OffsetDateTime::now_utc().unix_timestamp_nanos())?;
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(
&api.tier_config_mgr(),
&transaction.tier_name,
transaction.backend_fingerprint,
)
.await
.map_err(|err| TransitionOperatorError::Remote(std::io::Error::other(err)))?;
lease
.validate_remote_version_id(remote_version_id)
.map_err(TransitionOperatorError::Remote)?;
let before_delete_probe = lease
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
.await
.map(TransitionOperatorProbe::from)
.map_err(TransitionOperatorError::Remote)?;
if !matches!(&before_delete_probe, TransitionOperatorProbe::VersionedPresent(version_id) if version_id == remote_version_id) {
return Err(TransitionOperatorError::CandidateVersionMismatch {
expected: remote_version_id.to_string(),
actual: before_delete_probe,
});
}
delete_confirmed_transition_candidate_exact_with_lease_idempotent(&transaction.remote_object, remote_version_id, &lease)
.await
.map_err(TransitionOperatorError::Remote)?;
let probe = operator_probe_transition_candidate(api.clone(), &transaction).await?;
let journal_observed_after_delete = match load_transition_transaction_record(api, transaction_id).await {
Ok(_) => true,
Err(Error::ConfigNotFound) => false,
Err(err) => return Err(TransitionOperatorError::Store(err)),
};
Ok(TransitionOperatorDeleteResult {
status: TransitionOperatorStatus {
transaction_id,
state: transaction.state,
tier_name: transaction.tier_name,
remote_object: transaction.remote_object,
not_after_unix_nanos: transaction.not_after_unix_nanos,
probe,
},
journal_observed_after_delete,
})
}
pub async fn finalize_missing_transition_transaction_for_operator(
api: Arc<ECStore>,
transaction_id: Uuid,
) -> TransitionOperatorResult<()> {
let transaction = load_operator_reconcile_transaction(api.clone(), transaction_id).await?;
validate_operator_reconcile_transaction(&transaction, time::OffsetDateTime::now_utc().unix_timestamp_nanos())?;
let probe = operator_probe_transition_candidate(api.clone(), &transaction).await?;
if probe != TransitionOperatorProbe::Missing {
return Err(TransitionOperatorError::CandidateNotMissing(probe));
}
delete_transition_transaction_record(api, transaction_id)
.await
.map_err(TransitionOperatorError::Store)
}
pub(crate) fn decode_transition_transaction_record(object: &str, data: &[u8]) -> Result<TransitionTransaction> {
let transaction_id = transition_transaction_id_from_record_object_name(object)?;
TransitionTransaction::decode(transaction_id, data)
}
fn transition_transaction_id_from_record_object_name(object: &str) -> Result<Uuid> {
let prefix = format!("{TRANSITION_TRANSACTION_RECORD_PREFIX}/");
let suffix = object
.strip_prefix(&prefix)
.ok_or(TransitionTransactionError::Corrupt("transaction record path has wrong prefix"))?;
let file_name = suffix
.rsplit('/')
.next()
.ok_or(TransitionTransactionError::Corrupt("transaction record path is incomplete"))?;
let transaction_key = file_name
.strip_suffix(".json")
.ok_or(TransitionTransactionError::Corrupt("transaction record path has wrong suffix"))?;
if transaction_key.len() != 32 || !transaction_key.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(TransitionTransactionError::Corrupt("transaction record path has invalid transaction id"));
}
Uuid::parse_str(transaction_key).map_err(|_| TransitionTransactionError::Corrupt("transaction record path has invalid uuid"))
}
pub async fn process_transition_transaction_record(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
transaction.validate().map_err(transition_transaction_store_error)?;
match transaction.state {
TransitionTransactionState::Uploaded => {
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
TransitionTransactionState::CleanupPending => match local_commit_matches_transaction(api.clone(), transaction).await {
Ok(true) => {
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
Ok(false) => {
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
Err(err) if transition_source_is_missing(&err) => {
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
Err(err) => Err(err),
},
TransitionTransactionState::LocalCommitStarted => {
match local_commit_matches_transaction(api.clone(), transaction).await {
Ok(true) => {
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) => Err(err),
}
}
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed => {
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
TransitionTransactionState::UploadOutcomeUnknown => recover_unknown_upload_outcome(api, transaction).await,
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
}
}
async fn recover_unknown_upload_outcome(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(
&api.tier_config_mgr(),
&transaction.tier_name,
transaction.backend_fingerprint,
)
.await
.map_err(Error::other)?;
match lease
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
.await
.map_err(Error::other)?
{
TransitionCandidateProbe::Missing => {
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
TransitionCandidateProbe::UnversionedPresent => {
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::unversioned()).await
}
TransitionCandidateProbe::VersionedPresent(version_id)
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
{
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
&transaction.remote_object,
&version_id,
&transaction.tier_name,
transaction.backend_fingerprint,
&api.tier_config_mgr(),
)
.await
.map_err(Error::other)?;
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
TransitionCandidateProbe::VersionedPresent(version_id) => {
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
}
TransitionCandidateProbe::Ambiguous | TransitionCandidateProbe::Unsupported => {
Ok(TransitionTransactionRecoveryOutcome::Retained)
}
}
}
async fn cleanup_recovered_unknown_upload_candidate(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
remote_version: TransitionRemoteVersion,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
let mut cleanup = transaction.clone();
cleanup
.mark_cleanup_pending(
transaction.fence(),
TransitionCleanupProof {
transaction_id: transaction.transaction_id,
write_id: transaction.write_id,
remote_object: transaction.remote_object.clone(),
remote_version,
backend_fingerprint: transaction.backend_fingerprint,
decision: TransitionCleanupDecision::RemoteVersionRecoveredAfterCancellation,
},
)
.map_err(transition_transaction_store_error)?;
save_transition_transaction_record(api.clone(), &cleanup).await?;
delete_transition_remote_candidate(api.clone(), &cleanup).await?;
delete_transition_transaction_record(api, cleanup.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
fn transition_source_is_missing(err: &Error) -> bool {
matches!(
err,
Error::FileNotFound
| Error::FileVersionNotFound
| Error::ObjectNotFound(_, _)
| Error::VersionNotFound(_, _, _)
| Error::BucketNotFound(_)
)
}
async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<bool> {
let opts = ObjectOptions {
version_id: transaction.source.version_id.map(|version_id| version_id.to_string()),
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
metadata_cache_safe: false,
..Default::default()
};
let object = api
.get_object_info(&transaction.source.bucket, &transaction.source.object, &opts)
.await?;
let transitioned = &object.transitioned_object;
Ok(transitioned.status == TRANSITION_COMPLETE
&& transitioned.name == transaction.remote_object
&& transitioned.tier == transaction.tier_name
&& transitioned.version_id == transaction.remote_version.tier_delete_version_id().unwrap_or_default())
}
async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<()> {
let version_id = transaction.remote_version.tier_delete_version_id().unwrap_or_default();
let version_id_exact = transaction.remote_version.kind == TransitionRemoteVersionKind::Versioned;
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
&transaction.remote_object,
version_id,
&transaction.tier_name,
transaction.backend_fingerprint,
&api.tier_config_mgr(),
version_id_exact,
)
.await
.map(|_| ())
.map_err(Error::other)
}
pub async fn recover_transition_transaction_records(
api: Arc<ECStore>,
limit: usize,
marker: Option<String>,
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
if limit == 0 {
return Err(Error::other("transition transaction recovery limit must be greater than zero"));
}
let list_limit = i32::try_from(limit).map_or(i32::MAX, |value| value);
let list = api
.clone()
.list_objects_v2(
RUSTFS_META_BUCKET,
TRANSITION_TRANSACTION_RECORD_PREFIX,
marker.clone(),
None,
list_limit,
false,
None,
false,
)
.await?;
let mut stats = TransitionTransactionRecoveryStats {
scanned: 0,
recovered: 0,
retained: 0,
failed: 0,
next_marker: list.next_continuation_token,
truncated: list.is_truncated,
};
for object in list.objects {
stats.scanned += 1;
let transaction_id = match transition_transaction_id_from_record_object_name(&object.name) {
Ok(transaction_id) => transaction_id,
Err(err) => {
stats.failed += 1;
warn!(
event = EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
transaction_record = %object.name,
error = ?err,
"Failed to derive transition transaction id from record path"
);
continue;
}
};
let transaction = match load_transition_transaction_record(api.clone(), transaction_id).await {
Ok(transaction) => transaction,
Err(Error::ConfigNotFound) => continue,
Err(err) => {
stats.failed += 1;
warn!(
event = EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
transaction_record = %object.name,
transaction_id = %transaction_id,
error = ?err,
"Failed to load transition transaction record"
);
continue;
}
};
match process_transition_transaction_record(api.clone(), &transaction).await {
Ok(
TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted
| TransitionTransactionRecoveryOutcome::RecordDeleted,
) => {
stats.recovered += 1;
}
Ok(TransitionTransactionRecoveryOutcome::Retained) => {
stats.retained += 1;
debug!(
event = EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
transaction_record = %object.name,
transaction_id = %transaction.transaction_id,
state = ?transaction.state,
"Transition transaction recovery retained record for a later reconcile pass"
);
}
Err(err) => {
stats.failed += 1;
debug!(
event = EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
transaction_record = %object.name,
transaction_id = %transaction.transaction_id,
state = ?transaction.state,
error = ?err,
"Transition transaction recovery will retry later"
);
}
}
}
Ok(stats)
}
pub async fn run_transition_transaction_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
let mut interval = tokio::time::interval(TRANSITION_TRANSACTION_RECOVERY_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut marker: Option<String> = None;
loop {
tokio::select! {
biased;
_ = cancel_token.cancelled() => return,
_ = interval.tick() => {},
}
let recovery =
recover_transition_transaction_records(api.clone(), DEFAULT_TRANSITION_TRANSACTION_RECOVERY_LIMIT, marker.clone());
let Some(result) =
await_transition_transaction_recovery(&cancel_token, TRANSITION_TRANSACTION_RECOVERY_TIMEOUT, recovery).await
else {
return;
};
match result {
Ok(stats) => {
marker = stats.next_marker;
debug!(
event = EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
scanned = stats.scanned,
recovered = stats.recovered,
retained = stats.retained,
failed = stats.failed,
truncated = stats.truncated,
next_marker = ?marker,
"Recovered transition transaction records"
);
}
Err(err) => {
warn!(
event = EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
next_marker = ?marker,
error = ?err,
"Failed to recover transition transaction records"
);
}
}
}
}
async fn await_transition_transaction_recovery<T, F>(
cancel_token: &CancellationToken,
timeout: Duration,
recovery: F,
) -> Option<EcstoreResult<T>>
where
F: Future<Output = EcstoreResult<T>>,
{
tokio::select! {
_ = cancel_token.cancelled() => None,
result = tokio::time::timeout(timeout, recovery) => Some(match result {
Ok(result) => result,
Err(_) => Err(Error::other(format!(
"transition transaction recovery timed out after {} seconds",
timeout.as_secs()
))),
}),
}
}
fn state_change_allowed(from: TransitionTransactionState, to: TransitionTransactionState) -> bool {
matches!(
(from, to),
(TransitionTransactionState::UploadStarted, TransitionTransactionState::Uploaded)
| (
TransitionTransactionState::UploadStarted,
TransitionTransactionState::UploadOutcomeUnknown
)
| (TransitionTransactionState::UploadStarted, TransitionTransactionState::AbortedNoRemote)
| (TransitionTransactionState::UploadOutcomeUnknown, TransitionTransactionState::Uploaded)
| (TransitionTransactionState::Uploaded, TransitionTransactionState::LocalCommitStarted)
| (TransitionTransactionState::LocalCommitStarted, TransitionTransactionState::Committed)
)
}
fn state_requires_known_remote_version(state: TransitionTransactionState) -> bool {
matches!(
state,
TransitionTransactionState::Uploaded
| TransitionTransactionState::LocalCommitStarted
| TransitionTransactionState::Committed
| TransitionTransactionState::CleanupPending
)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
const BACKEND_FINGERPRINT: [u8; 32] = [7; 32];
#[derive(Default)]
struct MemoryTransactionStore {
records: HashMap<Uuid, Vec<u8>>,
}
impl MemoryTransactionStore {
fn create(&mut self, transaction: &TransitionTransaction) -> Result<()> {
if self.records.contains_key(&transaction.transaction_id) {
return Err(TransitionTransactionError::AlreadyExists);
}
self.records.insert(transaction.transaction_id, transaction.encode()?);
Ok(())
}
fn load(&self, transaction_id: Uuid) -> Result<TransitionTransaction> {
let data = self
.records
.get(&transaction_id)
.ok_or(TransitionTransactionError::NotFound)?;
TransitionTransaction::decode(transaction_id, data)
}
fn compare_save(
&mut self,
transaction_id: Uuid,
fence: TransitionTransactionFence,
next: TransitionTransactionState,
) -> Result<TransitionTransactionFence> {
let mut transaction = self.load(transaction_id)?;
let remote_version = (next == TransitionTransactionState::Uploaded)
.then(|| TransitionRemoteVersion::versioned(Uuid::new_v4().to_string()));
let next_fence = transaction.advance(fence, next, remote_version)?;
self.records.insert(transaction_id, transaction.encode()?);
Ok(next_fence)
}
fn compare_delete(&mut self, transaction_id: Uuid, fence: TransitionTransactionFence) -> Result<()> {
let transaction = self.load(transaction_id)?;
if transaction.fence() != fence {
return Err(TransitionTransactionError::Fenced);
}
self.records.remove(&transaction_id);
Ok(())
}
}
fn source_identity(version_mode: TransitionSourceVersionMode) -> TransitionSourceIdentity {
TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "object/key".to_string(),
version_id: (version_mode == TransitionSourceVersionMode::Versioned).then(Uuid::new_v4),
data_dir: Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "etag".to_string(),
version_mode,
}
}
fn new_transaction() -> TransitionTransaction {
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::new_v4(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source: source_identity(TransitionSourceVersionMode::Versioned),
tier_name: "warm-tier".to_string(),
backend_fingerprint: BACKEND_FINGERPRINT,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("valid transaction should be created")
}
fn upload(transaction: &mut TransitionTransaction) -> TransitionTransactionFence {
let fence = transaction.fence();
transaction
.advance(
fence,
TransitionTransactionState::Uploaded,
Some(TransitionRemoteVersion::versioned(Uuid::new_v4().to_string())),
)
.expect("upload state change should succeed")
}
#[test]
fn operator_reconcile_requires_expired_unknown_upload_outcome() {
let mut transaction = new_transaction();
let active_deadline = transaction.not_after_unix_nanos;
assert!(matches!(
validate_operator_reconcile_transaction(&transaction, i128::from(active_deadline) + 1),
Err(TransitionOperatorError::InvalidState(TransitionTransactionState::UploadStarted))
));
transaction
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
.expect("unknown upload outcome should be recorded");
assert!(matches!(
validate_operator_reconcile_transaction(&transaction, i128::from(active_deadline) - 1),
Err(TransitionOperatorError::NotExpired)
));
validate_operator_reconcile_transaction(&transaction, i128::from(active_deadline))
.expect("expired unknown upload outcome should be eligible");
}
fn cleanup_proof(transaction: &TransitionTransaction, decision: TransitionCleanupDecision) -> TransitionCleanupProof {
TransitionCleanupProof {
transaction_id: transaction.transaction_id,
write_id: transaction.write_id,
remote_object: transaction.remote_object.clone(),
remote_version: transaction.remote_version.clone(),
backend_fingerprint: transaction.backend_fingerprint,
decision,
}
}
#[test]
fn remote_version_distinguishes_unknown_unversioned_and_versioned() {
assert_eq!(TransitionRemoteVersion::known_from_put_response("").tier_delete_version_id(), None);
assert_eq!(
TransitionRemoteVersion::known_from_put_response(Uuid::nil().to_string()).tier_delete_version_id(),
None
);
let version_id = Uuid::new_v4().to_string();
assert_eq!(
TransitionRemoteVersion::known_from_put_response(version_id.clone()).tier_delete_version_id(),
Some(version_id.as_str())
);
assert!(TransitionRemoteVersion::unknown().is_unknown());
}
#[test]
fn transaction_round_trip_rejects_schema_checksum_unknown_fields_and_wrong_key() {
let transaction = new_transaction();
let encoded = transaction.encode().expect("transaction should encode");
let decoded = TransitionTransaction::decode(transaction.transaction_id, &encoded).expect("transaction should decode");
assert_eq!(decoded, transaction);
let mut wrong_schema: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded transaction should be json");
wrong_schema["schema"] = serde_json::Value::String("rustfs-transition-transaction-v2".to_string());
let wrong_schema_bytes = serde_json::to_vec(&wrong_schema).expect("wrong schema transaction should encode");
assert!(matches!(
TransitionTransaction::decode(transaction.transaction_id, &wrong_schema_bytes),
Err(TransitionTransactionError::UnsupportedSchema(_))
));
let mut bad_checksum: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded transaction should be json");
bad_checksum["content_sha256"] =
serde_json::Value::String("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string());
let bad_checksum_bytes = serde_json::to_vec(&bad_checksum).expect("bad checksum transaction should encode");
assert!(matches!(
TransitionTransaction::decode(transaction.transaction_id, &bad_checksum_bytes),
Err(TransitionTransactionError::ChecksumMismatch)
));
let mut unknown_field: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded transaction should be json");
unknown_field["unexpected"] = serde_json::Value::Bool(true);
let unknown_field_bytes = serde_json::to_vec(&unknown_field).expect("unknown field transaction should encode");
assert!(matches!(
TransitionTransaction::decode(transaction.transaction_id, &unknown_field_bytes),
Err(TransitionTransactionError::Json(_))
));
let mut nested_unknown_field: serde_json::Value =
serde_json::from_slice(&encoded).expect("encoded transaction should be json");
nested_unknown_field["transaction"]["remote_version"]["unexpected"] = serde_json::Value::Bool(true);
let nested_unknown_field_bytes =
serde_json::to_vec(&nested_unknown_field).expect("nested unknown field transaction should encode");
assert!(matches!(
TransitionTransaction::decode(transaction.transaction_id, &nested_unknown_field_bytes),
Err(TransitionTransactionError::Json(_))
));
assert!(matches!(
TransitionTransaction::decode(Uuid::new_v4(), &encoded),
Err(TransitionTransactionError::Corrupt("transaction_id does not match record key"))
));
}
#[test]
fn invalid_source_and_identity_boundaries_fail_closed() {
assert!(matches!(
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::nil(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source: source_identity(TransitionSourceVersionMode::Versioned),
tier_name: "warm-tier".to_string(),
backend_fingerprint: BACKEND_FINGERPRINT,
not_after_unix_nanos: 1,
}),
Err(TransitionTransactionError::Corrupt("deployment_id is nil"))
));
let mut source = source_identity(TransitionSourceVersionMode::Unversioned);
source.version_id = Some(Uuid::nil());
assert!(matches!(
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::new_v4(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source,
tier_name: "warm-tier".to_string(),
backend_fingerprint: BACKEND_FINGERPRINT,
not_after_unix_nanos: 1,
}),
Err(TransitionTransactionError::Corrupt("source version_id is nil"))
));
let mut source = source_identity(TransitionSourceVersionMode::VersionSuspended);
source.version_id = Some(Uuid::new_v4());
assert!(matches!(
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::new_v4(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source,
tier_name: "warm-tier".to_string(),
backend_fingerprint: BACKEND_FINGERPRINT,
not_after_unix_nanos: 1,
}),
Err(TransitionTransactionError::Corrupt("non-versioned source mode must not carry version_id"))
));
assert!(
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::new_v4(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source: source_identity(TransitionSourceVersionMode::VersionSuspended),
tier_name: "warm-tier".to_string(),
backend_fingerprint: BACKEND_FINGERPRINT,
not_after_unix_nanos: 1,
})
.is_ok()
);
}
#[test]
fn state_machine_requires_known_remote_version_and_rejects_commit_skip() {
let mut transaction = new_transaction();
let fence = transaction.fence();
assert!(matches!(
transaction.advance(fence, TransitionTransactionState::Committed, None),
Err(TransitionTransactionError::InvalidStateChange {
from: TransitionTransactionState::UploadStarted,
to: TransitionTransactionState::Committed,
})
));
assert!(matches!(
transaction.advance(fence, TransitionTransactionState::Uploaded, Some(TransitionRemoteVersion::unknown())),
Err(TransitionTransactionError::Corrupt("uploaded state requires known remote version"))
));
let uploaded_fence = upload(&mut transaction);
let local_commit_fence = transaction
.advance(uploaded_fence, TransitionTransactionState::LocalCommitStarted, None)
.expect("local commit start should succeed");
let committed_fence = transaction
.advance(local_commit_fence, TransitionTransactionState::Committed, None)
.expect("commit should succeed");
assert_eq!(committed_fence.revision, 4);
assert_eq!(transaction.state, TransitionTransactionState::Committed);
}
#[test]
fn cleanup_pending_requires_exact_proof_and_state_specific_decision() {
let mut transaction = new_transaction();
let uploaded_fence = upload(&mut transaction);
let mut wrong_remote = cleanup_proof(&transaction, TransitionCleanupDecision::UploadAbortedBeforeLocalCommit);
wrong_remote.remote_object.push_str("-different");
assert!(matches!(
transaction.mark_cleanup_pending(uploaded_fence, wrong_remote),
Err(TransitionTransactionError::Fenced)
));
let proof = cleanup_proof(&transaction, TransitionCleanupDecision::UploadAbortedBeforeLocalCommit);
let cleanup_fence = transaction
.mark_cleanup_pending(uploaded_fence, proof)
.expect("uploaded transaction should enter cleanup pending");
assert_eq!(cleanup_fence.revision, 3);
assert_eq!(transaction.state, TransitionTransactionState::CleanupPending);
let mut local_commit_transaction = new_transaction();
let uploaded_fence = upload(&mut local_commit_transaction);
let local_commit_fence = local_commit_transaction
.advance(uploaded_fence, TransitionTransactionState::LocalCommitStarted, None)
.expect("local commit start should succeed");
let mut changed_source = local_commit_transaction.source.clone();
changed_source.etag = "different-etag".to_string();
let proof = cleanup_proof(
&local_commit_transaction,
TransitionCleanupDecision::SourceReconciledUnchanged {
observed_source: changed_source,
},
);
assert!(matches!(
local_commit_transaction.mark_cleanup_pending(local_commit_fence, proof),
Err(TransitionTransactionError::InvalidStateChange {
from: TransitionTransactionState::LocalCommitStarted,
to: TransitionTransactionState::CleanupPending,
})
));
}
#[test]
fn upload_outcome_unknown_can_only_cleanup_after_version_recovery() {
let mut transaction = new_transaction();
let unknown_fence = transaction
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
.expect("upload outcome unknown should be recorded");
let mut proof = cleanup_proof(&transaction, TransitionCleanupDecision::RemoteVersionRecoveredAfterCancellation);
proof.remote_version = TransitionRemoteVersion::unknown();
assert!(matches!(
transaction.mark_cleanup_pending(unknown_fence, proof),
Err(TransitionTransactionError::Corrupt("cleanup proof requires known remote version"))
));
let proof = TransitionCleanupProof {
remote_version: TransitionRemoteVersion::versioned(Uuid::new_v4().to_string()),
decision: TransitionCleanupDecision::RemoteVersionRecoveredAfterCancellation,
..cleanup_proof(&transaction, TransitionCleanupDecision::RemoteVersionRecoveredAfterCancellation)
};
let cleanup_fence = transaction
.mark_cleanup_pending(unknown_fence, proof)
.expect("recovered remote version should allow cleanup pending");
assert_eq!(cleanup_fence.revision, 3);
assert_eq!(transaction.state, TransitionTransactionState::CleanupPending);
}
#[test]
fn stale_fence_blocks_second_actor_and_compare_delete() {
let mut store = MemoryTransactionStore::default();
let transaction = new_transaction();
let transaction_id = transaction.transaction_id;
let first_actor_fence = transaction.fence();
let second_actor_fence = transaction.fence();
store.create(&transaction).expect("initial create should succeed");
let uploaded_fence = store
.compare_save(transaction_id, first_actor_fence, TransitionTransactionState::Uploaded)
.expect("first actor should advance upload");
assert!(matches!(
store.compare_save(transaction_id, second_actor_fence, TransitionTransactionState::LocalCommitStarted),
Err(TransitionTransactionError::Fenced)
));
let local_commit_fence = store
.compare_save(transaction_id, uploaded_fence, TransitionTransactionState::LocalCommitStarted)
.expect("fresh fence should advance local commit");
assert!(matches!(
store.compare_delete(transaction_id, uploaded_fence),
Err(TransitionTransactionError::Fenced)
));
store
.compare_delete(transaction_id, local_commit_fence)
.expect("fresh fence should delete transaction");
assert!(matches!(store.load(transaction_id), Err(TransitionTransactionError::NotFound)));
}
#[test]
fn decode_rejects_oversized_record_before_parsing() {
let transaction = new_transaction();
let oversized = vec![b' '; MAX_TRANSITION_TRANSACTION_SIZE + 1];
assert!(matches!(
TransitionTransaction::decode(transaction.transaction_id, &oversized),
Err(TransitionTransactionError::Corrupt("encoded transaction exceeds maximum size"))
));
}
#[test]
fn record_object_name_is_stable_and_sanitized() {
let transaction_id = Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").expect("test uuid should parse");
let object = transition_transaction_record_object_name(transaction_id).expect("record object should build");
assert_eq!(object, "ilm/transition-transactions/records/aa/aa/aaaaaaaabbbbccccddddeeeeeeeeeeee.json");
let file_name = object.rsplit('/').next().expect("record object should have file name");
assert!(!file_name.contains('-'));
assert!(matches!(
transition_transaction_record_object_name(Uuid::nil()),
Err(TransitionTransactionError::Corrupt("transaction_id is nil"))
));
}
}