// 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::sync::Arc; use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::config::com; use crate::disk::RUSTFS_META_BUCKET; use crate::error::{Error, Result as EcstoreResult}; use crate::object_api::{ObjectInfo, ObjectOptions}; use crate::services::tier::tier::TierDestinationId; use crate::storage_api_contracts::{ list::{ListOperations, StorageListObjectsV2Info}, object::{EcstoreObjectIO, EcstoreObjectOperations, HTTPPreconditions}, }; pub(crate) const TIER_MUTATION_INTENT_SCHEMA: &str = "rustfs-tier-mutation-intent-v1"; pub(crate) const MAX_TIER_MUTATION_INTENT_SIZE: usize = rustfs_protos::TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE; pub(crate) const TIER_MUTATION_INTENT_RECORD_PREFIX: &str = "tier/mutation-intents/records"; pub(crate) const TIER_COORDINATOR_MUTATION_INTENT_RECORD_PREFIX: &str = "tier/mutation-intents/coordinators"; const TIER_MUTATION_INTENT_ADVANCE_CAS_ATTEMPTS: usize = 3; pub(crate) type TierMutationDigest = [u8; 32]; pub(crate) type Result = std::result::Result; #[derive(Debug, thiserror::Error)] pub(crate) enum TierMutationIntentError { #[error("tier mutation intent is corrupt: {0}")] Corrupt(&'static str), #[error("tier mutation intent schema is unsupported: {0}")] UnsupportedSchema(String), #[error("tier mutation intent checksum mismatch")] ChecksumMismatch, #[error("invalid tier mutation intent state change from {from:?} to {to:?}")] InvalidStateChange { from: TierMutationIntentState, to: TierMutationIntentState, }, #[error("tier mutation intent json error: {0}")] Json(#[from] serde_json::Error), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum TierMutationIntentState { Prepared, Committed, Aborted, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) enum TierMutationIntentKind { Add, Edit, Remove, Clear, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct TierMutationIntentTarget { pub tier_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub old_backend_identity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub new_backend_identity: Option, } impl TierMutationIntentTarget { pub(crate) fn validate(&self) -> Result<()> { if self.tier_name.is_empty() { return Err(TierMutationIntentError::Corrupt("target tier name is empty")); } if self.old_backend_identity.is_none() && self.new_backend_identity.is_none() { return Err(TierMutationIntentError::Corrupt("target has no backend identity")); } if self.old_backend_identity.as_ref().is_some_and(identity_is_empty) { return Err(TierMutationIntentError::Corrupt("target old backend identity is empty")); } if self.new_backend_identity.as_ref().is_some_and(identity_is_empty) { return Err(TierMutationIntentError::Corrupt("target new backend identity is empty")); } Ok(()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct TierMutationIntent { pub mutation_id: Uuid, pub revision: u64, pub kind: TierMutationIntentKind, pub state: TierMutationIntentState, #[serde(default, skip_serializing_if = "Option::is_none")] pub old_config_etag: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub committed_config_etag: Option, pub candidate_digest: TierMutationDigest, pub affected_targets: Vec, pub expires_at_unix_nanos: i64, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TierMutationIntentRecordScan { pub intents: Vec, pub scanned: usize, pub failed: usize, pub next_marker: Option, pub truncated: bool, } impl TierMutationIntent { pub(crate) fn validate(&self) -> Result<()> { if self.mutation_id.is_nil() { return Err(TierMutationIntentError::Corrupt("mutation_id is nil")); } if self.revision == 0 { return Err(TierMutationIntentError::Corrupt("revision is zero")); } if self.old_config_etag.as_deref().is_some_and(str::is_empty) { return Err(TierMutationIntentError::Corrupt("old config etag is empty")); } if self.old_config_etag.is_none() && self.kind != TierMutationIntentKind::Add { return Err(TierMutationIntentError::Corrupt("old config etag is required for mutation kind")); } if self.committed_config_etag.as_deref().is_some_and(str::is_empty) { return Err(TierMutationIntentError::Corrupt("committed config etag is empty")); } if self.state != TierMutationIntentState::Committed && self.committed_config_etag.is_some() { return Err(TierMutationIntentError::Corrupt("non-committed intent carries committed config etag")); } if self.state == TierMutationIntentState::Committed && self.committed_config_etag.is_none() { return Err(TierMutationIntentError::Corrupt("committed intent is missing committed config etag")); } if digest_is_empty(&self.candidate_digest) { return Err(TierMutationIntentError::Corrupt("candidate digest is empty")); } if self.affected_targets.is_empty() { return Err(TierMutationIntentError::Corrupt("affected targets are empty")); } if self.expires_at_unix_nanos <= 0 { return Err(TierMutationIntentError::Corrupt("expiry timestamp is not positive")); } let mut previous: Option<&str> = None; for target in &self.affected_targets { target.validate()?; self.validate_target_shape(target)?; if previous.is_some_and(|previous| previous >= target.tier_name.as_str()) { return Err(TierMutationIntentError::Corrupt("affected targets are not canonical")); } previous = Some(&target.tier_name); } Ok(()) } fn validate_target_shape(&self, target: &TierMutationIntentTarget) -> Result<()> { match self.kind { TierMutationIntentKind::Add if target.old_backend_identity.is_none() && target.new_backend_identity.is_some() => { Ok(()) } TierMutationIntentKind::Edit if target.old_backend_identity.is_some() && target.new_backend_identity.is_some() => { Ok(()) } TierMutationIntentKind::Remove | TierMutationIntentKind::Clear if target.old_backend_identity.is_some() && target.new_backend_identity.is_none() => { Ok(()) } _ => Err(TierMutationIntentError::Corrupt( "target backend identity shape does not match mutation kind", )), } } pub(crate) fn advance(&mut self, next: TierMutationIntentState, committed_config_etag: Option) -> Result<()> { match (self.state, next) { (TierMutationIntentState::Prepared, TierMutationIntentState::Committed) => { let committed_config_etag = committed_config_etag.ok_or(TierMutationIntentError::Corrupt("commit requires config etag"))?; if committed_config_etag.is_empty() { return Err(TierMutationIntentError::Corrupt("commit config etag is empty")); } self.state = next; self.committed_config_etag = Some(committed_config_etag); } (TierMutationIntentState::Prepared, TierMutationIntentState::Aborted) => { if committed_config_etag.is_some() { return Err(TierMutationIntentError::Corrupt("abort must not carry committed config etag")); } self.state = next; self.committed_config_etag = None; } _ => { return Err(TierMutationIntentError::InvalidStateChange { from: self.state, to: next, }); } } self.revision = self .revision .checked_add(1) .ok_or(TierMutationIntentError::Corrupt("revision overflow"))?; self.validate() } pub(crate) fn advance_idempotent( &mut self, next: TierMutationIntentState, committed_config_etag: Option, ) -> Result { match (self.state, next) { (TierMutationIntentState::Prepared, TierMutationIntentState::Committed | TierMutationIntentState::Aborted) => { self.advance(next, committed_config_etag)?; Ok(true) } (TierMutationIntentState::Committed, TierMutationIntentState::Committed) => { let committed_config_etag = committed_config_etag.ok_or(TierMutationIntentError::Corrupt("commit requires config etag"))?; if committed_config_etag.is_empty() { return Err(TierMutationIntentError::Corrupt("commit config etag is empty")); } if self.committed_config_etag.as_deref() != Some(committed_config_etag.as_str()) { return Err(TierMutationIntentError::Corrupt("committed config etag does not match")); } self.validate()?; Ok(false) } (TierMutationIntentState::Aborted, TierMutationIntentState::Aborted) => { if committed_config_etag.is_some() { return Err(TierMutationIntentError::Corrupt("abort must not carry committed config etag")); } self.validate()?; Ok(false) } _ => Err(TierMutationIntentError::InvalidStateChange { from: self.state, to: next, }), } } pub(crate) fn same_identity_as(&self, other: &Self) -> bool { self.mutation_id == other.mutation_id && self.kind == other.kind && self.old_config_etag == other.old_config_etag && self.candidate_digest == other.candidate_digest && self.affected_targets == other.affected_targets && self.expires_at_unix_nanos == other.expires_at_unix_nanos } pub(crate) fn encode(&self) -> Result> { self.validate()?; let intent_bytes = serde_json::to_vec(self)?; let content_sha256 = hex_sha256(&intent_bytes, ToOwned::to_owned); let persisted = PersistedTierMutationIntent { schema: TIER_MUTATION_INTENT_SCHEMA.to_string(), content_sha256, intent: self.clone(), }; let encoded = serde_json::to_vec(&persisted)?; if encoded.len() > MAX_TIER_MUTATION_INTENT_SIZE { return Err(TierMutationIntentError::Corrupt("encoded intent exceeds maximum size")); } Ok(encoded) } pub(crate) fn decode(expected_mutation_id: Uuid, data: &[u8]) -> Result { if data.len() > MAX_TIER_MUTATION_INTENT_SIZE { return Err(TierMutationIntentError::Corrupt("encoded intent exceeds maximum size")); } let persisted: PersistedTierMutationIntent = serde_json::from_slice(data)?; if persisted.schema != TIER_MUTATION_INTENT_SCHEMA { return Err(TierMutationIntentError::UnsupportedSchema(persisted.schema)); } if !is_sha256_checksum(&persisted.content_sha256) { return Err(TierMutationIntentError::Corrupt("content checksum is not a sha256 checksum")); } let intent_bytes = serde_json::to_vec(&persisted.intent)?; let actual_checksum = hex_sha256(&intent_bytes, ToOwned::to_owned); if persisted.content_sha256 != actual_checksum { return Err(TierMutationIntentError::ChecksumMismatch); } if persisted.intent.mutation_id != expected_mutation_id { return Err(TierMutationIntentError::Corrupt("mutation_id does not match intent key")); } persisted.intent.validate()?; Ok(persisted.intent) } } #[allow( dead_code, reason = "intent-record persistence asserted by store::init tests (backlog#1823)" )] pub(crate) fn tier_mutation_intent_record_object_name(mutation_id: Uuid) -> Result { tier_mutation_intent_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, mutation_id) } fn tier_mutation_intent_record_object_name_with_prefix(prefix: &str, mutation_id: Uuid) -> Result { if mutation_id.is_nil() { return Err(TierMutationIntentError::Corrupt("mutation_id is nil")); } let mutation_key = mutation_id.simple().to_string(); Ok(format!("{}/{}/{}/{}.json", prefix, &mutation_key[..2], &mutation_key[2..4], mutation_key)) } #[allow( dead_code, reason = "intent-record persistence asserted by store::init tests (backlog#1823)" )] pub(crate) fn tier_mutation_intent_id_from_record_object_name(object: &str) -> Result { tier_mutation_intent_id_from_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, object) } fn tier_mutation_intent_id_from_record_object_name_with_prefix(prefix: &str, object: &str) -> Result { let prefix = format!("{prefix}/"); let suffix = object .strip_prefix(&prefix) .ok_or(TierMutationIntentError::Corrupt("intent record path has wrong prefix"))?; let mut parts = suffix.split('/'); let shard_a = parts .next() .ok_or(TierMutationIntentError::Corrupt("intent record path is incomplete"))?; let shard_b = parts .next() .ok_or(TierMutationIntentError::Corrupt("intent record path is incomplete"))?; let file_name = parts .next() .ok_or(TierMutationIntentError::Corrupt("intent record path is incomplete"))?; if parts.next().is_some() { return Err(TierMutationIntentError::Corrupt("intent record path is not canonical")); } let mutation_key = file_name .strip_suffix(".json") .ok_or(TierMutationIntentError::Corrupt("intent record path has wrong suffix"))?; if mutation_key.len() != 32 || !mutation_key .bytes() .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) { return Err(TierMutationIntentError::Corrupt("intent record path has invalid mutation id")); } if shard_a != &mutation_key[..2] || shard_b != &mutation_key[2..4] { return Err(TierMutationIntentError::Corrupt("intent record path shard does not match mutation id")); } Uuid::parse_str(mutation_key).map_err(|_| TierMutationIntentError::Corrupt("intent record path has invalid uuid")) } #[allow( dead_code, reason = "intent-record persistence asserted by store::init tests (backlog#1823)" )] pub(crate) async fn save_tier_mutation_intent_record(api: Arc, intent: &TierMutationIntent) -> EcstoreResult<()> where S: EcstoreObjectIO, { let object = tier_mutation_intent_record_object_name_with_prefix(TIER_MUTATION_INTENT_RECORD_PREFIX, intent.mutation_id) .map_err(tier_mutation_intent_store_error)?; let data = intent.encode().map_err(tier_mutation_intent_store_error)?; com::save_config(api, &object, data).await } pub(crate) async fn save_tier_coordinator_mutation_intent_record_if_absent( api: Arc, intent: &TierMutationIntent, ) -> EcstoreResult<()> where S: EcstoreObjectIO, { save_tier_mutation_intent_record_if_absent_with_prefix(api, TIER_COORDINATOR_MUTATION_INTENT_RECORD_PREFIX, intent).await } pub(crate) async fn save_tier_mutation_intent_record_if_absent(api: Arc, intent: &TierMutationIntent) -> EcstoreResult<()> where S: EcstoreObjectIO, { save_tier_mutation_intent_record_if_absent_with_prefix(api, TIER_MUTATION_INTENT_RECORD_PREFIX, intent).await } async fn save_tier_mutation_intent_record_if_absent_with_prefix( api: Arc, prefix: &str, intent: &TierMutationIntent, ) -> EcstoreResult<()> where S: EcstoreObjectIO, { let object = tier_mutation_intent_record_object_name_with_prefix(prefix, intent.mutation_id) .map_err(tier_mutation_intent_store_error)?; let data = intent.encode().map_err(tier_mutation_intent_store_error)?; com::save_config_with_opts( api, &object, data, &ObjectOptions { max_parity: true, http_preconditions: Some(HTTPPreconditions { if_none_match: Some("*".to_string()), ..Default::default() }), ..Default::default() }, ) .await } pub(crate) async fn load_tier_mutation_intent_record(api: Arc, mutation_id: Uuid) -> EcstoreResult where S: EcstoreObjectIO, { let (intent, _) = load_tier_mutation_intent_record_with_etag(api, mutation_id).await?; Ok(intent) } pub(crate) async fn load_tier_mutation_intent_record_with_etag( api: Arc, mutation_id: Uuid, ) -> EcstoreResult<(TierMutationIntent, String)> where S: EcstoreObjectIO, { load_tier_mutation_intent_record_with_etag_at_prefix(api, TIER_MUTATION_INTENT_RECORD_PREFIX, mutation_id).await } async fn load_tier_mutation_intent_record_with_etag_at_prefix( api: Arc, prefix: &str, mutation_id: Uuid, ) -> EcstoreResult<(TierMutationIntent, String)> where S: EcstoreObjectIO, { let object = tier_mutation_intent_record_object_name_with_prefix(prefix, mutation_id).map_err(tier_mutation_intent_store_error)?; let (data, object_info) = com::read_config_with_metadata(api, &object, &ObjectOptions::default()).await?; let etag = object_info .etag .filter(|etag| !etag.trim().is_empty()) .ok_or_else(|| Error::other("tier mutation intent record is missing an ETag"))?; let intent = TierMutationIntent::decode(mutation_id, &data).map_err(tier_mutation_intent_store_error)?; Ok((intent, etag)) } #[allow( dead_code, reason = "intent-record persistence asserted by store::init tests (backlog#1823)" )] pub(crate) async fn save_tier_mutation_intent_record_if_current( api: Arc, intent: &TierMutationIntent, current_etag: &str, ) -> EcstoreResult<()> where S: EcstoreObjectIO, { if current_etag.trim().is_empty() { return Err(Error::other("tier mutation intent current ETag is empty")); } save_tier_mutation_intent_record_if_current_with_prefix(api, TIER_MUTATION_INTENT_RECORD_PREFIX, intent, current_etag).await } async fn save_tier_mutation_intent_record_if_current_with_prefix( api: Arc, prefix: &str, intent: &TierMutationIntent, current_etag: &str, ) -> EcstoreResult<()> where S: EcstoreObjectIO, { if current_etag.trim().is_empty() { return Err(Error::other("tier mutation intent current ETag is empty")); } let object = tier_mutation_intent_record_object_name_with_prefix(prefix, intent.mutation_id) .map_err(tier_mutation_intent_store_error)?; let data = intent.encode().map_err(tier_mutation_intent_store_error)?; com::save_config_with_opts( api, &object, data, &ObjectOptions { max_parity: true, http_preconditions: Some(HTTPPreconditions { if_match: Some(current_etag.to_string()), ..Default::default() }), ..Default::default() }, ) .await } pub(crate) async fn delete_tier_mutation_intent_record(api: Arc, mutation_id: Uuid) -> EcstoreResult<()> where S: EcstoreObjectOperations, { delete_tier_mutation_intent_record_with_prefix(api, TIER_MUTATION_INTENT_RECORD_PREFIX, mutation_id).await } pub(crate) async fn delete_tier_coordinator_mutation_intent_record(api: Arc, mutation_id: Uuid) -> EcstoreResult<()> where S: EcstoreObjectOperations, { delete_tier_mutation_intent_record_with_prefix(api, TIER_COORDINATOR_MUTATION_INTENT_RECORD_PREFIX, mutation_id).await } async fn delete_tier_mutation_intent_record_with_prefix(api: Arc, prefix: &str, mutation_id: Uuid) -> EcstoreResult<()> where S: EcstoreObjectOperations, { let object = tier_mutation_intent_record_object_name_with_prefix(prefix, mutation_id).map_err(tier_mutation_intent_store_error)?; match com::delete_config(api, &object).await { Ok(()) | Err(Error::ConfigNotFound) => Ok(()), Err(err) => Err(err), } } pub(crate) async fn advance_tier_mutation_intent_record_idempotent( api: Arc, mutation_id: Uuid, next: TierMutationIntentState, committed_config_etag: Option, ) -> EcstoreResult<(TierMutationIntent, bool)> where S: EcstoreObjectIO, { advance_tier_mutation_intent_record_idempotent_at_prefix( api, TIER_MUTATION_INTENT_RECORD_PREFIX, mutation_id, next, committed_config_etag, ) .await } pub(crate) async fn advance_tier_coordinator_mutation_intent_record_idempotent( api: Arc, mutation_id: Uuid, next: TierMutationIntentState, committed_config_etag: Option, ) -> EcstoreResult<(TierMutationIntent, bool)> where S: EcstoreObjectIO, { advance_tier_mutation_intent_record_idempotent_at_prefix( api, TIER_COORDINATOR_MUTATION_INTENT_RECORD_PREFIX, mutation_id, next, committed_config_etag, ) .await } async fn advance_tier_mutation_intent_record_idempotent_at_prefix( api: Arc, prefix: &str, mutation_id: Uuid, next: TierMutationIntentState, committed_config_etag: Option, ) -> EcstoreResult<(TierMutationIntent, bool)> where S: EcstoreObjectIO, { for attempt in 0..TIER_MUTATION_INTENT_ADVANCE_CAS_ATTEMPTS { let (mut intent, current_etag) = load_tier_mutation_intent_record_with_etag_at_prefix(api.clone(), prefix, mutation_id).await?; let advanced = intent .advance_idempotent(next, committed_config_etag.clone()) .map_err(tier_mutation_intent_store_error)?; if !advanced { return Ok((intent, false)); } match save_tier_mutation_intent_record_if_current_with_prefix(api.clone(), prefix, &intent, ¤t_etag).await { Ok(()) => return Ok((intent, true)), Err(Error::PreconditionFailed) => { let (mut current, _) = load_tier_mutation_intent_record_with_etag_at_prefix(api.clone(), prefix, mutation_id).await?; if !current.same_identity_as(&intent) { return Err(Error::PreconditionFailed); } let replayed = current .advance_idempotent(next, intent.committed_config_etag.clone()) .map_err(tier_mutation_intent_store_error)?; if replayed { if attempt + 1 < TIER_MUTATION_INTENT_ADVANCE_CAS_ATTEMPTS { continue; } return Err(Error::PreconditionFailed); } return Ok((current, false)); } Err(err) => return Err(err), } } Err(Error::PreconditionFailed) } pub(crate) async fn list_tier_mutation_intent_records( api: Arc, limit: usize, marker: Option, ) -> EcstoreResult where S: EcstoreObjectIO + ListOperations>, { list_tier_mutation_intent_records_at_prefix(api, TIER_MUTATION_INTENT_RECORD_PREFIX, limit, marker).await } pub(crate) async fn list_tier_coordinator_mutation_intent_records( api: Arc, limit: usize, marker: Option, ) -> EcstoreResult where S: EcstoreObjectIO + ListOperations>, { list_tier_mutation_intent_records_at_prefix(api, TIER_COORDINATOR_MUTATION_INTENT_RECORD_PREFIX, limit, marker).await } async fn list_tier_mutation_intent_records_at_prefix( api: Arc, prefix: &str, limit: usize, marker: Option, ) -> EcstoreResult where S: EcstoreObjectIO + ListOperations>, { if limit == 0 { return Err(Error::other("tier mutation intent scan limit must be greater than zero")); } let list = api .clone() .list_objects_v2( RUSTFS_META_BUCKET, prefix, marker, None, i32::try_from(limit).unwrap_or(i32::MAX), false, None, false, ) .await?; let mut scan = TierMutationIntentRecordScan { intents: Vec::with_capacity(list.objects.len()), scanned: 0, failed: 0, next_marker: list.next_continuation_token, truncated: list.is_truncated, }; for object in list.objects { scan.scanned += 1; let mutation_id = match tier_mutation_intent_id_from_record_object_name_with_prefix(prefix, &object.name) { Ok(mutation_id) => mutation_id, Err(_) => { scan.failed += 1; continue; } }; match load_tier_mutation_intent_record_with_etag_at_prefix(api.clone(), prefix, mutation_id).await { Ok((intent, _)) => scan.intents.push(intent), Err(Error::ConfigNotFound) => {} Err(_) => scan.failed += 1, } } Ok(scan) } fn tier_mutation_intent_store_error(err: TierMutationIntentError) -> Error { Error::other(err) } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct PersistedTierMutationIntent { schema: String, content_sha256: String, intent: TierMutationIntent, } fn identity_is_empty(identity: &TierDestinationId) -> bool { identity.iter().all(|byte| *byte == 0) } fn digest_is_empty(digest: &TierMutationDigest) -> bool { digest.iter().all(|byte| *byte == 0) } #[cfg(test)] mod tests { use super::*; const OLD_IDENTITY: TierDestinationId = [1; 32]; const NEW_IDENTITY: TierDestinationId = [2; 32]; const CANDIDATE_DIGEST: TierMutationDigest = [3; 32]; fn prepared_intent() -> TierMutationIntent { TierMutationIntent { mutation_id: Uuid::new_v4(), revision: 1, kind: TierMutationIntentKind::Edit, state: TierMutationIntentState::Prepared, old_config_etag: Some("old-etag".to_string()), committed_config_etag: None, candidate_digest: CANDIDATE_DIGEST, affected_targets: vec![TierMutationIntentTarget { tier_name: "COLD-A".to_string(), old_backend_identity: Some(OLD_IDENTITY), new_backend_identity: Some(NEW_IDENTITY), }], expires_at_unix_nanos: 1_780_000_000_000_000_000, } } #[test] fn intent_round_trip_preserves_committed_state() { let mut intent = prepared_intent(); intent .advance(TierMutationIntentState::Committed, Some("new-etag".to_string())) .expect("intent should commit"); let encoded = intent.encode().expect("intent should encode"); let decoded = TierMutationIntent::decode(intent.mutation_id, &encoded).expect("intent should decode"); assert_eq!(decoded, intent); assert_eq!(decoded.committed_config_etag.as_deref(), Some("new-etag")); } #[test] fn intent_decode_rejects_unknown_fields_and_checksum_mismatch() { let intent = prepared_intent(); let encoded = intent.encode().expect("intent should encode"); let mut persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded intent should be json"); persisted["intent"]["unexpected"] = serde_json::Value::Bool(true); let unknown = serde_json::to_vec(&persisted).expect("mutated intent should encode"); assert!(matches!( TierMutationIntent::decode(intent.mutation_id, &unknown), Err(TierMutationIntentError::Json(_)) )); let mut persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded intent should be json"); persisted["intent"]["revision"] = serde_json::Value::from(2_u64); let mismatched = serde_json::to_vec(&persisted).expect("mutated intent should encode"); assert!(matches!( TierMutationIntent::decode(intent.mutation_id, &mismatched), Err(TierMutationIntentError::ChecksumMismatch) )); } #[test] fn intent_validation_requires_canonical_targets() { let mut duplicate = prepared_intent(); duplicate.affected_targets.push(TierMutationIntentTarget { tier_name: "COLD-A".to_string(), old_backend_identity: Some(OLD_IDENTITY), new_backend_identity: Some(NEW_IDENTITY), }); assert!(matches!( duplicate.validate(), Err(TierMutationIntentError::Corrupt("affected targets are not canonical")) )); let mut unsorted = prepared_intent(); unsorted.affected_targets.insert( 0, TierMutationIntentTarget { tier_name: "COLD-Z".to_string(), old_backend_identity: Some(OLD_IDENTITY), new_backend_identity: Some(NEW_IDENTITY), }, ); assert!(matches!( unsorted.validate(), Err(TierMutationIntentError::Corrupt("affected targets are not canonical")) )); } #[test] fn intent_state_machine_rejects_late_abort_after_commit() { let mut intent = prepared_intent(); intent .advance(TierMutationIntentState::Committed, Some("new-etag".to_string())) .expect("intent should commit"); assert!(matches!( intent.advance(TierMutationIntentState::Aborted, None), Err(TierMutationIntentError::InvalidStateChange { from: TierMutationIntentState::Committed, to: TierMutationIntentState::Aborted, }) )); } #[test] fn intent_idempotent_commit_retry_keeps_revision_and_rejects_conflicts() { let mut intent = prepared_intent(); assert!( intent .advance_idempotent(TierMutationIntentState::Committed, Some("new-etag".to_string())) .expect("initial commit should advance") ); assert_eq!(intent.revision, 2); assert!( !intent .advance_idempotent(TierMutationIntentState::Committed, Some("new-etag".to_string())) .expect("same commit retry should be idempotent") ); assert_eq!(intent.revision, 2); assert!(matches!( intent.advance_idempotent(TierMutationIntentState::Committed, Some("other-etag".to_string())), Err(TierMutationIntentError::Corrupt("committed config etag does not match")) )); assert!(matches!( intent.advance_idempotent(TierMutationIntentState::Aborted, None), Err(TierMutationIntentError::InvalidStateChange { from: TierMutationIntentState::Committed, to: TierMutationIntentState::Aborted, }) )); } #[test] fn intent_idempotent_abort_retry_keeps_revision_and_rejects_commit() { let mut intent = prepared_intent(); assert!( intent .advance_idempotent(TierMutationIntentState::Aborted, None) .expect("initial abort should advance") ); assert_eq!(intent.revision, 2); assert!( !intent .advance_idempotent(TierMutationIntentState::Aborted, None) .expect("same abort retry should be idempotent") ); assert_eq!(intent.revision, 2); assert!(matches!( intent.advance_idempotent(TierMutationIntentState::Aborted, Some("unexpected-etag".to_string())), Err(TierMutationIntentError::Corrupt("abort must not carry committed config etag")) )); assert!(matches!( intent.advance_idempotent(TierMutationIntentState::Committed, Some("new-etag".to_string())), Err(TierMutationIntentError::InvalidStateChange { from: TierMutationIntentState::Aborted, to: TierMutationIntentState::Committed, }) )); } #[test] fn intent_validation_rejects_placeholder_identity() { let mut intent = prepared_intent(); intent.affected_targets[0].old_backend_identity = Some([0; 32]); assert!(matches!( intent.validate(), Err(TierMutationIntentError::Corrupt("target old backend identity is empty")) )); } #[test] fn intent_allows_initial_config_create_without_old_etag() { let mut intent = prepared_intent(); intent.kind = TierMutationIntentKind::Add; intent.old_config_etag = None; intent.affected_targets[0].old_backend_identity = None; let encoded = intent.encode().expect("initial create intent should encode"); let decoded = TierMutationIntent::decode(intent.mutation_id, &encoded).expect("initial create intent should decode"); assert_eq!(decoded.old_config_etag, None); assert_eq!(decoded.kind, TierMutationIntentKind::Add); } #[test] fn intent_record_object_name_is_canonical_and_reversible() { let mutation_id = Uuid::parse_str("12345678-1234-5678-9abc-def012345678").expect("test uuid should parse"); let object = tier_mutation_intent_record_object_name(mutation_id).expect("record object should build"); let parsed = tier_mutation_intent_id_from_record_object_name(&object).expect("record object should parse"); assert_eq!(object, "tier/mutation-intents/records/12/34/12345678123456789abcdef012345678.json"); assert_eq!(parsed, mutation_id); } #[test] fn intent_record_object_name_rejects_nil_and_malformed_paths() { assert!(matches!( tier_mutation_intent_record_object_name(Uuid::nil()), Err(TierMutationIntentError::Corrupt("mutation_id is nil")) )); assert!(matches!( tier_mutation_intent_id_from_record_object_name("tier/mutation-intents/records/12/34/not-a-uuid.json"), Err(TierMutationIntentError::Corrupt("intent record path has invalid mutation id")) )); assert!(matches!( tier_mutation_intent_id_from_record_object_name( "tier/mutation-intents/records/12/34/12345678123456789abcdef012345678" ), Err(TierMutationIntentError::Corrupt("intent record path has wrong suffix")) )); assert!(matches!( tier_mutation_intent_id_from_record_object_name( "tier/mutation-intents/records/12/35/12345678123456789abcdef012345678.json" ), Err(TierMutationIntentError::Corrupt("intent record path shard does not match mutation id")) )); assert!(matches!( tier_mutation_intent_id_from_record_object_name( "tier/mutation-intents/records/12/34/12345678123456789abcdef012345678/extra.json" ), Err(TierMutationIntentError::Corrupt("intent record path is not canonical")) )); assert!(matches!( tier_mutation_intent_id_from_record_object_name( "tier/mutation-intents/records/12/34/12345678123456789ABCDEF012345678.json" ), Err(TierMutationIntentError::Corrupt("intent record path has invalid mutation id")) )); } #[test] fn intent_decode_rejects_record_key_mismatch() { let intent = prepared_intent(); let encoded = intent.encode().expect("intent should encode"); let wrong_id = Uuid::new_v4(); assert!(matches!( TierMutationIntent::decode(wrong_id, &encoded), Err(TierMutationIntentError::Corrupt("mutation_id does not match intent key")) )); } #[test] fn intent_validation_rejects_target_shape_mismatch() { let mut add = prepared_intent(); add.kind = TierMutationIntentKind::Add; assert!(matches!( add.validate(), Err(TierMutationIntentError::Corrupt( "target backend identity shape does not match mutation kind" )) )); let mut remove = prepared_intent(); remove.kind = TierMutationIntentKind::Remove; assert!(matches!( remove.validate(), Err(TierMutationIntentError::Corrupt( "target backend identity shape does not match mutation kind" )) )); let mut clear_without_etag = prepared_intent(); clear_without_etag.kind = TierMutationIntentKind::Clear; clear_without_etag.old_config_etag = None; clear_without_etag.affected_targets[0].new_backend_identity = None; assert!(matches!( clear_without_etag.validate(), Err(TierMutationIntentError::Corrupt("old config etag is required for mutation kind")) )); } }