// 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 super::replication_config_store::ReplicationConfigStore; use super::replication_error_boundary::{Error as EcstoreError, is_err_object_not_found, is_err_version_not_found}; use super::replication_filemeta_boundary::{ MrfOpKind, MrfReplicateEntry, REPLICATE_HEAL_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicatedTargetInfo, ReplicationStatusType, ReplicationType, ReplicationWorkerOperation, ResyncDecision, replicate_decision_for_admitted_targets, replication_statuses_map, version_purge_statuses_map, }; use super::replication_lock_boundary::ReplicationLockTiming; use super::replication_logging::{EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REPLICATION}; use super::replication_metadata_boundary::ReplicationMetadataStore; use super::replication_object_config::{ReplicationConfig, check_replicate_delete_strict, must_replicate}; use super::replication_object_decision_boundary::MustReplicateOptions; use super::replication_queue_boundary::{ DeletedObjectReplicationInfo, LARGE_WORKER_COUNT, ReplicationBackpressureRecommendation, ReplicationBackpressureState, ReplicationBatchAdmission, ReplicationHealQueueAction, ReplicationHealQueueResult, ReplicationHealResyncDeletes, ReplicationOperation, ReplicationPoolOpts, ReplicationPriority, ReplicationQueueAdmission, ReplicationWorkerQueue, WORKER_MAX_LIMIT, initial_worker_counts, large_worker_backpressure_resize, mrf_worker_size_to_count, replication_backpressure_recommendation, replication_heal_queue_action, resized_worker_counts, should_queue_large_object, worker_queue_for_replication_type, }; use super::replication_resync_boundary::ResyncStatusType; use super::replication_resync_boundary::{ BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus, decode_mrf_file, decode_resync_file, encode_mrf_file, should_auto_resume_resync, }; use super::replication_resyncer::{ ReplicationResyncer, get_heal_replicate_object_info, replicate_delete, replicate_delete_with_outcome, replicate_object, replicate_object_with_outcome, save_resync_status, }; use super::replication_state::ReplicationStats; use super::replication_storage_boundary::{ HTTPPreconditions, ObjectInfo, ObjectOptions, ObjectToDelete, ReplicationDeletedObject, ReplicationObjectIO, ReplicationStorage, }; use super::replication_target_boundary::{ReplicationTargetStore, replication_object_is_ssec_encrypted}; use super::replication_versioning_boundary::ReplicationVersioningStore; use super::runtime_boundary as runtime_sources; use futures_util::stream::{self, StreamExt}; use metrics::{counter, histogram}; use rustfs_utils::hash::HashAlgorithm; use rustfs_utils::http::{SUFFIX_REPLICATION_TIMESTAMP, get_str}; use std::collections::HashMap; use std::collections::hash_map::Entry; use std::sync::Arc; use std::sync::LazyLock; use std::sync::RwLock as StdRwLock; use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; use std::time::Instant; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tokio::sync::Mutex; use tokio::sync::Notify; use tokio::sync::RwLock; use tokio::sync::mpsc; use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Sender; use tokio::task::JoinHandle; use tokio::time::Duration; use tokio_util::sync::CancellationToken; use tracing::{debug, info, instrument, warn}; const EVENT_REPLICATION_WORKER_RESIZE_SKIPPED: &str = "replication_worker_resize_skipped"; const EVENT_REPLICATION_WORKER_RESIZED: &str = "replication_worker_resized"; const EVENT_REPLICATION_BACKPRESSURE: &str = "replication_backpressure"; const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_skipped"; const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered"; const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable"; const DELETE_BATCH_ADMISSION_CONCURRENCY: usize = 16; const METRIC_DELETE_BATCH_ITEMS_TOTAL: &str = "rustfs_replication_delete_batch_items_total"; const METRIC_DELETE_BATCH_SIZE: &str = "rustfs_replication_delete_batch_size"; const MRF_CORRUPT_FILE_PREFIX: &str = "config/replication/mrf.corrupt"; const MRF_PENDING_CAP: usize = 200_000; const MRF_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(100); const MRF_RETRY_MAX_DELAY: Duration = Duration::from_secs(5); #[derive(Debug, Default)] pub struct DurableMrfBacklog { pub available: bool, pub entries: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DurableMrfBucketBacklog { pub bucket: String, pub count: u64, pub bytes: u64, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DurableMrfTargetBacklog { pub bucket: String, pub target_arn: String, pub count: u64, pub bytes: u64, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DurableMrfBacklogSummary { pub available: bool, pub buckets: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq)] struct DurableMrfBacklogSnapshot { summary: DurableMrfBacklogSummary, targets: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MrfBucketBacklogObservability { pub bucket: String, pub pending_count: u64, pub pending_bytes: u64, pub dropped_count: u64, pub missed_count: u64, pub flush_failure_count: u64, pub last_flush_duration_millis: u64, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MrfBacklogObservabilitySummary { pub buckets: Vec, } static DURABLE_MRF_BACKLOG_SUMMARY: LazyLock> = LazyLock::new(|| StdRwLock::new(DurableMrfBacklogSummary::default())); static DURABLE_MRF_TARGET_BACKLOG: LazyLock>> = LazyLock::new(|| StdRwLock::new(Vec::new())); static MRF_BACKLOG_OBSERVABILITY: LazyLock> = LazyLock::new(|| StdRwLock::new(MrfBacklogObservabilityTracker::default())); fn should_replay_force_delete_intent(entry: &MrfReplicateEntry) -> bool { entry.force_delete_id.is_some() && entry.force_delete_local_commit && !entry.target_arns.is_empty() } #[derive(Debug, Clone, Default)] struct DurableMrfBacklogTracker { available: bool, buckets: HashMap, targets: HashMap<(String, String), DurableMrfTargetBacklog>, } impl DurableMrfBacklogTracker { fn add_entry(&mut self, entry: &MrfReplicateEntry) { let Ok(size) = u64::try_from(entry.size) else { self.available = false; self.buckets.clear(); self.targets.clear(); return; }; if !self.available { return; } let bucket_name = entry.bucket.clone(); let bucket = match self.buckets.entry(bucket_name) { Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => { let bucket = entry.key().clone(); entry.insert(DurableMrfBucketBacklog { bucket, ..Default::default() }) } }; bucket.count = bucket.count.saturating_add(1); bucket.bytes = bucket.bytes.saturating_add(size); for target_arn in &entry.target_arns { if target_arn.is_empty() { continue; } let key = (entry.bucket.clone(), target_arn.clone()); let target = match self.targets.entry(key) { Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => { let (bucket, target_arn) = entry.key().clone(); entry.insert(DurableMrfTargetBacklog { bucket, target_arn, ..Default::default() }) } }; target.count = target.count.saturating_add(1); target.bytes = target.bytes.saturating_add(size); } } fn into_snapshot(self) -> DurableMrfBacklogSnapshot { if !self.available { return DurableMrfBacklogSnapshot::default(); } DurableMrfBacklogSnapshot { summary: DurableMrfBacklogSummary { available: true, buckets: self.buckets.into_values().collect(), }, targets: self.targets.into_values().collect(), } } } fn durable_mrf_backlog_tracker_from_entries(entries: &[MrfReplicateEntry]) -> DurableMrfBacklogTracker { let mut tracker = DurableMrfBacklogTracker { available: true, ..Default::default() }; for entry in entries { tracker.add_entry(entry); } tracker } fn move_staged_mrf_entries(pending: &mut Vec, staged: &mut Vec) -> Vec { let pending_capacity = MRF_PENDING_CAP.saturating_sub(pending.len()); let mut staged_entries = std::mem::take(staged); let capped_batch = staged_entries.split_off(pending_capacity.min(staged_entries.len())); pending.append(&mut staged_entries); capped_batch } #[derive(Debug)] struct PendingMrfAppend { digest: [u8; 32], entry_count: usize, } fn mrf_payload_digest(data: &[u8]) -> [u8; 32] { let encoded = HashAlgorithm::SHA256.hash_encode(data); let mut digest = [0; 32]; digest.copy_from_slice(encoded.as_ref()); digest } fn add_durable_mrf_suffix(tracker: &mut DurableMrfBacklogTracker, entries: &[MrfReplicateEntry], count: usize) { let start = entries.len().saturating_sub(count); for entry in &entries[start..] { tracker.add_entry(entry); } } #[derive(Debug, Clone, Default)] struct MrfBacklogObservabilityTracker { buckets: HashMap, } impl MrfBacklogObservabilityTracker { fn bucket_mut(&mut self, bucket_name: &str) -> &mut MrfBucketBacklogObservability { match self.buckets.entry(bucket_name.to_string()) { Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => entry.insert(MrfBucketBacklogObservability { bucket: bucket_name.to_string(), ..Default::default() }), } } fn add_pending(&mut self, entry: &MrfReplicateEntry) { let Ok(size) = u64::try_from(entry.size) else { return; }; let bucket = self.bucket_mut(&entry.bucket); bucket.pending_count = bucket.pending_count.saturating_add(1); bucket.pending_bytes = bucket.pending_bytes.saturating_add(size); } fn flush_pending_entries<'a>(&mut self, entries: impl IntoIterator, duration_millis: u64) { for entry in entries { let Ok(size) = u64::try_from(entry.size) else { continue; }; let bucket = self.bucket_mut(&entry.bucket); bucket.pending_count = bucket.pending_count.saturating_sub(1); bucket.pending_bytes = bucket.pending_bytes.saturating_sub(size); bucket.last_flush_duration_millis = duration_millis; } } #[cfg(test)] fn record_drop(&mut self, entry: &MrfReplicateEntry) { let bucket = self.bucket_mut(&entry.bucket); bucket.dropped_count = bucket.dropped_count.saturating_add(1); } fn record_missed(&mut self, bucket_name: &str) { let bucket = self.bucket_mut(bucket_name); bucket.missed_count = bucket.missed_count.saturating_add(1); } fn record_flush_failure(&mut self, duration_millis: u64) { for bucket in self.buckets.values_mut().filter(|bucket| bucket.pending_count > 0) { bucket.flush_failure_count = bucket.flush_failure_count.saturating_add(1); bucket.last_flush_duration_millis = duration_millis; } } fn snapshot(&self) -> MrfBacklogObservabilitySummary { MrfBacklogObservabilitySummary { buckets: self.buckets.values().cloned().collect(), } } } fn durable_mrf_backlog_summary_from_entries<'a>( entries: impl IntoIterator, ) -> DurableMrfBacklogSnapshot { let mut tracker = DurableMrfBacklogTracker { available: true, ..Default::default() }; for entry in entries { tracker.add_entry(entry); } tracker.into_snapshot() } #[cfg(test)] fn durable_mrf_backlog_summary_from_sizes(entries: I) -> DurableMrfBacklogSnapshot where I: IntoIterator, { let mut tracker = DurableMrfBacklogTracker { available: true, ..Default::default() }; for (bucket_name, entry_size) in entries { tracker.add_entry(&MrfReplicateEntry { bucket: bucket_name, object: String::new(), version_id: None, retry_count: 0, size: entry_size, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }); } tracker.into_snapshot() } fn set_durable_mrf_backlog_snapshot(snapshot: DurableMrfBacklogSnapshot) { match DURABLE_MRF_BACKLOG_SUMMARY.write() { Ok(mut guard) => *guard = snapshot.summary, Err(poisoned) => *poisoned.into_inner() = snapshot.summary, } match DURABLE_MRF_TARGET_BACKLOG.write() { Ok(mut guard) => *guard = snapshot.targets, Err(poisoned) => *poisoned.into_inner() = snapshot.targets, } } fn set_durable_mrf_backlog_summary(summary: DurableMrfBacklogSummary) { set_durable_mrf_backlog_snapshot(DurableMrfBacklogSnapshot { summary, targets: Vec::new(), }); } pub fn durable_mrf_backlog_summary_snapshot() -> DurableMrfBacklogSummary { match DURABLE_MRF_BACKLOG_SUMMARY.read() { Ok(guard) => guard.clone(), Err(poisoned) => poisoned.into_inner().clone(), } } pub fn durable_mrf_target_backlog_snapshot() -> Vec { match DURABLE_MRF_TARGET_BACKLOG.read() { Ok(guard) => guard.clone(), Err(poisoned) => poisoned.into_inner().clone(), } } pub fn mrf_backlog_observability_snapshot() -> MrfBacklogObservabilitySummary { match MRF_BACKLOG_OBSERVABILITY.read() { Ok(guard) => guard.snapshot(), Err(poisoned) => poisoned.into_inner().snapshot(), } } fn update_mrf_backlog_observability(mut update: impl FnMut(&mut MrfBacklogObservabilityTracker)) { match MRF_BACKLOG_OBSERVABILITY.write() { Ok(mut guard) => update(&mut guard), Err(poisoned) => update(&mut poisoned.into_inner()), } } fn observe_mrf_pending(entry: &MrfReplicateEntry) { update_mrf_backlog_observability(|tracker| tracker.add_pending(entry)); } fn observe_mrf_pending_flushed(entries: &[MrfReplicateEntry], duration_millis: u64) { update_mrf_backlog_observability(|tracker| tracker.flush_pending_entries(entries, duration_millis)); } fn observe_mrf_missed(bucket: &str) { update_mrf_backlog_observability(|tracker| tracker.record_missed(bucket)); } fn observe_mrf_flush_failure(duration_millis: u64) { update_mrf_backlog_observability(|tracker| tracker.record_flush_failure(duration_millis)); } fn durable_mrf_backlog_from_read(result: Result, EcstoreError>) -> DurableMrfBacklog { match result { Ok(data) => match decode_mrf_file(&data) { Ok(entries) if entries.iter().all(|entry| entry.size >= 0) => DurableMrfBacklog { available: true, entries, }, Ok(_) | Err(_) => DurableMrfBacklog::default(), }, Err(EcstoreError::ConfigNotFound) => DurableMrfBacklog { available: true, entries: Vec::new(), }, Err(_) => DurableMrfBacklog::default(), } } pub async fn read_durable_mrf_backlog(storage: Arc) -> DurableMrfBacklog { durable_mrf_backlog_from_read(ReplicationConfigStore::read(storage, ReplicationMetadataStore::MRF_REPLICATION_FILE).await) } pub async fn persist_force_delete_intent( storage: Arc, mut entry: MrfReplicateEntry, ) -> Result<(), EcstoreError> { entry.force_delete_local_commit = false; update_force_delete_intents(storage, move |entries, _exists| { if entries .iter() .any(|existing| existing.force_delete_id == entry.force_delete_id) { return Ok(false); } entries.push(entry.clone()); Ok(true) }) .await } pub async fn commit_force_delete_intent( storage: Arc, operation_id: uuid::Uuid, ) -> Result<(), EcstoreError> { update_force_delete_intents(storage, move |entries, exists| { if !exists { return Err(EcstoreError::ConfigNotFound); } let Some(entry) = entries.iter_mut().find(|entry| entry.force_delete_id == Some(operation_id)) else { return Err(EcstoreError::ConfigNotFound); }; if entry.force_delete_local_commit { return Ok(false); } entry.force_delete_local_commit = true; Ok(true) }) .await } pub async fn complete_force_delete_intent( storage: Arc, operation_id: uuid::Uuid, ) -> Result<(), EcstoreError> { update_force_delete_intents(storage, move |entries, exists| { if !exists { return Ok(false); } let original_len = entries.len(); entries.retain(|entry| entry.force_delete_id != Some(operation_id)); Ok(entries.len() != original_len) }) .await } const FORCE_DELETE_INTENT_CAS_RETRIES: usize = 3; fn is_retryable_force_delete_error(error: &EcstoreError) -> bool { matches!(error, EcstoreError::PreconditionFailed) || error.to_string().contains("force-delete journal lock lost") } async fn update_force_delete_intents(storage: Arc, mut update: F) -> Result<(), EcstoreError> where S: ReplicationStorage, F: FnMut(&mut Vec, bool) -> Result, { let file = ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE; for attempt in 0..=FORCE_DELETE_INTENT_CAS_RETRIES { let result = { let lock = storage .new_ns_lock( ReplicationMetadataStore::rustfs_meta_bucket(), ReplicationMetadataStore::FORCE_DELETE_REPLICATION_TRANSACTION_LOCK, ) .await?; // Lock order is transaction namespace lock -> force-delete journal object lock. // Keep the transaction guard alive through the conditional write so legacy // writers cannot interleave a read-modify-write transition within this process. let guard = lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await?; let (mut entries, preconditions, exists) = read_force_delete_intents(storage.clone(), file).await?; if !update(&mut entries, exists)? { return Ok(()); } save_force_delete_intents(storage.clone(), file, &guard, entries, preconditions).await }; match result { Err(error) if is_retryable_force_delete_error(&error) && attempt < FORCE_DELETE_INTENT_CAS_RETRIES => { tokio::time::sleep(Duration::from_millis(25)).await; } result => return result, } } Err(EcstoreError::other("force-delete journal update retries exhausted")) } async fn read_force_delete_intents( storage: Arc, file: &str, ) -> Result<(Vec, HTTPPreconditions, bool), EcstoreError> { match ReplicationConfigStore::read_no_lock_with_metadata(storage, file).await { Ok((data, object_info)) => { let etag = object_info .etag .filter(|etag| !etag.trim().is_empty()) .ok_or_else(|| EcstoreError::other("force-delete journal has no ETag for conditional update"))?; Ok(( decode_mrf_file(&data)?, HTTPPreconditions { if_match: Some(etag), ..Default::default() }, true, )) } Err(EcstoreError::ConfigNotFound) => Ok(( Vec::new(), HTTPPreconditions { if_none_match: Some("*".to_string()), ..Default::default() }, false, )), Err(err) => Err(err), } } async fn save_force_delete_intents( storage: Arc, file: &str, guard: &rustfs_lock::NamespaceLockGuard, entries: Vec, preconditions: HTTPPreconditions, ) -> Result<(), EcstoreError> { ensure_force_delete_journal_lock_held(guard.is_lock_lost())?; ReplicationConfigStore::save_conditional(storage, file, encode_mrf_file(&entries)?, preconditions).await } fn ensure_force_delete_journal_lock_held(lock_lost: bool) -> Result<(), EcstoreError> { if lock_lost { return Err(EcstoreError::other("force-delete journal lock lost before conditional update")); } Ok(()) } async fn write_mrf_journal_snapshot( storage: Arc, desired: &[MrfReplicateEntry], ) -> Result<(), EcstoreError> { let file = ReplicationMetadataStore::MRF_REPLICATION_FILE; let mut merged = desired.to_vec(); let mut saw_conflict = false; for _attempt in 0..=FORCE_DELETE_INTENT_CAS_RETRIES { let lock = storage .new_ns_lock(ReplicationMetadataStore::rustfs_meta_bucket(), file) .await?; let guard = lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await?; let current = ReplicationConfigStore::read_no_lock_with_metadata(storage.clone(), file).await; let etag = match current { Ok((data, object_info)) => { if saw_conflict { let current = decode_mrf_file(&data)?; for entry in current { if !merged.iter().any(|existing| mrf_entries_same(existing, &entry)) { merged.push(entry); } } } object_info.etag } Err(EcstoreError::ConfigNotFound) => None, Err(err) => return Err(err), }; if guard.is_lock_lost() { return Err(EcstoreError::other("MRF journal namespace lock was lost before commit")); } let preconditions = match etag.filter(|value| !value.trim().is_empty()) { Some(etag) => HTTPPreconditions { if_match: Some(etag), ..Default::default() }, None => HTTPPreconditions { if_none_match: Some("*".to_string()), ..Default::default() }, }; let data = if merged.is_empty() { Vec::new() } else { encode_mrf_file(&merged)? }; match ReplicationConfigStore::save_conditional(storage.clone(), file, data, preconditions).await { Ok(()) => return Ok(()), Err(EcstoreError::PreconditionFailed) => saw_conflict = true, Err(err) => return Err(err), } } Err(EcstoreError::PreconditionFailed) } fn mrf_entries_same(left: &MrfReplicateEntry, right: &MrfReplicateEntry) -> bool { left.bucket == right.bucket && left.object == right.object && left.version_id == right.version_id && left.op == right.op && left.target_arns == right.target_arns && left.force_delete_id == right.force_delete_id && left.delete_marker_version_id == right.delete_marker_version_id } #[derive(Debug, thiserror::Error)] #[error("replication resync {active_resync_id} is already active for {bucket}/{arn}")] struct ResyncActiveConflictError { bucket: String, arn: String, active_resync_id: String, } pub fn resync_start_conflict_id(error: &EcstoreError) -> Option<&str> { match error { EcstoreError::Io(io_error) => io_error .get_ref()? .downcast_ref::() .map(|conflict| conflict.active_resync_id.as_str()), _ => None, } } /// Main replication pool structure #[derive(Debug)] pub struct ReplicationPool { // Atomic counters for active workers active_workers: Arc, active_lrg_workers: Arc, active_mrf_workers: Arc, storage: Arc, // Configuration priority: RwLock, max_workers: RwLock, max_l_workers: RwLock, // Statistics stats: Arc, // Worker channels workers: RwLock>>, lrg_workers: RwLock>>, // MRF (Most Recent Failures) channels mrf_replica_tx: Sender, // Shared among N MRF workers; Arc allows spawning more than one worker. mrf_replica_rx: Arc>>, mrf_save_tx: Sender, mrf_save_rx: Mutex>>, mrf_recovery_complete: Arc, mrf_recovery_result: Arc>>>, // Control channels mrf_worker_kill_tx: Sender<()>, mrf_stop_tx: Sender<()>, // Worker size tracking mrf_worker_size: AtomicI32, // Task handles for cleanup task_handles: Mutex>>, // Replication resyncer for handling bucket resync operations resyncer: Arc, } impl ReplicationPool { /// Creates a new replication pool with specified options pub async fn new(opts: ReplicationPoolOpts, stats: Arc, storage: Arc) -> Arc { let worker_counts = initial_worker_counts(&opts); let max_workers = opts.max_workers.unwrap_or(WORKER_MAX_LIMIT); let max_l_workers = opts.max_l_workers.unwrap_or(LARGE_WORKER_COUNT); // Create MRF channels let (mrf_replica_tx, mrf_replica_rx) = mpsc::channel(100000); let (mrf_save_tx, mrf_save_rx) = mpsc::channel(100000); let (mrf_worker_kill_tx, _mrf_worker_kill_rx) = mpsc::channel(worker_counts.mrf_workers); let (mrf_stop_tx, _mrf_stop_rx) = mpsc::channel(1); let pool = Arc::new(Self { active_workers: Arc::new(AtomicI32::new(0)), active_lrg_workers: Arc::new(AtomicI32::new(0)), active_mrf_workers: Arc::new(AtomicI32::new(0)), priority: RwLock::new(opts.priority), max_workers: RwLock::new(max_workers), max_l_workers: RwLock::new(max_l_workers), stats, storage, workers: RwLock::new(Vec::new()), lrg_workers: RwLock::new(Vec::new()), mrf_replica_tx, mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)), mrf_save_tx, mrf_save_rx: Mutex::new(Some(mrf_save_rx)), mrf_recovery_complete: Arc::new(Notify::new()), mrf_recovery_result: Arc::new(Mutex::new(None)), mrf_worker_kill_tx, mrf_stop_tx, mrf_worker_size: AtomicI32::new(0), task_handles: Mutex::new(Vec::new()), resyncer: Arc::new(ReplicationResyncer::new().await), }); // Initialize workers pool.resize_lrg_workers(max_l_workers, 0).await; pool.resize_workers(worker_counts.workers, 0).await; pool.resize_failed_workers(worker_counts.mrf_workers_i32()).await; // Start background tasks pool.start_mrf_persister().await; pool.start_mrf_processor().await; pool.start_force_delete_processor().await; pool } /// Returns the number of active workers handling replication traffic pub fn active_workers(&self) -> i32 { self.active_workers.load(Ordering::SeqCst) } /// Returns the number of active workers handling replication failures pub fn active_mrf_workers(&self) -> i32 { self.active_mrf_workers.load(Ordering::SeqCst) } /// Returns the number of active workers handling traffic > 128MiB object size pub fn active_lrg_workers(&self) -> i32 { self.active_lrg_workers.load(Ordering::SeqCst) } /// Resizes the large workers pool pub async fn resize_lrg_workers(&self, n: usize, check_old: usize) { let mut lrg_workers = self.lrg_workers.write().await; if (check_old > 0 && lrg_workers.len() != check_old) || n == lrg_workers.len() || n < 1 { return; } // Add workers if needed while lrg_workers.len() < n { let (tx, rx) = mpsc::channel(100000); lrg_workers.push(tx); let active_counter = self.active_lrg_workers.clone(); let storage = self.storage.clone(); let stats = self.stats.clone(); let handle = tokio::spawn(async move { let mut rx = rx; while let Some(operation) = rx.recv().await { let _active = ActiveWorkerGuard::new(active_counter.clone()); process_replication_operation(operation, stats.clone(), storage.clone()).await; } }); self.task_handles.lock().await.push(handle); } // Remove workers if needed while lrg_workers.len() > n { if let Some(worker) = lrg_workers.pop() { drop(worker); // Closing the channel will terminate the worker } } } /// Resizes the regular workers pool pub async fn resize_workers(&self, n: usize, check_old: usize) { let mut workers = self.workers.write().await; if (check_old > 0 && workers.len() != check_old) || n == workers.len() || n < 1 { debug!( event = EVENT_REPLICATION_WORKER_RESIZE_SKIPPED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, check_old_mismatch = check_old > 0 && workers.len() != check_old, same_size = n == workers.len(), invalid_target_size = n < 1, current_workers = workers.len(), target_workers = n, "Skipped replication worker resize" ); return; } // Add workers if needed if workers.len() < n { info!( event = EVENT_REPLICATION_WORKER_RESIZED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, action = "increase", from_workers = workers.len(), to_workers = n, "Resized replication workers" ); } while workers.len() < n { let (tx, rx) = mpsc::channel(10000); workers.push(tx); let active_counter = self.active_workers.clone(); let stats = self.stats.clone(); let storage = self.storage.clone(); let handle = tokio::spawn(async move { let mut rx = rx; while let Some(operation) = rx.recv().await { let _active = ActiveWorkerGuard::new(active_counter.clone()); process_replication_operation(operation, stats.clone(), storage.clone()).await; } }); self.task_handles.lock().await.push(handle); } // Remove workers if needed if workers.len() > n { info!( event = EVENT_REPLICATION_WORKER_RESIZED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, action = "decrease", from_workers = workers.len(), to_workers = n, "Resized replication workers" ); } while workers.len() > n { if let Some(worker) = workers.pop() { drop(worker); // Closing the channel will terminate the worker } } } /// Resizes the failed workers pool pub async fn resize_failed_workers(&self, n: i32) { // Spawn workers up to n. Each worker shares the receiver via Arc>. // The mutex is held only while calling recv() — released before processing — so // all workers process entries concurrently (the dequeue step is serialised but // the replication I/O is not). while self.mrf_worker_size.load(Ordering::SeqCst) < n { self.mrf_worker_size.fetch_add(1, Ordering::SeqCst); let active_counter = self.active_mrf_workers.clone(); let stats = self.stats.clone(); let storage = self.storage.clone(); let mrf_rx = Arc::clone(&self.mrf_replica_rx); let handle = tokio::spawn(async move { loop { let operation = { mrf_rx.lock().await.recv().await }; let Some(operation) = operation else { break }; let _active = ActiveWorkerGuard::new(active_counter.clone()); process_replication_operation(operation, stats.clone(), storage.clone()).await; } }); self.task_handles.lock().await.push(handle); } // Remove workers if needed while self.mrf_worker_size.load(Ordering::SeqCst) > n { self.mrf_worker_size.fetch_sub(1, Ordering::SeqCst); let _ = self.mrf_worker_kill_tx.try_send(()); } } /// Resizes worker priority and counts pub async fn resize_worker_priority( &self, pri: ReplicationPriority, max_workers: Option, max_l_workers: Option, ) { let current_workers = self.workers.read().await.len(); let current_mrf = mrf_worker_size_to_count(self.mrf_worker_size.load(Ordering::SeqCst)); let worker_counts = resized_worker_counts(&pri, max_workers, current_workers, current_mrf); if let Some(max_w) = max_workers { *self.max_workers.write().await = max_w; } let max_l_workers_val = max_l_workers.unwrap_or(LARGE_WORKER_COUNT); *self.max_l_workers.write().await = max_l_workers_val; *self.priority.write().await = pri; self.resize_workers(worker_counts.workers, 0).await; self.resize_failed_workers(worker_counts.mrf_workers_i32()).await; self.resize_lrg_workers(max_l_workers_val, 0).await; } /// Gets a worker channel deterministically based on bucket and object names async fn get_worker_ch(&self, bucket: &str, object: &str, _size: i64) -> Option> { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); format!("{bucket}{object}").hash(&mut hasher); let hash = hasher.finish(); let workers = self.workers.read().await; if workers.is_empty() { return None; } let index = (hash as usize) % workers.len(); workers.get(index).cloned() } async fn worker_queue_channel( &self, op_type: &ReplicationType, bucket: &str, object: &str, size: i64, ) -> Option> { match worker_queue_for_replication_type(op_type) { ReplicationWorkerQueue::Mrf => Some(self.mrf_replica_tx.clone()), ReplicationWorkerQueue::Regular => self.get_worker_ch(bucket, object, size).await, } } async fn apply_queue_backpressure(&self, queue_type: &'static str, include_mrf_workers: bool, message: &'static str) { let priority = self.priority.read().await.clone(); let max_workers = *self.max_workers.read().await; let current_workers = self.workers.read().await.len(); let current_mrf_workers = self.mrf_worker_size.load(Ordering::SeqCst); let recommendation = replication_backpressure_recommendation( &priority, ReplicationBackpressureState { current_workers, active_workers: self.active_workers(), current_mrf_workers, active_mrf_workers: self.active_mrf_workers(), max_workers, include_mrf_workers, }, ); match recommendation { ReplicationBackpressureRecommendation::KeepFast => { debug!( event = EVENT_REPLICATION_BACKPRESSURE, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, queue_type, priority = "fast", recommendation = "none", "{message}" ); } ReplicationBackpressureRecommendation::SetPriorityAuto => { debug!( event = EVENT_REPLICATION_BACKPRESSURE, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, queue_type, priority = "slow", recommendation = "set_priority_auto", "{message}" ); } ReplicationBackpressureRecommendation::Resize(resize) => { if let Some(regular_workers) = resize.regular_workers { self.resize_workers(regular_workers.new_count, regular_workers.existing_count) .await; } if let Some(mrf_workers) = resize.mrf_workers { self.resize_failed_workers(mrf_workers).await; } } ReplicationBackpressureRecommendation::Noop => {} } } /// Queues a replica task pub async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission { let target_arns = ri.dsc.replicate_target_arns(); // If object is large, queue it to a static set of large workers if should_queue_large_object(ri.size) { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); format!("{}{}", ri.bucket, ri.name).hash(&mut hasher); let hash = hasher.finish(); let lrg_workers = self.lrg_workers.read().await; if !lrg_workers.is_empty() { let index = (hash as usize) % lrg_workers.len(); if let Some(worker) = lrg_workers.get(index) { self.stats.inc_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type); self.stats.inc_target_q(&ri.bucket, &target_arns, ri.size); if worker.try_send(ReplicationOperation::Object(Box::new(ri.clone()))).is_ok() { return ReplicationQueueAdmission::Queued; } self.stats.dec_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type); self.stats.dec_target_q(&ri.bucket, &target_arns, ri.size); // Try to add more workers if possible let max_l_workers = *self.max_l_workers.read().await; let existing = lrg_workers.len(); let resize = large_worker_backpressure_resize(existing, self.active_lrg_workers(), max_l_workers); drop(lrg_workers); // Queue to MRF if worker is busy. let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "large_object").await; if let Some(resize) = resize { self.resize_lrg_workers(resize.new_count, resize.existing_count).await; } return admission; } } return ReplicationQueueAdmission::Missed; } // Handle regular sized objects let ch = self.worker_queue_channel(&ri.op_type, &ri.bucket, &ri.name, ri.size).await; let Some(channel) = ch else { return ReplicationQueueAdmission::Missed; }; self.stats.inc_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type); self.stats.inc_target_q(&ri.bucket, &target_arns, ri.size); if channel.try_send(ReplicationOperation::Object(Box::new(ri.clone()))).is_ok() { return ReplicationQueueAdmission::Queued; } self.stats.dec_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type); self.stats.dec_target_q(&ri.bucket, &target_arns, ri.size); // Queue to MRF if all workers are busy. let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "object").await; // Try to scale up workers based on priority self.apply_queue_backpressure("object", true, "Replication queue is backpressured") .await; admission } /// Queues a replica delete task pub async fn queue_replica_delete_task(&self, doi: DeletedObjectReplicationInfo) -> ReplicationQueueAdmission { let target_arns = doi.admitted_target_arns(); let ch = self .worker_queue_channel(&doi.op_type, &doi.bucket, &doi.delete_object.object_name, 0) .await; let Some(channel) = ch else { return ReplicationQueueAdmission::Missed; }; self.stats.inc_q(&doi.bucket, 0, true, doi.op_type); self.stats.inc_target_q(&doi.bucket, &target_arns, 0); if channel.try_send(ReplicationOperation::Delete(Box::new(doi.clone()))).is_ok() { return ReplicationQueueAdmission::Queued; } self.stats.dec_q(&doi.bucket, 0, true, doi.op_type); self.stats.dec_target_q(&doi.bucket, &target_arns, 0); let admission = self.queue_mrf_save_admission(doi.to_mrf_entry(), "delete").await; self.apply_queue_backpressure("delete", false, "Replication delete queue is backpressured") .await; admission } /// Queues a DeleteObjects replication tail with a fixed concurrency window. /// Each item retains the existing regular-worker to MRF fallback contract. pub async fn queue_replica_delete_batch(&self, deletes: &[DeletedObjectReplicationInfo]) -> ReplicationBatchAdmission { let mut summary = ReplicationBatchAdmission::default(); let mut admissions = stream::iter( deletes .iter() .cloned() .map(|delete| async move { self.queue_replica_delete_task(delete).await }), ) .buffer_unordered(DELETE_BATCH_ADMISSION_CONCURRENCY); while let Some(admission) = admissions.next().await { summary.record(admission); } let outcome = summary.outcome(); let total = u64::try_from(summary.total).unwrap_or(u64::MAX); let queued = u64::try_from(summary.queued).unwrap_or(u64::MAX); let missed = u64::try_from(summary.missed).unwrap_or(u64::MAX); histogram!(METRIC_DELETE_BATCH_SIZE).record(total as f64); counter!(METRIC_DELETE_BATCH_ITEMS_TOTAL, "outcome" => outcome, "state" => "queued").increment(queued); counter!(METRIC_DELETE_BATCH_ITEMS_TOTAL, "outcome" => outcome, "state" => "missed").increment(missed); debug!( event = "replication_delete_batch_admission", batch_size = summary.total, queued = summary.queued, missed = summary.missed, outcome, "Admitted DeleteObjects replication batch" ); summary } /// Queues an MRF save operation async fn queue_mrf_save(&self, entry: MrfReplicateEntry) { let _ = self.queue_mrf_save_admission(entry, "mrf_worker").await; } async fn queue_mrf_save_admission(&self, entry: MrfReplicateEntry, queue_type: &'static str) -> ReplicationQueueAdmission { let bucket = entry.bucket.clone(); let size = entry.size; let is_delete = matches!(entry.op, MrfOpKind::Delete); let target_arns = entry.target_arns.clone(); let admission = queue_mrf_save_entry(&self.mrf_save_tx, entry, queue_type).await; if admission == ReplicationQueueAdmission::Queued { self.stats.inc_q(&bucket, size, is_delete, ReplicationType::Heal); self.stats.inc_target_q(&bucket, &target_arns, size); } admission } /// Starts the MRF processor — one-shot at startup. /// /// Reads the on-disk MRF file, re-injects admitted entries as Heal operations, and /// rewrites any entries that could not be admitted for a later startup retry. async fn start_mrf_processor(&self) { let storage = self.storage.clone(); let recovery_complete = self.mrf_recovery_complete.clone(); let recovery_result = self.mrf_recovery_result.clone(); let handle = tokio::spawn(async move { let data = match ReplicationConfigStore::read(storage.clone(), ReplicationMetadataStore::MRF_REPLICATION_FILE).await { Ok(d) => d, Err(EcstoreError::ConfigNotFound) => { set_durable_mrf_backlog_summary(DurableMrfBacklogSummary { available: true, buckets: Vec::new(), }); *recovery_result.lock().await = Some(Vec::new()); recovery_complete.notify_one(); return; } Err(e) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %e, "Failed to load MRF recovery file" ); recovery_complete.notify_one(); return; } }; let entries = match decode_mrf_file(&data) { Ok(v) => v, Err(e) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %e, "Failed to decode MRF recovery file — preserving corrupt data" ); quarantine_mrf_file(&storage, &data).await; recovery_complete.notify_one(); return; } }; set_durable_mrf_backlog_snapshot(durable_mrf_backlog_summary_from_entries(&entries)); let total = entries.len(); let mut queued_count = 0usize; let mut retry_entries = Vec::new(); for entry in entries.iter() { let admission = match entry.op { MrfOpKind::Delete => { if should_replay_force_delete_intent(entry) { let Some(operation_id) = entry.force_delete_id else { continue; }; let delete = DeletedObjectReplicationInfo { delete_object: ReplicationDeletedObject { object_name: entry.object.clone(), force_delete: true, force_delete_id: Some(operation_id), force_delete_target_arns: entry.target_arns.clone(), force_delete_generation: entry.force_delete_generation, ..Default::default() }, bucket: entry.bucket.clone(), op_type: ReplicationType::Heal, event_type: REPLICATE_HEAL_DELETE.to_string(), ..Default::default() }; if replicate_delete_with_outcome(delete, storage.clone()).await { ReplicationQueueAdmission::Queued } else { ReplicationQueueAdmission::Missed } } else if entry.force_delete_id.is_some() { ReplicationQueueAdmission::Skipped } else { // Reconstruct a heal delete and re-queue it. We do NOT call // get_object_info here because the delete-marker or version may // already be absent from the local store — that is expected. // // The MRF entry does not persist the replication decision and the // source object is gone, so re-derive the decision from the live // bucket config (mirroring get_heal_replicate_object_info) and set // it on the reconstructed delete. Without this the decision string // is empty and the delete replicates to zero targets — a silent // no-op that leaves replicas diverged (backlog#858 / #799 B9). let versioned = ReplicationVersioningStore::prefix_enabled(&entry.bucket, &entry.object).await; let oi = ObjectInfo { bucket: entry.bucket.clone(), name: entry.object.clone(), version_id: entry.version_id, delete_marker: entry.delete_marker, ..Default::default() }; let dsc = if entry.target_arns.is_empty() { match ReplicationMetadataStore::optional_replication_config(&entry.bucket).await { Ok(None) => continue, Err(_) => { retry_entries.push(entry.clone()); continue; } Ok(Some(_)) => match check_replicate_delete_strict( &entry.bucket, &ObjectToDelete { object_name: entry.object.clone(), version_id: entry.version_id, ..Default::default() }, &oi, &ObjectOptions { versioned, ..Default::default() }, None, ) .await { Ok(dsc) => dsc, Err(_) => { retry_entries.push(entry.clone()); continue; } }, } } else { replicate_decision_for_admitted_targets(&entry.target_arns) }; let mut rstate = oi.replication_state(); rstate.replicate_decision_str = dsc.to_string(); let delete_marker_mtime = entry .delete_marker_mtime .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(i128::from(nanos)).ok()); let dv = DeletedObjectReplicationInfo { delete_object: ReplicationDeletedObject { object_name: entry.object.clone(), version_id: entry.version_id, delete_marker_version_id: entry.delete_marker_version_id, delete_marker: entry.delete_marker, delete_marker_mtime, force_delete: entry.force_delete, replication_state: Some(rstate), ..Default::default() }, bucket: entry.bucket.clone(), op_type: ReplicationType::Heal, event_type: REPLICATE_HEAL_DELETE.to_string(), ..Default::default() }; if replicate_delete_with_outcome(dv, storage.clone()).await { ReplicationQueueAdmission::Queued } else { ReplicationQueueAdmission::Missed } } } MrfOpKind::Object | MrfOpKind::Heal | MrfOpKind::ExistingObject => { let opts = ObjectOptions { version_id: entry.version_id.map(|u| u.to_string()), ..Default::default() }; let oi = match storage.get_object_info(&entry.bucket, &entry.object, &opts).await { Ok(oi) => oi, Err(e) => { debug!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, bucket = %entry.bucket, object = %entry.object, error = %e, "MRF recovery: source object lookup failed" ); if should_retry_mrf_source_lookup(&e) { retry_entries.push(entry.clone()); } continue; } }; if entry.target_arns.is_empty() { // Legacy entries predate target admission persistence. They cannot // be safely attributed, so retain the old live-config fallback. queue_replication_heal(&entry.bucket, oi, entry.retry_count.max(0) as u32).await } else { let dsc = replicate_decision_for_admitted_targets(&entry.target_arns); let mut roi = replicate_object_info_from_object_info(oi, dsc, entry.op.replication_type()); roi.retry_count = entry.retry_count.max(0) as u32; if replicate_object_with_outcome(roi, storage.clone()).await.1 { ReplicationQueueAdmission::Queued } else { ReplicationQueueAdmission::Missed } } } MrfOpKind::Metadata => { let opts = ObjectOptions { version_id: entry.version_id.map(|u| u.to_string()), ..Default::default() }; let oi = match storage.get_object_info(&entry.bucket, &entry.object, &opts).await { Ok(oi) => oi, Err(e) => { debug!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, bucket = %entry.bucket, object = %entry.object, error = %e, "MRF metadata recovery: source object lookup failed" ); if should_retry_mrf_source_lookup(&e) { retry_entries.push(entry.clone()); } continue; } }; if entry.target_arns.is_empty() { queue_replication_metadata(&entry.bucket, oi, entry.retry_count.max(0) as u32).await } else { let dsc = replicate_decision_for_admitted_targets(&entry.target_arns); let mut roi = replicate_object_info_from_object_info(oi, dsc, ReplicationType::Metadata); roi.retry_count = entry.retry_count.max(0) as u32; if replicate_object_with_outcome(roi, storage.clone()).await.1 { ReplicationQueueAdmission::Queued } else { ReplicationQueueAdmission::Missed } } } }; if admission == ReplicationQueueAdmission::Missed { retry_entries.push(entry.clone()); } else if admission == ReplicationQueueAdmission::Queued { queued_count += 1; } } let retained_count = retry_entries.len(); *recovery_result.lock().await = Some(retry_entries); recovery_complete.notify_one(); if queued_count > 0 { info!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, recovered = queued_count, total, retained = retained_count, "Replayed MRF entries admitted for retry" ); } }); self.task_handles.lock().await.push(handle); } async fn start_force_delete_processor(&self) { let storage = self.storage.clone(); let handle = tokio::spawn(async move { let data = match ReplicationConfigStore::read(storage.clone(), ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE) .await { Ok(data) => data, Err(EcstoreError::ConfigNotFound) => return, Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to load durable force-delete intents" ); return; } }; let entries = match decode_mrf_file(&data) { Ok(entries) => entries, Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to decode durable force-delete intents" ); return; } }; for entry in entries { if !should_replay_force_delete_intent(&entry) { continue; } let Some(operation_id) = entry.force_delete_id else { continue; }; schedule_replication_delete(DeletedObjectReplicationInfo { delete_object: ReplicationDeletedObject { object_name: entry.object, force_delete: true, force_delete_id: Some(operation_id), force_delete_target_arns: entry.target_arns, force_delete_generation: entry.force_delete_generation, ..Default::default() }, bucket: entry.bucket, op_type: ReplicationType::Heal, event_type: REPLICATE_HEAL_DELETE.to_string(), ..Default::default() }) .await; } }); self.task_handles.lock().await.push(handle); } /// Starts the MRF persister — ongoing background task. /// /// Drains `mrf_save_rx` (entries that overflowed the normal worker channels) and /// writes them to the on-disk MRF file every flush interval (default 10s, /// overridable via `RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS`) or when 1 000 new /// entries accumulate. Each flush rewrites the whole cumulative backlog so no /// previously-persisted (and not-yet-replayed) entry is lost; the file is only /// consumed and cleared at startup. async fn start_mrf_persister(&self) { let Some(mut rx) = self.mrf_save_rx.lock().await.take() else { return; }; let storage = self.storage.clone(); let stats = self.stats.clone(); let recovery_complete = self.mrf_recovery_complete.clone(); let recovery_result = self.mrf_recovery_result.clone(); let handle = tokio::spawn(async move { let mut staged = Vec::new(); let mut staging_closed = false; let retry_timer = tokio::time::sleep(Duration::ZERO); tokio::pin!(retry_timer); let mut pending = loop { tokio::select! { entry = rx.recv(), if staged.len() < MRF_PENDING_CAP && !staging_closed => match entry { Some(entry) => { observe_mrf_pending(&entry); staged.push(entry); }, None => staging_closed = true, }, _ = &mut retry_timer => { match ReplicationConfigStore::read(storage.clone(), ReplicationMetadataStore::MRF_REPLICATION_FILE).await { Ok(data) => match decode_mrf_file(&data) { Ok(entries) => break entries, Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to seed MRF persister from the startup recovery file; retrying without overwriting it" ); } }, Err(EcstoreError::ConfigNotFound) => break Vec::new(), Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to read the startup MRF backlog for persister seeding; retrying without overwriting it" ); } } retry_timer.as_mut().reset(tokio::time::Instant::now() + MRF_RETRY_INITIAL_DELAY); } } }; let initial_pending_len = pending.len(); let mut capped_batch = move_staged_mrf_entries(&mut pending, &mut staged); let pending_staged_len = pending.len().saturating_sub(initial_pending_len); // The on-disk MRF file is a restart-recovery backstop: entries are // only replayed (and the file cleared) at startup, never during the // run. So the file must hold the *cumulative* set of overflow entries // written this run. `pending` is therefore kept cumulative and the // whole set is rewritten on each flush — clearing it after a flush // let the next flush overwrite the file and drop everything written // earlier (backlog#859 / #799 B10). Bounded by `MRF_PENDING_CAP` so a // sustained failure storm can't grow it without limit. let mut durable_tracker = DurableMrfBacklogTracker { available: true, ..Default::default() }; for entry in &pending[..initial_pending_len] { durable_tracker.add_entry(entry); } if initial_pending_len > 0 { set_durable_mrf_backlog_snapshot(durable_tracker.clone().into_snapshot()); } let mut new_entries_pending_stats = pending_staged_len; let mut new_entries_pending_observability = pending_staged_len; let mut dirty = pending_staged_len > 0; let mut capped = initial_pending_len >= MRF_PENDING_CAP; let mut recovery_applied = false; let mut channel_closed = false; let mut capped_payload = None; if capped { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, cap = MRF_PENDING_CAP, pending = initial_pending_len, "MRF pending backlog is at capacity — applying backpressure" ); } // Flush interval: `RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS` (default 10000ms, // clamped to >=10ms), read once when the persister task starts. let mut interval = tokio::time::interval(super::replication_timing::mrf_flush_interval()); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { if !channel_closed && rx.is_closed() && rx.is_empty() { channel_closed = true; } if recovery_applied && channel_closed && !dirty && capped_batch.is_empty() { break; } if recovery_applied && (pending.len() >= MRF_PENDING_CAP || !capped_batch.is_empty()) { if dirty { if let Some(duration_millis) = flush_mrf_to_disk(&pending, &storage).await { add_durable_mrf_suffix(&mut durable_tracker, &pending, new_entries_pending_stats); set_durable_mrf_backlog_snapshot(durable_tracker.clone().into_snapshot()); let observe_start = pending.len().saturating_sub(new_entries_pending_observability); observe_mrf_pending_flushed(&pending[observe_start..], duration_millis); new_entries_pending_observability = 0; if new_entries_pending_stats > 0 { let stats_start = pending.len().saturating_sub(new_entries_pending_stats); dec_mrf_entries(stats.as_ref(), &pending[stats_start..]); new_entries_pending_stats = 0; } dirty = false; } else { // Keep the channel bounded while the current backlog // cannot be persisted; draining it here would turn a // failed flush into unbounded in-memory growth. interval.tick().await; continue; } } if !capped { capped = true; warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, cap = MRF_PENDING_CAP, "MRF pending backlog reached capacity — applying backpressure" ); } if capped_batch.is_empty() { while let Ok(entry) = rx.try_recv() { capped_batch.push(entry); } for entry in &capped_batch { observe_mrf_pending(entry); } } if !capped_batch.is_empty() && let Some(duration_millis) = append_mrf_entries_to_disk(&capped_batch, &storage, &mut capped_payload, &pending).await { for entry in &capped_batch { durable_tracker.add_entry(entry); } set_durable_mrf_backlog_snapshot(durable_tracker.clone().into_snapshot()); observe_mrf_pending_flushed(&capped_batch, duration_millis); dec_mrf_entries(stats.as_ref(), &capped_batch); capped_batch.clear(); capped_payload = None; } if channel_closed && capped_batch.is_empty() && !dirty { break; } interval.tick().await; continue; } tokio::select! { entry = rx.recv(), if (!channel_closed || !rx.is_empty()) && pending.len() < MRF_PENDING_CAP => match entry { Some(e) => { observe_mrf_pending(&e); pending.push(e); new_entries_pending_stats += 1; new_entries_pending_observability += 1; dirty = true; // Flush eagerly once enough new entries have accumulated // since the last write (measured against the flushed // set, not the absolute length, so a large backlog is // not rewritten on every single add). if recovery_applied && new_entries_pending_stats >= 1000 && let Some(duration_millis) = flush_mrf_to_disk(&pending, &storage).await { add_durable_mrf_suffix(&mut durable_tracker, &pending, new_entries_pending_stats); set_durable_mrf_backlog_snapshot(durable_tracker.clone().into_snapshot()); let observe_start = pending.len().saturating_sub(new_entries_pending_observability); observe_mrf_pending_flushed(&pending[observe_start..], duration_millis); new_entries_pending_observability = 0; let stats_start = pending.len().saturating_sub(new_entries_pending_stats); dec_mrf_entries(stats.as_ref(), &pending[stats_start..]); new_entries_pending_stats = 0; dirty = false; } } None => { channel_closed = true; } }, _ = recovery_complete.notified(), if !recovery_applied => { recovery_applied = true; if let Some(retry_entries) = recovery_result.lock().await.take() { let new_entries = pending.split_off(initial_pending_len.min(pending.len())); pending = retry_entries; pending.extend(new_entries); let pending_capacity = MRF_PENDING_CAP.saturating_sub(pending.len()); let moved_count = pending_capacity.min(capped_batch.len()); if moved_count > 0 { pending.extend(capped_batch.drain(..moved_count)); new_entries_pending_stats += moved_count; new_entries_pending_observability += moved_count; } durable_tracker = durable_mrf_backlog_tracker_from_entries( &pending[..pending.len().saturating_sub(new_entries_pending_stats)], ); dirty = true; } }, _ = interval.tick() => { if recovery_applied && dirty && let Some(duration_millis) = flush_mrf_to_disk(&pending, &storage).await { add_durable_mrf_suffix(&mut durable_tracker, &pending, new_entries_pending_stats); set_durable_mrf_backlog_snapshot(durable_tracker.clone().into_snapshot()); let observe_start = pending.len().saturating_sub(new_entries_pending_observability); observe_mrf_pending_flushed(&pending[observe_start..], duration_millis); new_entries_pending_observability = 0; if new_entries_pending_stats > 0 { let stats_start = pending.len().saturating_sub(new_entries_pending_stats); dec_mrf_entries(stats.as_ref(), &pending[stats_start..]); new_entries_pending_stats = 0; } dirty = false; } } } } }); self.task_handles.lock().await.push(handle); } /// Worker function for handling regular replication operations async fn add_worker( &self, mut rx: Receiver, active_counter: Arc, stats: Arc, ) { while let Some(operation) = rx.recv().await { let _active = ActiveWorkerGuard::new(active_counter.clone()); process_replication_operation(operation, stats.clone(), self.storage.clone()).await; } } /// Worker function for handling large object replication operations async fn add_large_worker( &self, mut rx: Receiver, active_counter: Arc, stats: Arc, storage: Arc, ) { while let Some(operation) = rx.recv().await { let _active = ActiveWorkerGuard::new(active_counter.clone()); process_replication_operation(operation, stats.clone(), storage.clone()).await; } } /// Worker function for handling MRF (Most Recent Failures) operations async fn add_mrf_worker( &self, mut rx: Receiver, active_counter: Arc, stats: Arc, ) { while let Some(operation) = rx.recv().await { let _active = ActiveWorkerGuard::new(active_counter.clone()); process_replication_operation(operation, stats.clone(), self.storage.clone()).await; } } /// Delete resync metadata from replication resync state in memory pub async fn delete_resync_metadata(&self, bucket: &str) { let mut status_map = self.resyncer.status_map.write().await; status_map.remove(bucket); // Note: global site resync metrics deletion would be handled here // global_site_resync_metrics.delete_bucket(bucket); } /// Initialize bucket replication resync for all buckets pub async fn init_resync_internal( self: Arc, cancellation_token: CancellationToken, buckets: Vec, ) -> Result<(), EcstoreError> { // Load bucket metadata system in background let pool_clone = self; tokio::spawn(async move { pool_clone.start_resync_routine(buckets, cancellation_token).await; }); Ok(()) } pub async fn get_bucket_resync_status(&self, bucket: &str) -> Result { if let Some(status) = self.resyncer.status_map.read().await.get(bucket).cloned() { return Ok(status); } let status = load_bucket_resync_metadata(bucket, self.storage.clone()).await?; self.resyncer .status_map .write() .await .insert(bucket.to_string(), status.clone()); Ok(status) } pub async fn cancel_bucket_resync(&self, opts: ResyncOpts) -> Result<(), EcstoreError> { self.resyncer.cancel(&opts).await; self.resyncer .mark_status(ResyncStatusType::ResyncCanceled, opts, self.storage.clone()) .await?; Ok(()) } pub async fn start_bucket_resync(self: Arc, opts: ResyncOpts) -> Result<(), EcstoreError> { let new_run = self.clone().admit_bucket_resync(opts.clone()).await?; self.activate_bucket_resync(opts, !new_run).await } pub async fn admit_bucket_resync(self: Arc, opts: ResyncOpts) -> Result { tokio::spawn(async move { self.admit_bucket_resync_transaction(opts).await }) .await .map_err(|error| EcstoreError::other(format!("replication resync admission task failed: {error}")))? } async fn admit_bucket_resync_transaction(self: Arc, opts: ResyncOpts) -> Result { let admission_lock_key = ReplicationMetadataStore::resync_admission_lock_key(&opts.bucket); let admission_lock = self .storage .new_ns_lock(ReplicationMetadataStore::rustfs_meta_bucket(), &admission_lock_key) .await?; // Lock order: bucket resync admission lock -> resync status config-object lock. let _admission_guard = match admission_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await { Ok(guard) => guard, Err(lock_error) => { if let Ok(status) = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await { self.resyncer.status_map.write().await.insert(opts.bucket.clone(), status); } return Err(EcstoreError::from(lock_error)); } }; let mut bucket_status = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await?; if let Some(active) = bucket_status.targets_map.get(&opts.arn) { if active.resync_id == opts.resync_id { self.resyncer .status_map .write() .await .insert(opts.bucket.clone(), bucket_status); return Ok(false); } if should_auto_resume_resync(active.resync_status) { let active_resync_id = active.resync_id.clone(); self.resyncer .status_map .write() .await .insert(opts.bucket.clone(), bucket_status); return Err(EcstoreError::other(ResyncActiveConflictError { bucket: opts.bucket.clone(), arn: opts.arn.clone(), active_resync_id, })); } } let now = OffsetDateTime::now_utc(); bucket_status.last_update = Some(now); bucket_status.targets_map.insert( opts.arn.clone(), TargetReplicationResyncStatus { start_time: Some(now), last_update: Some(now), resync_id: opts.resync_id.clone(), resync_before_date: opts.resync_before, resync_status: ResyncStatusType::ResyncPending, failed_size: 0, failed_count: 0, replicated_size: 0, replicated_count: 0, bucket: opts.bucket.clone(), object: String::new(), error: None, }, ); save_resync_status(&opts.bucket, &bucket_status, self.storage.clone()).await?; self.resyncer .status_map .write() .await .insert(opts.bucket.clone(), bucket_status); Ok(true) } pub async fn activate_bucket_resync(self: Arc, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError> { let bucket_status = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await?; let Some(target_status) = bucket_status.targets_map.get(&opts.arn) else { return Err(EcstoreError::other("replication resync admission is missing")); }; if target_status.resync_id != opts.resync_id { return Err(EcstoreError::other(ResyncActiveConflictError { bucket: opts.bucket.clone(), arn: opts.arn.clone(), active_resync_id: target_status.resync_id.clone(), })); } if !should_auto_resume_resync(target_status.resync_status) { return Ok(()); } self.resyncer .status_map .write() .await .insert(opts.bucket.clone(), bucket_status); let resyncer = self.resyncer.clone(); let storage = self.storage.clone(); let cancel_token = CancellationToken::new(); if resyncer.register_cancel_token(&opts, cancel_token.clone()).await { tokio::spawn(async move { Box::pin( resyncer .clone() .resync_bucket(cancel_token, storage, recovering, opts.clone()), ) .await; resyncer.clear_cancel_token(&opts).await; }); } Ok(()) } /// Start the resync routine that runs in a loop async fn start_resync_routine(self: Arc, buckets: Vec, cancellation_token: CancellationToken) { // Retry-poll sleep upper bound: `RUSTFS_REPL_RESYNC_POLL_MAX_MS` // (default 60000ms, clamped to >=10ms), read once when this routine // starts. The anti-busy-spin floor is min(1s, max) so the default // keeps the historical "sleep at least one second" behavior while // short test overrides stay short. let max_sleep = super::replication_timing::resync_poll_max_sleep(); let max_sleep_ms = u64::try_from(max_sleep.as_millis()).unwrap_or(u64::MAX).max(1); let floor_sleep = Duration::from_secs(1).min(max_sleep); // Run the replication resync in a loop loop { let self_clone = self.clone(); let ctx = cancellation_token.clone(); tokio::select! { _ = cancellation_token.cancelled() => { return; } result = self_clone.load_resync(&buckets, ctx) => { if result.is_ok() { return; } } } // Generate a random duration between 0 and `max_sleep` (default 1 minute) use rand::RngExt; let duration_millis = rand::rng().random_range(0..max_sleep_ms); let mut duration = Duration::from_millis(duration_millis); // Make sure to sleep at least `floor_sleep` to avoid high CPU ticks if duration < floor_sleep { duration = floor_sleep; } tokio::time::sleep(duration).await; } } /// Load bucket replication resync statuses into memory #[instrument(skip(_cancellation_token))] async fn load_resync( self: Arc, buckets: &[String], _cancellation_token: CancellationToken, ) -> Result<(), EcstoreError> { let load_resync_lock = match self .storage .new_ns_lock(ReplicationMetadataStore::rustfs_meta_bucket(), "replication/resync/load-resync.lock") .await { Ok(lock) => lock, Err(err) => { warn!( event = EVENT_REPLICATION_RESYNC_LOAD_SKIPPED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = ?err, reason = "leader_lock_create_failed", "Skipped replication resync metadata load" ); return Ok(()); } }; let _load_resync_guard = match load_resync_lock .get_write_lock(ReplicationLockTiming::acquire_timeout()) .await { Ok(guard) => guard, Err(_) => { debug!( event = EVENT_REPLICATION_RESYNC_LOAD_SKIPPED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, reason = "leader_lock_held_by_another_node", "Another node is already loading replication resync metadata" ); return Ok(()); } }; let mut recovered_statuses = Vec::new(); let mut restart_opts = Vec::new(); let mut recovered_bucket_count = 0usize; let mut skipped_failed_target_count = 0usize; for bucket in buckets { let meta = match load_bucket_resync_metadata(bucket, self.storage.clone()).await { Ok(meta) => meta, Err(err) => { if !matches!(err, EcstoreError::VolumeNotFound) { debug!( event = EVENT_REPLICATION_RESYNC_LOAD_SKIPPED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, bucket, error = ?err, reason = "metadata_load_failed", "Skipped replication resync metadata load" ); } continue; } }; if meta.targets_map.is_empty() { continue; } recovered_bucket_count += 1; for (arn, stats) in &meta.targets_map { if should_auto_resume_resync(stats.resync_status) { restart_opts.push(ResyncOpts { bucket: bucket.clone(), arn: arn.clone(), resync_id: stats.resync_id.clone(), resync_before: stats.resync_before_date, }); } else if stats.resync_status == ResyncStatusType::ResyncFailed { skipped_failed_target_count += 1; } } recovered_statuses.push((bucket.clone(), meta)); } if !recovered_statuses.is_empty() { let mut status_map = self.resyncer.status_map.write().await; status_map.extend(recovered_statuses); } if !restart_opts.is_empty() || skipped_failed_target_count > 0 { info!( event = EVENT_REPLICATION_RESYNC_RECOVERED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, recovered_buckets = recovered_bucket_count, resumed_targets = restart_opts.len(), skipped_failed_targets = skipped_failed_target_count, "Recovered replication resync state from persisted metadata; failed targets require manual resync restart" ); } for opts in restart_opts { let ctx = CancellationToken::new(); let resync = self.resyncer.clone(); let storage = self.storage.clone(); tokio::spawn(async move { if resync.register_cancel_token(&opts, ctx.clone()).await { Box::pin(resync.clone().resync_bucket(ctx, storage, true, opts.clone())).await; resync.clear_cancel_token(&opts).await; } }); } Ok(()) } } struct ActiveWorkerGuard { counter: Arc, } impl ActiveWorkerGuard { fn new(counter: Arc) -> Self { counter.fetch_add(1, Ordering::SeqCst); Self { counter } } } impl Drop for ActiveWorkerGuard { fn drop(&mut self) { self.counter.fetch_sub(1, Ordering::SeqCst); } } struct ReplicationBacklogGuard { stats: Arc, bucket: String, size: i64, is_delete_marker: bool, op_type: ReplicationType, target_arns: Vec, } impl ReplicationBacklogGuard { fn for_object(stats: Arc, object: &ReplicateObjectInfo) -> Self { Self { stats, bucket: object.bucket.clone(), size: object.size, is_delete_marker: object.delete_marker, op_type: object.op_type, target_arns: object.dsc.replicate_target_arns(), } } fn for_delete(stats: Arc, delete: &DeletedObjectReplicationInfo) -> Self { Self { stats, bucket: delete.bucket.clone(), size: 0, is_delete_marker: true, op_type: delete.op_type, target_arns: delete.admitted_target_arns(), } } } impl Drop for ReplicationBacklogGuard { fn drop(&mut self) { self.stats.dec_q(&self.bucket, self.size, self.is_delete_marker, self.op_type); self.stats.dec_target_q(&self.bucket, &self.target_arns, self.size); } } async fn process_replication_operation( operation: ReplicationOperation, stats: Arc, storage: Arc, ) { match operation { ReplicationOperation::Object(obj_info) => { let _backlog = ReplicationBacklogGuard::for_object(stats, obj_info.as_ref()); replicate_object(*obj_info, storage).await; } ReplicationOperation::Delete(del_info) => { let _backlog = ReplicationBacklogGuard::for_delete(stats, del_info.as_ref()); replicate_delete(*del_info, storage).await; } } } async fn queue_mrf_save_entry( tx: &Sender, entry: MrfReplicateEntry, queue_type: &'static str, ) -> ReplicationQueueAdmission { let Err(error) = tx.send(entry).await else { return ReplicationQueueAdmission::Queued; }; let entry = error.0; warn!( event = EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, bucket = %entry.bucket, object = %entry.object, queue_type = queue_type, "MRF save channel unavailable — replication failure entry could not be persisted for retry" ); observe_mrf_missed(&entry.bucket); ReplicationQueueAdmission::Missed } async fn quarantine_mrf_file(storage: &Arc, data: &[u8]) { let quarantine_file = format!("{MRF_CORRUPT_FILE_PREFIX}.{}.bin", OffsetDateTime::now_utc().unix_timestamp_nanos()); let payload = data.to_vec(); let mut retry_delay = MRF_RETRY_INITIAL_DELAY; loop { match ReplicationConfigStore::save(storage.clone(), &quarantine_file, payload.clone()).await { Ok(()) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, file = %quarantine_file, "Quarantined corrupt MRF recovery file" ); break; } Err(error) => warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, file = %quarantine_file, error = %error, "Failed to quarantine corrupt MRF recovery file; retrying without overwriting it" ), } tokio::time::sleep(retry_delay).await; retry_delay = retry_delay.saturating_mul(2).min(MRF_RETRY_MAX_DELAY); } // Clear the active path only if it still contains the bytes that were // quarantined. The write lock closes the read/clear race with another node // replacing the active generation while this node retries quarantine. retry_delay = MRF_RETRY_INITIAL_DELAY; loop { let lock = match storage .new_ns_lock( ReplicationMetadataStore::rustfs_meta_bucket(), ReplicationMetadataStore::MRF_REPLICATION_FILE, ) .await { Ok(lock) => lock, Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to acquire the MRF recovery lock before clearing quarantine source; retrying" ); tokio::time::sleep(retry_delay).await; retry_delay = retry_delay.saturating_mul(2).min(MRF_RETRY_MAX_DELAY); continue; } }; let _guard = match lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await { Ok(guard) => guard, Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to acquire the MRF recovery lock before clearing quarantine source; retrying" ); tokio::time::sleep(retry_delay).await; retry_delay = retry_delay.saturating_mul(2).min(MRF_RETRY_MAX_DELAY); continue; } }; match ReplicationConfigStore::read_no_lock(storage.clone(), ReplicationMetadataStore::MRF_REPLICATION_FILE).await { Err(EcstoreError::ConfigNotFound) => return, Ok(current) if current != data => return, Ok(_) => { match ReplicationConfigStore::save_no_lock( storage.clone(), ReplicationMetadataStore::MRF_REPLICATION_FILE, Vec::new(), ) .await { Ok(()) => return, Err(error) => warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to clear the corrupt MRF recovery path after quarantine; retrying" ), } } Err(error) => warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to verify the corrupt MRF recovery path before clearing; retrying" ), } drop(_guard); tokio::time::sleep(retry_delay).await; retry_delay = retry_delay.saturating_mul(2).min(MRF_RETRY_MAX_DELAY); } } fn should_retry_mrf_source_lookup(error: &EcstoreError) -> bool { !is_err_object_not_found(error) && !is_err_version_not_found(error) } fn dec_mrf_entries(stats: &ReplicationStats, entries: &[MrfReplicateEntry]) { for entry in entries { stats.dec_q(&entry.bucket, entry.size, matches!(entry.op, MrfOpKind::Delete), ReplicationType::Heal); stats.dec_target_q(&entry.bucket, &entry.target_arns, entry.size); } } /// Encodes `entries` and overwrites the MRF persistence file. /// Returns the flush duration on success; on failure logs the error and returns `None`. /// Callers must NOT clear their in-memory buffer on `None` so the next tick /// can retry — otherwise a transient storage error permanently drops the batch. async fn flush_mrf_to_disk(entries: &[MrfReplicateEntry], storage: &Arc) -> Option { let started = Instant::now(); match write_mrf_journal_snapshot(storage.clone(), entries).await { Ok(()) => Some(duration_millis_u64(started.elapsed())), Err(e) => { let duration_millis = duration_millis_u64(started.elapsed()); observe_mrf_flush_failure(duration_millis); warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, count = entries.len(), error = %e, "Failed to flush MRF entries to disk" ); None } } } async fn recover_corrupt_mrf_generation( corrupt_generation: &[u8], entries_to_append: &[MrfReplicateEntry], known_pending: &[MrfReplicateEntry], storage: &Arc, pending_payload: &mut Option, started: Instant, ) -> Option { let quarantine_file = format!("{MRF_CORRUPT_FILE_PREFIX}.{}.bin", OffsetDateTime::now_utc().unix_timestamp_nanos()); if let Err(error) = ReplicationConfigStore::save_no_lock(storage.clone(), &quarantine_file, corrupt_generation.to_vec()).await { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, file = %quarantine_file, error = %error, "Failed to quarantine a corrupt active MRF generation before rebuilding it" ); return None; } let mut entries = Vec::with_capacity(known_pending.len().saturating_add(entries_to_append.len())); entries.extend_from_slice(known_pending); entries.extend_from_slice(entries_to_append); let data = match encode_mrf_file(&entries) { Ok(data) => data, Err(error) => { observe_mrf_flush_failure(0); warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, count = entries.len(), error = %error, "Failed to rebuild the active MRF generation after quarantining corruption" ); return None; } }; *pending_payload = Some(PendingMrfAppend { digest: mrf_payload_digest(&data), entry_count: entries.len(), }); if let Err(error) = write_mrf_journal_snapshot(storage.clone(), &entries).await { observe_mrf_flush_failure(duration_millis_u64(started.elapsed())); warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, count = entries.len(), error = %error, "Failed to replace the active MRF generation after quarantining corruption" ); return None; } Some(duration_millis_u64(started.elapsed())) } async fn append_mrf_entries_to_disk( entries_to_append: &[MrfReplicateEntry], storage: &Arc, pending_payload: &mut Option, known_pending: &[MrfReplicateEntry], ) -> Option { if entries_to_append.is_empty() { return Some(0); } let started = Instant::now(); let lock = match storage .new_ns_lock( ReplicationMetadataStore::rustfs_meta_bucket(), ReplicationMetadataStore::MRF_REPLICATION_FILE, ) .await { Ok(lock) => lock, Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to acquire the MRF lock before appending a capped entry" ); return None; } }; let _guard = match lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await { Ok(guard) => guard, Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to acquire the MRF write lock before appending a capped entry" ); return None; } }; let current = match ReplicationConfigStore::read_no_lock(storage.clone(), ReplicationMetadataStore::MRF_REPLICATION_FILE).await { Ok(data) => data, Err(EcstoreError::ConfigNotFound) => Vec::new(), Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to read MRF backlog before appending a capped entry" ); return None; } }; if pending_payload .as_ref() .is_some_and(|pending| pending.digest == mrf_payload_digest(¤t)) { return Some(duration_millis_u64(started.elapsed())); } let mut entries = match decode_mrf_file(¤t) { Ok(entries) => entries, Err(_) if current.is_empty() => Vec::new(), Err(error) => { warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to decode MRF backlog before appending a capped entry" ); return recover_corrupt_mrf_generation(¤t, entries_to_append, known_pending, storage, pending_payload, started) .await; } }; if let Some(pending) = pending_payload.as_ref() && entries.len() >= pending.entry_count { match encode_mrf_file(&entries[..pending.entry_count]) { Ok(prefix) if mrf_payload_digest(&prefix) == pending.digest => return Some(duration_millis_u64(started.elapsed())), Ok(_) => {} Err(error) => { observe_mrf_flush_failure(0); warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, error = %error, "Failed to verify a capped MRF append after an ambiguous save error" ); return None; } } } entries.extend_from_slice(entries_to_append); let data = match encode_mrf_file(&entries) { Ok(data) => data, Err(error) => { observe_mrf_flush_failure(0); warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, count = entries.len(), error = %error, "Failed to encode capped MRF entries for disk append" ); return None; } }; *pending_payload = Some(PendingMrfAppend { digest: mrf_payload_digest(&data), entry_count: entries.len(), }); if let Err(error) = ReplicationConfigStore::save_no_lock(storage.clone(), ReplicationMetadataStore::MRF_REPLICATION_FILE, data).await { let duration_millis = duration_millis_u64(started.elapsed()); observe_mrf_flush_failure(duration_millis); warn!( component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, count = entries.len(), error = %error, "Failed to append capped MRF entries to disk" ); return None; } Some(duration_millis_u64(started.elapsed())) } fn duration_millis_u64(duration: std::time::Duration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } /// Load bucket resync metadata from disk async fn load_bucket_resync_metadata( bucket: &str, obj_api: Arc, ) -> Result { let mut brs = BucketReplicationResyncStatus::new(); let resync_file_path = ReplicationMetadataStore::bucket_resync_file_path(bucket); let data = match ReplicationConfigStore::read(obj_api, &resync_file_path).await { Ok(data) => data, Err(EcstoreError::ConfigNotFound) => return Ok(brs), Err(err) => return Err(err), }; if data.is_empty() { // Seems to be empty return Ok(brs); } brs = decode_resync_file(&data)?; Ok(brs) } // Define a trait object type for the replication pool pub type DynReplicationPool = dyn ReplicationPoolTrait + Send + Sync; /// Trait that abstracts the replication pool operations #[async_trait::async_trait] pub trait ReplicationPoolTrait: std::fmt::Debug { fn active_workers(&self) -> i32; fn active_mrf_workers(&self) -> i32; fn active_lrg_workers(&self) -> i32; async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission; async fn queue_replica_delete_task(&self, ri: DeletedObjectReplicationInfo) -> ReplicationQueueAdmission; async fn queue_replica_delete_batch(&self, deletes: &[DeletedObjectReplicationInfo]) -> ReplicationBatchAdmission; async fn resize(&self, priority: ReplicationPriority, max_workers: usize, max_l_workers: usize); async fn get_bucket_resync_status(&self, bucket: &str) -> Result; async fn cancel_bucket_resync(&self, opts: ResyncOpts) -> Result<(), EcstoreError>; async fn admit_bucket_resync(self: Arc, opts: ResyncOpts) -> Result; async fn activate_bucket_resync(self: Arc, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError>; async fn start_bucket_resync(self: Arc, opts: ResyncOpts) -> Result<(), EcstoreError>; async fn init_resync( self: Arc, cancellation_token: CancellationToken, buckets: Vec, ) -> Result<(), EcstoreError>; } // Implement the trait for ReplicationPool #[async_trait::async_trait] impl ReplicationPoolTrait for ReplicationPool { fn active_workers(&self) -> i32 { ReplicationPool::::active_workers(self) } fn active_mrf_workers(&self) -> i32 { ReplicationPool::::active_mrf_workers(self) } fn active_lrg_workers(&self) -> i32 { ReplicationPool::::active_lrg_workers(self) } async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission { self.queue_replica_task(ri).await } async fn queue_replica_delete_task(&self, ri: DeletedObjectReplicationInfo) -> ReplicationQueueAdmission { self.queue_replica_delete_task(ri).await } async fn queue_replica_delete_batch(&self, deletes: &[DeletedObjectReplicationInfo]) -> ReplicationBatchAdmission { self.queue_replica_delete_batch(deletes).await } async fn resize(&self, priority: ReplicationPriority, max_workers: usize, max_l_workers: usize) { self.resize(priority, max_workers, max_l_workers).await; } async fn get_bucket_resync_status(&self, bucket: &str) -> Result { self.get_bucket_resync_status(bucket).await } async fn cancel_bucket_resync(&self, opts: ResyncOpts) -> Result<(), EcstoreError> { self.cancel_bucket_resync(opts).await } async fn admit_bucket_resync(self: Arc, opts: ResyncOpts) -> Result { self.admit_bucket_resync(opts).await } async fn activate_bucket_resync(self: Arc, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError> { self.activate_bucket_resync(opts, recovering).await } async fn start_bucket_resync(self: Arc, opts: ResyncOpts) -> Result<(), EcstoreError> { self.start_bucket_resync(opts).await } async fn init_resync( self: Arc, cancellation_token: CancellationToken, buckets: Vec, ) -> Result<(), EcstoreError> { self.init_resync_internal(cancellation_token, buckets).await } } /// Initializes background replication with the given options. /// /// Phase 5 (backlog#939): the replication stats/pool moved into the per-instance /// `InstanceContext`; this owner initializes the current instance's cells /// (lazily, once — single-instance behavior is unchanged). pub async fn init_background_replication(storage: Arc) { let ctx = crate::runtime::global::current_ctx(); let stats = ctx .replication_stats_cell() .get_or_init(|| async { let stats = Arc::new(ReplicationStats::new()); stats.start_background_tasks().await; stats }) .await; let _pool = ctx .replication_pool_cell() .get_or_init(|| async { let pool = ReplicationPool::new(ReplicationPoolOpts::default(), stats.clone(), storage).await; pool as Arc }) .await; assert!(runtime_sources::replication_runtime_initialized()); } pub fn get_global_replication_pool() -> Option> { runtime_sources::replication_pool() } pub fn get_global_replication_stats() -> Option> { runtime_sources::replication_stats() } pub(crate) async fn schedule_replication( oi: ObjectInfo, o: Arc, dsc: ReplicateDecision, op_type: ReplicationType, ) { let (synchronous, asynchronous) = dsc.partition_by_sync(); let mut async_oi = oi; if synchronous.replicate_any() { let ri = replicate_object_info_from_object_info(async_oi.clone(), synchronous, op_type); let state = replicate_object(ri, o.clone()).await; async_oi.replication_status_internal = state.replication_status_internal; async_oi.version_purge_status_internal = state.version_purge_status_internal; } if asynchronous.replicate_any() && let Some(pool) = runtime_sources::replication_pool() { let ri = replicate_object_info_from_object_info(async_oi, asynchronous, op_type); let _ = pool.queue_replica_task(ri).await; } } fn replicate_object_info_from_object_info( oi: ObjectInfo, dsc: ReplicateDecision, op_type: ReplicationType, ) -> ReplicateObjectInfo { let tgt_statuses = replication_statuses_map(&oi.replication_status_internal.clone().unwrap_or_default()); let purge_statuses = version_purge_statuses_map(&oi.version_purge_status_internal.clone().unwrap_or_default()); let tm = get_str(&oi.user_defined, SUFFIX_REPLICATION_TIMESTAMP) .map(|v| OffsetDateTime::parse(&v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH)); let mut rstate = oi.replication_state(); rstate.replicate_decision_str = dsc.to_string(); let asz = oi.get_actual_size().unwrap_or_default(); let ssec = replication_object_is_ssec_encrypted(&oi.user_defined); let checksum = if ssec { oi.checksum.clone() } else { None }; ReplicateObjectInfo { name: oi.name, size: oi.size, actual_size: asz, bucket: oi.bucket, version_id: oi.version_id, etag: oi.etag, mod_time: oi.mod_time, replication_status: oi.replication_status, replication_status_internal: oi.replication_status_internal, delete_marker: oi.delete_marker, version_purge_status_internal: oi.version_purge_status_internal, version_purge_status: oi.version_purge_status, replication_state: Some(rstate), op_type, dsc, target_statuses: tgt_statuses, target_purge_statuses: purge_statuses, replication_timestamp: tm, user_tags: (*oi.user_tags).clone(), checksum, retry_count: 0, event_type: "".to_string(), existing_obj_resync: ResyncDecision::default(), ssec, } } pub(crate) async fn schedule_replication_delete(dv: DeletedObjectReplicationInfo) -> ReplicationQueueAdmission { let admission = if let Some(pool) = runtime_sources::replication_pool() { pool.queue_replica_delete_task(dv.clone()).await } else { ReplicationQueueAdmission::Missed }; if let Some(stats) = runtime_sources::replication_stats() { let target_arns = dv.admitted_target_arns(); if let Some(rs) = dv.delete_object.replication_state.as_ref() { for k in target_arns .iter() .filter(|target_arn| rs.targets.contains_key(*target_arn) || rs.purge_targets.contains_key(*target_arn)) { let ri = ReplicatedTargetInfo { arn: k.clone(), size: 0, duration: Duration::default(), op_type: ReplicationType::Delete, ..Default::default() }; stats .update(&dv.bucket, &ri, ReplicationStatusType::Pending, ReplicationStatusType::Empty) .await; } } } admission } /// QueueReplicationHeal is a wrapper for queue_replication_heal_internal pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u32) -> ReplicationQueueAdmission { // ignore modtime zero objects if oi.mod_time.is_none() || oi.mod_time == Some(OffsetDateTime::UNIX_EPOCH) { return ReplicationQueueAdmission::Skipped; } let rcfg = match ReplicationMetadataStore::optional_replication_config(bucket).await { Ok(Some(config)) => config, Ok(None) => return ReplicationQueueAdmission::Skipped, Err(err) => { debug!( event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, bucket, error = %err, reason = "config_lookup_failed", "Skipped replication heal queue due to missing replication config" ); return ReplicationQueueAdmission::Missed; } }; let tgts = match ReplicationTargetStore::list_bucket_targets(bucket).await { Ok(targets) => Some(targets), Err(err) => { debug!( event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, bucket, error = %err, reason = "target_list_failed", "Skipped bucket target list during replication heal queue setup" ); None } }; let rcfg_wrapper = ReplicationConfig::new(Some(rcfg), tgts); queue_replication_heal_internal(bucket, oi, rcfg_wrapper, retry_count) .await .admission } pub async fn queue_replication_metadata(bucket: &str, oi: ObjectInfo, retry_count: u32) -> ReplicationQueueAdmission { let dsc = must_replicate( bucket, &oi.name, MustReplicateOptions::new(&oi.user_defined, (*oi.user_tags).clone(), ReplicationType::Metadata, false) .with_replication_status(oi.replication_status.clone()), ) .await; if !dsc.replicate_any() { return ReplicationQueueAdmission::Skipped; } let mut roi = replicate_object_info_from_object_info(oi, dsc, ReplicationType::Metadata); roi.retry_count = retry_count; if let Some(pool) = runtime_sources::replication_pool() { pool.queue_replica_task(roi).await } else { ReplicationQueueAdmission::Missed } } /// queue_replication_heal_internal enqueues objects that failed replication OR eligible for resyncing through /// an ongoing resync operation or via existing objects replication configuration setting. pub(crate) async fn queue_replication_heal_internal( _bucket: &str, oi: ObjectInfo, rcfg: ReplicationConfig, retry_count: u32, ) -> ReplicationHealQueueResult { let mut roi = ReplicateObjectInfo::default(); // ignore modtime zero objects if oi.mod_time.is_none() || oi.mod_time == Some(OffsetDateTime::UNIX_EPOCH) { return ReplicationHealQueueResult { object_info: roi, admission: ReplicationQueueAdmission::Skipped, }; } if rcfg.config.is_none() || rcfg.remotes.is_none() { return ReplicationHealQueueResult { object_info: roi, admission: ReplicationQueueAdmission::Skipped, }; } roi = match get_heal_replicate_object_info(&oi, &rcfg).await { Ok(roi) => roi, Err(err) => { warn!( event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED, component = LOG_COMPONENT_ECSTORE, subsystem = LOG_SUBSYSTEM_REPLICATION, bucket = %oi.bucket, object = %oi.name, error = %err, "Failed to classify object for replication heal" ); return ReplicationHealQueueResult { object_info: roi, admission: ReplicationQueueAdmission::Missed, }; } }; roi.retry_count = retry_count; match replication_heal_queue_action(&mut roi) { ReplicationHealQueueAction::Skip => ReplicationHealQueueResult { object_info: roi, admission: ReplicationQueueAdmission::Skipped, }, ReplicationHealQueueAction::QueueObject => { let admission = if let Some(pool) = runtime_sources::replication_pool() { pool.queue_replica_task(roi.clone()).await } else { ReplicationQueueAdmission::Missed }; ReplicationHealQueueResult { object_info: roi, admission, } } ReplicationHealQueueAction::QueueDelete(dv) => { let admission = if let Some(pool) = runtime_sources::replication_pool() { pool.queue_replica_delete_task(dv).await } else { ReplicationQueueAdmission::Missed }; ReplicationHealQueueResult { object_info: roi, admission, } } ReplicationHealQueueAction::QueueResyncDeletes(batch) => { let admission = queue_replicate_deletes(batch).await; ReplicationHealQueueResult { object_info: roi, admission, } } } } async fn queue_replicate_deletes(batch: ReplicationHealResyncDeletes) -> ReplicationQueueAdmission { let mut admission = ReplicationQueueAdmission::Skipped; for dv in batch.target_delete_infos() { let target_admission = if let Some(pool) = runtime_sources::replication_pool() { pool.queue_replica_delete_task(dv).await } else { ReplicationQueueAdmission::Missed }; admission.merge(target_admission); } admission } #[cfg(test)] mod tests { use super::super::replication_filemeta_boundary::ReplicateTargetDecision; use super::super::replication_resync_boundary::{decode_mrf_file, encode_mrf_file, encode_resync_file}; use super::super::replication_storage_boundary::{ DeletedObject, FileInfo, GetObjectReader, HTTPRangeSpec, ListOperations, ObjectIO, ObjectOperations, PutObjReader, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageNamespaceLocking, StorageObjectInfoOrErr, WalkOptions, }; use super::*; use std::collections::{HashMap, VecDeque}; use std::fmt::{Debug, Formatter}; use std::io::Cursor; use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicBool, AtomicUsize}; use tokio::io::AsyncReadExt; use tokio::sync::Notify; use uuid::Uuid; type TestListObjectsV2Info = StorageListObjectsV2Info; type TestListObjectVersionsInfo = StorageListObjectVersionsInfo; type TestObjectInfoOrErr = StorageObjectInfoOrErr; struct LoadResyncSharedState { data: StdMutex>, etag_revision: AtomicUsize, last_put_preconditions: StdMutex>, last_put_no_lock: AtomicBool, omit_etag: AtomicBool, conditional_write_replacements: StdMutex>>, writes: StdMutex)>>, lock_manager: Arc, first_read_started: Notify, delay_first_read: AtomicBool, hold_first_read: AtomicBool, allow_first_read: Notify, read_count: AtomicUsize, write_count: AtomicUsize, fail_next_write: AtomicBool, fail_after_write: AtomicBool, block_next_write: AtomicBool, write_started: Notify, allow_write: Notify, } struct LoadResyncNodeStore { owner: String, shared: Arc, } impl LoadResyncNodeStore { fn new(owner: &str, shared: Arc) -> Self { Self { owner: owner.to_string(), shared, } } } impl Debug for LoadResyncNodeStore { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("LoadResyncNodeStore").field("owner", &self.owner).finish() } } #[async_trait::async_trait] impl ObjectIO for LoadResyncNodeStore { type Error = EcstoreError; type RangeSpec = HTTPRangeSpec; type HeaderMap = http::HeaderMap; type ObjectOptions = ObjectOptions; type ObjectInfo = ObjectInfo; type GetObjectReader = GetObjectReader; type PutObjectReader = PutObjReader; async fn get_object_reader( &self, _bucket: &str, object: &str, _range: Option, _h: Self::HeaderMap, _opts: &Self::ObjectOptions, ) -> Result { if !object.ends_with("/.replication/resync.bin") && !object.ends_with("config/replication/mrf.bin") && !object.ends_with("config/replication/force-delete.bin") { return Err(EcstoreError::FileNotFound); } let read_index = self.shared.read_count.fetch_add(1, Ordering::SeqCst); if read_index == 0 && self.shared.delay_first_read.load(Ordering::SeqCst) { self.shared.first_read_started.notify_waiters(); if self.shared.hold_first_read.load(Ordering::SeqCst) { self.shared.allow_first_read.notified().await; } else { tokio::time::sleep(Duration::from_millis(1_500)).await; } } let data = self .shared .data .lock() .expect("test data lock should not be poisoned") .clone(); if data.is_empty() { return Err(EcstoreError::FileNotFound); } let size = i64::try_from(data.len()).expect("test metadata length should fit i64"); Ok(Self::GetObjectReader { stream: Box::new(Cursor::new(data)), object_info: ObjectInfo { size, actual_size: size, etag: (!self.shared.omit_etag.load(Ordering::SeqCst)) .then(|| format!("mrf-{}", self.shared.etag_revision.load(Ordering::SeqCst))), ..Default::default() }, buffered_body: None, body_source: Default::default(), }) } async fn put_object( &self, _bucket: &str, object: &str, data: &mut Self::PutObjectReader, opts: &Self::ObjectOptions, ) -> Result { if opts.http_preconditions.is_some() && let Some(replacement) = self .shared .conditional_write_replacements .lock() .expect("test replacement lock should not be poisoned") .pop_front() { *self.shared.data.lock().expect("test data lock should not be poisoned") = replacement; self.shared.etag_revision.fetch_add(1, Ordering::SeqCst); } let current_etag = if self .shared .data .lock() .expect("test data lock should not be poisoned") .is_empty() { None } else { Some(format!("mrf-{}", self.shared.etag_revision.load(Ordering::SeqCst))) }; if opts.http_preconditions.as_ref().is_some_and(|preconditions| { preconditions.if_none_match_value() == Some("*") && current_etag.is_some() || preconditions .if_match_value() .is_some_and(|expected| current_etag.as_deref() != Some(expected)) }) { return Err(EcstoreError::PreconditionFailed); } *self .shared .last_put_preconditions .lock() .expect("test preconditions lock should not be poisoned") = opts.http_preconditions.clone(); self.shared.last_put_no_lock.store(opts.no_lock, Ordering::SeqCst); if self.shared.fail_next_write.swap(false, Ordering::SeqCst) { return Err(EcstoreError::Unexpected); } if self.shared.block_next_write.swap(false, Ordering::SeqCst) { self.shared.write_started.notify_one(); self.shared.allow_write.notified().await; } let mut encoded = Vec::new(); data.stream.read_to_end(&mut encoded).await.map_err(EcstoreError::from)?; self.shared .writes .lock() .expect("test writes lock should not be poisoned") .push((object.to_string(), encoded.clone())); if !object.starts_with(MRF_CORRUPT_FILE_PREFIX) { *self.shared.data.lock().expect("test data lock should not be poisoned") = encoded; self.shared.etag_revision.fetch_add(1, Ordering::SeqCst); } self.shared.write_count.fetch_add(1, Ordering::SeqCst); if self.shared.fail_after_write.swap(false, Ordering::SeqCst) { return Err(EcstoreError::Unexpected); } Ok(ObjectInfo::default()) } } #[async_trait::async_trait] impl ObjectOperations for LoadResyncNodeStore { type Error = EcstoreError; type ObjectInfo = ObjectInfo; type ObjectOptions = ObjectOptions; type FileInfo = FileInfo; type ObjectToDelete = ObjectToDelete; type DeletedObject = DeletedObject; async fn get_object_info( &self, _bucket: &str, _object: &str, _opts: &Self::ObjectOptions, ) -> Result { Err(EcstoreError::NotImplemented) } async fn verify_object_integrity( &self, _bucket: &str, _object: &str, _opts: &Self::ObjectOptions, ) -> Result<(), Self::Error> { Err(EcstoreError::NotImplemented) } async fn copy_object( &self, _src_bucket: &str, _src_object: &str, _dst_bucket: &str, _dst_object: &str, _src_info: &mut Self::ObjectInfo, _src_opts: &Self::ObjectOptions, _dst_opts: &Self::ObjectOptions, ) -> Result { Err(EcstoreError::NotImplemented) } async fn delete_object_version( &self, _bucket: &str, _object: &str, _fi: &Self::FileInfo, _force_del_marker: bool, ) -> Result<(), Self::Error> { Err(EcstoreError::NotImplemented) } async fn delete_object( &self, _bucket: &str, _object: &str, _opts: Self::ObjectOptions, ) -> Result { Err(EcstoreError::NotImplemented) } async fn delete_objects( &self, _bucket: &str, _objects: Vec, _opts: Self::ObjectOptions, ) -> (Vec, Vec>) { (Vec::new(), vec![Some(EcstoreError::NotImplemented)]) } async fn put_object_metadata( &self, _bucket: &str, _object: &str, _opts: &Self::ObjectOptions, ) -> Result { Err(EcstoreError::NotImplemented) } async fn get_object_tags( &self, _bucket: &str, _object: &str, _opts: &Self::ObjectOptions, ) -> Result { Err(EcstoreError::NotImplemented) } async fn put_object_tags( &self, _bucket: &str, _object: &str, _tags: &str, _opts: &Self::ObjectOptions, ) -> Result { Err(EcstoreError::NotImplemented) } async fn delete_object_tags( &self, _bucket: &str, _object: &str, _opts: &Self::ObjectOptions, ) -> Result { Err(EcstoreError::NotImplemented) } async fn add_partial(&self, _bucket: &str, _object: &str, _version_id: &str) -> Result<(), Self::Error> { Err(EcstoreError::NotImplemented) } async fn transition_object(&self, _bucket: &str, _object: &str, _opts: &Self::ObjectOptions) -> Result<(), Self::Error> { Err(EcstoreError::NotImplemented) } async fn restore_transitioned_object( self: Arc, _bucket: &str, _object: &str, _opts: &Self::ObjectOptions, ) -> Result<(), Self::Error> { Err(EcstoreError::NotImplemented) } } #[async_trait::async_trait] impl ListOperations for LoadResyncNodeStore { type Error = EcstoreError; type ListObjectsV2Info = TestListObjectsV2Info; type ListObjectVersionsInfo = TestListObjectVersionsInfo; type ObjectInfoOrErr = TestObjectInfoOrErr; type WalkOptions = WalkOptions; type WalkCancellation = CancellationToken; type WalkResultSender = Sender; async fn list_objects_v2( self: Arc, _bucket: &str, _prefix: &str, _continuation_token: Option, _delimiter: Option, _max_keys: i32, _fetch_owner: bool, _start_after: Option, _incl_deleted: bool, ) -> Result { Err(EcstoreError::NotImplemented) } async fn list_object_versions( self: Arc, _bucket: &str, _prefix: &str, _marker: Option, _version_marker: Option, _delimiter: Option, _max_keys: i32, ) -> Result { Err(EcstoreError::NotImplemented) } async fn walk( self: Arc, _rx: Self::WalkCancellation, _bucket: &str, _prefix: &str, _result: Self::WalkResultSender, _opts: Self::WalkOptions, ) -> Result<(), Self::Error> { Ok(()) } } #[async_trait::async_trait] impl StorageNamespaceLocking for LoadResyncNodeStore { type Error = EcstoreError; type NamespaceLock = rustfs_lock::NamespaceLockWrapper; async fn new_ns_lock(&self, bucket: &str, object: &str) -> Result { let lock = rustfs_lock::NamespaceLock::with_local_manager("load-resync-test".to_string(), self.shared.lock_manager.clone()); Ok(rustfs_lock::NamespaceLockWrapper::new( lock, rustfs_lock::ObjectKey::new(bucket.to_string(), object.to_string()), self.owner.clone(), )) } } async fn new_test_replication_pool(storage: Arc) -> Arc> { let (mrf_replica_tx, mrf_replica_rx) = mpsc::channel(1); let (mrf_save_tx, mrf_save_rx) = mpsc::channel(1); let (mrf_worker_kill_tx, _) = mpsc::channel(1); let (mrf_stop_tx, _) = mpsc::channel(1); Arc::new(ReplicationPool { active_workers: Arc::new(AtomicI32::new(0)), active_lrg_workers: Arc::new(AtomicI32::new(0)), active_mrf_workers: Arc::new(AtomicI32::new(0)), storage, priority: RwLock::new(ReplicationPoolOpts::default().priority), max_workers: RwLock::new(WORKER_MAX_LIMIT), max_l_workers: RwLock::new(LARGE_WORKER_COUNT), stats: Arc::new(ReplicationStats::new()), workers: RwLock::new(Vec::new()), lrg_workers: RwLock::new(Vec::new()), mrf_replica_tx, mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)), mrf_save_tx, mrf_save_rx: Mutex::new(Some(mrf_save_rx)), mrf_recovery_complete: Arc::new(Notify::new()), mrf_recovery_result: Arc::new(Mutex::new(None)), mrf_worker_kill_tx, mrf_stop_tx, mrf_worker_size: AtomicI32::new(0), task_handles: Mutex::new(Vec::new()), resyncer: Arc::new(ReplicationResyncer::new().await), }) } async fn current_queue(pool: &ReplicationPool, bucket: &str) -> (i64, i64) { let stats = pool.stats.get_latest_replication_stats(bucket).await; (stats.replication_stats.q_stat.curr.count, stats.replication_stats.q_stat.curr.bytes) } fn current_target_queue(pool: &ReplicationPool, bucket: &str, target_arn: &str) -> Option<(u64, u64)> { pool.stats .runtime_target_backlog_snapshot() .into_iter() .find(|target| target.bucket == bucket && target.target_arn == target_arn) .map(|target| (target.count, target.bytes)) } fn test_replicate_decision(target_arns: &[&str]) -> ReplicateDecision { let mut decision = ReplicateDecision::default(); for target_arn in target_arns { decision.set(ReplicateTargetDecision::new((*target_arn).to_string(), true, false)); } decision } async fn wait_for_current_queue(pool: &ReplicationPool, bucket: &str, expected: (i64, i64)) { tokio::time::timeout(Duration::from_secs(10), async { loop { if current_queue(pool, bucket).await == expected { break; } tokio::task::yield_now().await; } }) .await .expect("replication queue should reach the expected state"); } #[tokio::test] async fn regular_worker_admission_counts_channel_backlog_before_receive() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; let (tx, _rx) = mpsc::channel(1); pool.workers.write().await.push(tx); let admission = pool .queue_replica_task(ReplicateObjectInfo { bucket: "admission-bucket".to_string(), name: "object".to_string(), size: 4096, op_type: ReplicationType::Object, ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); assert_eq!(current_queue(&pool, "admission-bucket").await, (1, 4096)); } #[tokio::test] async fn regular_worker_admission_counts_target_backlog_before_receive() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; let (tx, _rx) = mpsc::channel(1); pool.workers.write().await.push(tx); let admission = pool .queue_replica_task(ReplicateObjectInfo { bucket: "target-admission-bucket".to_string(), name: "object".to_string(), size: 4096, op_type: ReplicationType::Object, dsc: test_replicate_decision(&["arn:rustfs:replication:target-b", "arn:rustfs:replication:target-a"]), ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); assert_eq!(current_queue(&pool, "target-admission-bucket").await, (1, 4096)); assert_eq!( current_target_queue(&pool, "target-admission-bucket", "arn:rustfs:replication:target-a"), Some((1, 4096)) ); assert_eq!( current_target_queue(&pool, "target-admission-bucket", "arn:rustfs:replication:target-b"), Some((1, 4096)) ); } #[tokio::test] async fn large_worker_admission_counts_channel_backlog_before_receive() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; let (tx, _rx) = mpsc::channel(1); pool.lrg_workers.write().await.push(tx); let size = 128 * 1024 * 1024; let admission = pool .queue_replica_task(ReplicateObjectInfo { bucket: "large-admission-bucket".to_string(), name: "large-object".to_string(), size, op_type: ReplicationType::Object, ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); assert_eq!(current_queue(&pool, "large-admission-bucket").await, (1, size)); } #[tokio::test] async fn delete_admission_counts_channel_backlog_before_receive() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; let (tx, _rx) = mpsc::channel(1); pool.workers.write().await.push(tx); let admission = pool .queue_replica_delete_task(DeletedObjectReplicationInfo { bucket: "delete-admission-bucket".to_string(), delete_object: ReplicationDeletedObject { object_name: "deleted-object".to_string(), ..Default::default() }, op_type: ReplicationType::Delete, ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); assert_eq!(current_queue(&pool, "delete-admission-bucket").await, (1, 0)); } #[tokio::test] async fn delete_admission_counts_target_backlog_before_receive() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; let (tx, _rx) = mpsc::channel(1); pool.workers.write().await.push(tx); let admission = pool .queue_replica_delete_task(DeletedObjectReplicationInfo { bucket: "delete-target-admission-bucket".to_string(), target_arn: "arn:rustfs:replication:target-a".to_string(), delete_object: ReplicationDeletedObject { object_name: "deleted-object".to_string(), ..Default::default() }, op_type: ReplicationType::Delete, ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); assert_eq!(current_queue(&pool, "delete-target-admission-bucket").await, (1, 0)); assert_eq!( current_target_queue(&pool, "delete-target-admission-bucket", "arn:rustfs:replication:target-a"), Some((1, 0)) ); } #[tokio::test] async fn regular_worker_drains_current_backlog_after_processing() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; pool.resize_workers(1, 0).await; let admission = pool .queue_replica_task(ReplicateObjectInfo { bucket: "regular-drain-bucket".to_string(), name: "object".to_string(), size: 4096, op_type: ReplicationType::Object, ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); wait_for_current_queue(&pool, "regular-drain-bucket", (0, 0)).await; } #[tokio::test] async fn large_worker_drains_current_backlog_after_processing() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; pool.resize_lrg_workers(1, 0).await; let size = 128 * 1024 * 1024; let admission = pool .queue_replica_task(ReplicateObjectInfo { bucket: "large-drain-bucket".to_string(), name: "large-object".to_string(), size, op_type: ReplicationType::Object, ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); wait_for_current_queue(&pool, "large-drain-bucket", (0, 0)).await; } #[tokio::test] async fn regular_delete_worker_drains_current_backlog_after_processing() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; pool.resize_workers(1, 0).await; let admission = pool .queue_replica_delete_task(DeletedObjectReplicationInfo { bucket: "delete-drain-bucket".to_string(), delete_object: ReplicationDeletedObject { object_name: "deleted-object".to_string(), ..Default::default() }, op_type: ReplicationType::Delete, ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); wait_for_current_queue(&pool, "delete-drain-bucket", (0, 0)).await; } fn load_resync_test_metadata() -> Vec { let mut status = BucketReplicationResyncStatus::new(); status.targets_map.insert( "arn:test".to_string(), TargetReplicationResyncStatus { bucket: "load-resync-lock".to_string(), resync_status: ResyncStatusType::ResyncCompleted, ..Default::default() }, ); encode_resync_file(&status).expect("test resync metadata should encode") } fn empty_resync_shared_state() -> Arc { Arc::new(LoadResyncSharedState { data: StdMutex::new(Vec::new()), etag_revision: AtomicUsize::new(0), last_put_preconditions: StdMutex::new(None), last_put_no_lock: AtomicBool::new(false), omit_etag: AtomicBool::new(false), conditional_write_replacements: StdMutex::new(VecDeque::new()), writes: StdMutex::new(Vec::new()), lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()), first_read_started: Notify::new(), delay_first_read: AtomicBool::new(false), hold_first_read: AtomicBool::new(false), allow_first_read: Notify::new(), read_count: AtomicUsize::new(0), write_count: AtomicUsize::new(0), fail_next_write: AtomicBool::new(false), fail_after_write: AtomicBool::new(false), block_next_write: AtomicBool::new(false), write_started: Notify::new(), allow_write: Notify::new(), }) } async fn hold_resync_runtime_lock( shared: &Arc, bucket: &str, arn: &str, ) -> rustfs_lock::NamespaceLockGuard { let lock = rustfs_lock::NamespaceLock::with_local_manager("resync-start-blocker".to_string(), shared.lock_manager.clone()); let lock = rustfs_lock::NamespaceLockWrapper::new( lock, rustfs_lock::ObjectKey::new( ReplicationMetadataStore::rustfs_meta_bucket().to_string(), ReplicationMetadataStore::resync_lock_key(bucket, arn), ), "blocker".to_string(), ); lock.get_write_lock(Duration::from_secs(1)) .await .expect("test should hold the runtime resync lock") } fn test_resync_opts(bucket: &str, arn: &str, id: &str) -> ResyncOpts { ResyncOpts { bucket: bucket.to_string(), arn: arn.to_string(), resync_id: id.to_string(), resync_before: Some(OffsetDateTime::UNIX_EPOCH), } } #[tokio::test] async fn concurrent_resync_starts_accept_one_id_and_reject_the_other() { let shared = empty_resync_shared_state(); let first_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await; let second_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-b", shared.clone()))).await; let _runtime_guard = hold_resync_runtime_lock(&shared, "atomic-start", "arn:test").await; let first = first_pool .clone() .start_bucket_resync(test_resync_opts("atomic-start", "arn:test", "run-a")); let second = second_pool .clone() .start_bucket_resync(test_resync_opts("atomic-start", "arn:test", "run-b")); let (first, second) = tokio::join!(first, second); let (accepted_id, conflict) = match (first, second) { (Ok(()), Err(conflict)) => ("run-a", conflict), (Err(conflict), Ok(())) => ("run-b", conflict), outcome => panic!("exactly one concurrent start should be accepted: {outcome:?}"), }; assert_eq!(resync_start_conflict_id(&conflict), Some(accepted_id)); let persisted = decode_resync_file(&shared.data.lock().expect("test data lock should not be poisoned")) .expect("accepted status should be persisted"); assert_eq!(persisted.targets_map["arn:test"].resync_id, accepted_id); assert_eq!(persisted.targets_map["arn:test"].resync_status, ResyncStatusType::ResyncPending); assert_eq!( first_pool.resyncer.status_map.read().await["atomic-start"].targets_map["arn:test"].resync_id, accepted_id ); assert_eq!( second_pool.resyncer.status_map.read().await["atomic-start"].targets_map["arn:test"].resync_id, accepted_id ); } #[tokio::test] async fn same_resync_id_retry_is_idempotent_without_rewriting_status() { let shared = empty_resync_shared_state(); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await; let _runtime_guard = hold_resync_runtime_lock(&shared, "same-id", "arn:test").await; let opts = test_resync_opts("same-id", "arn:test", "run-a"); pool.clone() .start_bucket_resync(opts.clone()) .await .expect("first start should be accepted"); let first_status = pool .resyncer .status_map .read() .await .get("same-id") .expect("accepted status should be published") .targets_map["arn:test"] .clone(); pool.clone() .start_bucket_resync(opts) .await .expect("same ID retry should be accepted idempotently"); let retried_status = pool .resyncer .status_map .read() .await .get("same-id") .expect("retried status should remain published") .targets_map["arn:test"] .clone(); assert_eq!(shared.write_count.load(Ordering::SeqCst), 1); assert_eq!(retried_status.resync_id, first_status.resync_id); assert_eq!(retried_status.start_time, first_status.start_time); assert_eq!(retried_status.resync_status, ResyncStatusType::ResyncPending); assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1); } #[tokio::test] async fn admitted_resync_waits_for_target_metadata_commit_before_activation() { let shared = empty_resync_shared_state(); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await; let _runtime_guard = hold_resync_runtime_lock(&shared, "two-phase-start", "arn:test").await; let opts = test_resync_opts("two-phase-start", "arn:test", "run-a"); let new_run = pool .clone() .admit_bucket_resync(opts.clone()) .await .expect("admission should persist the intent"); assert!(new_run); assert!(pool.resyncer.cancel_tokens.read().await.is_empty()); assert_eq!(shared.write_count.load(Ordering::SeqCst), 1); pool.clone() .activate_bucket_resync(opts, false) .await .expect("activation should start the admitted run"); assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1); } #[tokio::test] async fn same_id_retry_after_restart_recreates_missing_runtime_task() { let shared = empty_resync_shared_state(); let mut persisted = BucketReplicationResyncStatus::new(); persisted.targets_map.insert( "arn:test".to_string(), TargetReplicationResyncStatus { bucket: "restart-retry".to_string(), resync_id: "run-a".to_string(), resync_status: ResyncStatusType::ResyncPending, ..Default::default() }, ); *shared.data.lock().expect("test data lock should not be poisoned") = encode_resync_file(&persisted).expect("restart status should encode"); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await; let _runtime_guard = hold_resync_runtime_lock(&shared, "restart-retry", "arn:test").await; pool.clone() .start_bucket_resync(test_resync_opts("restart-retry", "arn:test", "run-a")) .await .expect("same ID retry should recover an accepted run"); assert_eq!(shared.write_count.load(Ordering::SeqCst), 0); assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1); assert_eq!( pool.resyncer.status_map.read().await["restart-retry"].targets_map["arn:test"].resync_id, "run-a" ); } #[tokio::test] async fn same_completed_resync_id_retry_does_not_restart_work() { let shared = empty_resync_shared_state(); let mut persisted = BucketReplicationResyncStatus::new(); persisted.targets_map.insert( "arn:test".to_string(), TargetReplicationResyncStatus { bucket: "completed-retry".to_string(), resync_id: "run-a".to_string(), resync_status: ResyncStatusType::ResyncCompleted, ..Default::default() }, ); *shared.data.lock().expect("test data lock should not be poisoned") = encode_resync_file(&persisted).expect("completed status should encode"); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await; pool.clone() .start_bucket_resync(test_resync_opts("completed-retry", "arn:test", "run-a")) .await .expect("completed same ID retry should remain idempotent"); assert_eq!(shared.write_count.load(Ordering::SeqCst), 0); assert!(pool.resyncer.cancel_tokens.read().await.is_empty()); assert_eq!( pool.resyncer.status_map.read().await["completed-retry"].targets_map["arn:test"].resync_status, ResyncStatusType::ResyncCompleted ); } #[tokio::test] async fn start_failure_does_not_publish_or_persist_requested_id() { let shared = empty_resync_shared_state(); shared.fail_next_write.store(true, Ordering::SeqCst); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await; let error = pool .clone() .start_bucket_resync(test_resync_opts("failed-start", "arn:test", "run-a")) .await .expect_err("metadata save failure should reject the start"); assert!(matches!(error, EcstoreError::Unexpected)); assert!(shared.data.lock().expect("test data lock should not be poisoned").is_empty()); assert!(!pool.resyncer.status_map.read().await.contains_key("failed-start")); assert_eq!(shared.write_count.load(Ordering::SeqCst), 0); } #[tokio::test] async fn canceled_start_request_finishes_accepted_transaction() { let shared = empty_resync_shared_state(); shared.block_next_write.store(true, Ordering::SeqCst); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await; let _runtime_guard = hold_resync_runtime_lock(&shared, "canceled-start", "arn:test").await; let start_pool = pool.clone(); let start = tokio::spawn(async move { start_pool .start_bucket_resync(test_resync_opts("canceled-start", "arn:test", "run-a")) .await }); tokio::time::timeout(Duration::from_secs(10), shared.write_started.notified()) .await .expect("start transaction should reach the durable write"); start.abort(); assert!(start.await.expect_err("caller task should be canceled").is_cancelled()); shared.allow_write.notify_one(); tokio::time::timeout(Duration::from_secs(10), async { loop { if pool.resyncer.status_map.read().await.contains_key("canceled-start") { break; } tokio::task::yield_now().await; } }) .await .expect("detached admission transaction should finish after caller cancellation"); assert_eq!(shared.write_count.load(Ordering::SeqCst), 1); assert_eq!( pool.resyncer.status_map.read().await["canceled-start"].targets_map["arn:test"].resync_id, "run-a" ); } #[test] fn replication_queue_admission_combines_target_results() { let mut admission = ReplicationQueueAdmission::Skipped; admission.merge(ReplicationQueueAdmission::Queued); assert_eq!(admission, ReplicationQueueAdmission::Queued); admission.merge(ReplicationQueueAdmission::Missed); assert_eq!(admission, ReplicationQueueAdmission::Missed); } #[tokio::test] async fn heal_queue_marks_missing_versioning_state_as_missed() { use super::super::replication_target_boundary::BucketTargets; use s3s::dto::{ DeleteReplication, DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, }; let arn = "arn:rustfs:replication:us-east-1:target:bucket"; let result = queue_replication_heal_internal( "missing-versioning-state", ObjectInfo { bucket: "missing-versioning-state".to_string(), name: "object".to_string(), version_id: Some(Uuid::new_v4()), version_purge_status: super::super::replication_filemeta_boundary::VersionPurgeStatusType::Pending, mod_time: Some(OffsetDateTime::now_utc()), ..Default::default() }, ReplicationConfig::new( Some(ReplicationConfiguration { role: String::new(), rules: vec![ReplicationRule { delete_marker_replication: None, delete_replication: Some(DeleteReplication { status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED), }), destination: Destination { bucket: arn.to_string(), ..Default::default() }, existing_object_replication: None, filter: None, id: Some("delete".to_string()), prefix: Some(String::new()), priority: Some(1), source_selection_criteria: None, status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED), }], }), Some(BucketTargets::default()), ), 0, ) .await; assert_eq!(result.admission, ReplicationQueueAdmission::Missed); } #[tokio::test] async fn queue_replica_task_counts_mrf_pending_backlog_when_worker_queue_is_full() { let shared = empty_resync_shared_state(); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared))).await; let (tx, _rx) = mpsc::channel(1); tx.try_send(ReplicationOperation::Object(Box::new(ReplicateObjectInfo { bucket: "runtime-backlog".to_string(), name: "already-buffered".to_string(), size: 1, op_type: ReplicationType::Object, ..Default::default() }))) .expect("test setup should fill the worker queue"); pool.workers.write().await.push(tx); let admission = pool .queue_replica_task(ReplicateObjectInfo { bucket: "runtime-backlog".to_string(), name: "fallback-object".to_string(), size: 2048, op_type: ReplicationType::Object, dsc: test_replicate_decision(&["arn:rustfs:replication:target-a"]), ..Default::default() }) .await; assert_eq!(admission, ReplicationQueueAdmission::Queued); let queued = pool.stats.get_latest_replication_stats("runtime-backlog").await; assert_eq!(queued.replication_stats.q_stat.curr.count, 1); assert_eq!(queued.replication_stats.q_stat.curr.bytes, 2048); assert_eq!( current_target_queue(&pool, "runtime-backlog", "arn:rustfs:replication:target-a"), Some((1, 2048)) ); } #[test] fn replicate_object_info_from_object_info_preserves_ssec_checksum() { let checksum = bytes::Bytes::from_static(b"ssec-checksum"); let oi = ObjectInfo { bucket: "source".to_string(), name: "object".to_string(), user_defined: Arc::new(HashMap::from([( rustfs_utils::http::SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string(), )])), checksum: Some(checksum.clone()), ..Default::default() }; let ri = replicate_object_info_from_object_info(oi, ReplicateDecision::default(), ReplicationType::Object); assert!(ri.ssec); assert_eq!(ri.checksum, Some(checksum)); } #[tokio::test] async fn mrf_save_admission_waits_for_capacity_instead_of_dropping() { let (tx, mut rx) = mpsc::channel(1); let first = MrfReplicateEntry { bucket: "bucket".to_string(), object: "first".to_string(), version_id: None, retry_count: 1, size: 1, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }; let second = MrfReplicateEntry { object: "second".to_string(), ..first.clone() }; tx.try_send(first).expect("first MRF entry should fill the test channel"); let admission = queue_mrf_save_entry(&tx, second, "test"); tokio::pin!(admission); assert!( tokio::time::timeout(Duration::from_millis(25), &mut admission).await.is_err(), "full MRF channel should apply backpressure instead of returning Missed" ); let received = rx.recv().await.expect("first MRF entry should still be queued"); assert_eq!(received.object, "first"); let admission = tokio::time::timeout(Duration::from_secs(1), &mut admission) .await .expect("MRF admission should finish once capacity is available"); assert_eq!(admission, ReplicationQueueAdmission::Queued); let received = rx .recv() .await .expect("second MRF entry should be queued after capacity opens"); assert_eq!(received.object, "second"); } #[tokio::test] async fn delete_batch_admission_reports_mrf_fallback_items() { let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await; let (worker_tx, worker_rx) = mpsc::channel(1); worker_tx .try_send(ReplicationOperation::Delete(Box::new(DeletedObjectReplicationInfo { bucket: "batch-backpressure".to_string(), delete_object: ReplicationDeletedObject { object_name: "already-queued".to_string(), ..Default::default() }, op_type: ReplicationType::Delete, ..Default::default() }))) .expect("test worker channel should be full"); pool.workers.write().await.push(worker_tx); let mut mrf_rx = pool .mrf_save_rx .lock() .await .take() .expect("test should own the MRF save receiver"); let deletes = (0..1) .map(|index| DeletedObjectReplicationInfo { bucket: "batch-backpressure".to_string(), delete_object: ReplicationDeletedObject { object_name: format!("object-{index}"), ..Default::default() }, op_type: ReplicationType::Delete, ..Default::default() }) .collect::>(); let summary = pool.queue_replica_delete_batch(&deletes).await; let entry = mrf_rx .recv() .await .expect("MRF fallback entry should be queued after batch admission"); assert_eq!(entry.object, "object-0"); assert_eq!(summary.total, 1); assert_eq!(summary.queued, 1); assert_eq!(summary.missed, 0); assert_eq!(summary.outcome(), "all_queued"); drop(worker_rx); } #[tokio::test] async fn mrf_save_admission_records_missed_when_channel_is_closed() { let (tx, rx) = mpsc::channel(1); drop(rx); let bucket = "mrf-missed-hook-bucket"; let admission = queue_mrf_save_entry( &tx, MrfReplicateEntry { bucket: bucket.to_string(), object: "missed".to_string(), version_id: None, retry_count: 1, size: 1, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }, "test", ) .await; assert_eq!(admission, ReplicationQueueAdmission::Missed); let snapshot = mrf_backlog_observability_snapshot(); let bucket = snapshot .buckets .iter() .find(|stats| stats.bucket == "mrf-missed-hook-bucket") .expect("missed MRF admission should be observable"); assert_eq!(bucket.missed_count, 1); } #[tokio::test] async fn mrf_flush_failure_keeps_pending_backlog_observable() { let shared = empty_resync_shared_state(); shared.fail_next_write.store(true, Ordering::SeqCst); let storage = Arc::new(LoadResyncNodeStore::new("mrf-flush-failure", shared)); let entry = MrfReplicateEntry { bucket: "mrf-flush-failure-bucket".to_string(), object: "pending".to_string(), version_id: None, retry_count: 1, size: 2048, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }; observe_mrf_pending(&entry); let result = flush_mrf_to_disk(std::slice::from_ref(&entry), &storage).await; assert_eq!(result, None); let snapshot = mrf_backlog_observability_snapshot(); let bucket = snapshot .buckets .iter() .find(|stats| stats.bucket == "mrf-flush-failure-bucket") .expect("failed MRF flush should keep the bucket observable"); assert_eq!(bucket.pending_count, 1); assert_eq!(bucket.pending_bytes, 2048); assert_eq!(bucket.flush_failure_count, 1); } #[tokio::test] async fn replication_backlog_guard_decrements_on_drop() { let stats = Arc::new(ReplicationStats::new()); stats.inc_q("guard-bucket", 256, false, ReplicationType::Object); stats.inc_target_q("guard-bucket", &["arn:rustfs:replication:target-a".to_string()], 256); { let object = ReplicateObjectInfo { bucket: "guard-bucket".to_string(), size: 256, op_type: ReplicationType::Object, dsc: test_replicate_decision(&["arn:rustfs:replication:target-a"]), ..Default::default() }; let _guard = ReplicationBacklogGuard::for_object(stats.clone(), &object); } let queued = stats.get_latest_replication_stats("guard-bucket").await; assert_eq!(queued.replication_stats.q_stat.curr.count, 0); assert_eq!(queued.replication_stats.q_stat.curr.bytes, 0); assert!(stats.runtime_target_backlog_snapshot().is_empty()); } #[test] fn dec_mrf_entries_decrements_target_backlog() { let stats = ReplicationStats::new(); let entry = MrfReplicateEntry { bucket: "mrf-target-drain-bucket".to_string(), object: "object".to_string(), version_id: None, retry_count: 1, size: 1024, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: vec!["arn:rustfs:replication:target-a".to_string()], ..Default::default() }; stats.inc_q(&entry.bucket, entry.size, false, ReplicationType::Heal); stats.inc_target_q(&entry.bucket, &entry.target_arns, entry.size); dec_mrf_entries(&stats, std::slice::from_ref(&entry)); assert!(stats.runtime_target_backlog_snapshot().is_empty()); } #[test] fn mrf_observability_tracker_separates_pending_drop_miss_and_flush_failure() { let first = MrfReplicateEntry { bucket: "tracker-bucket".to_string(), object: "first".to_string(), version_id: None, retry_count: 1, size: 1024, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }; let second = MrfReplicateEntry { object: "second".to_string(), size: 512, ..first.clone() }; let mut tracker = MrfBacklogObservabilityTracker::default(); tracker.add_pending(&first); tracker.add_pending(&second); tracker.record_drop(&second); tracker.record_missed("tracker-bucket"); tracker.record_flush_failure(7); tracker.flush_pending_entries([&first], 11); let snapshot = tracker.snapshot(); let bucket = snapshot .buckets .iter() .find(|stats| stats.bucket == "tracker-bucket") .expect("tracker bucket should be present"); assert_eq!(bucket.pending_count, 1); assert_eq!(bucket.pending_bytes, 512); assert_eq!(bucket.dropped_count, 1); assert_eq!(bucket.missed_count, 1); assert_eq!(bucket.flush_failure_count, 1); assert_eq!(bucket.last_flush_duration_millis, 11); } #[test] fn auto_resume_resync_only_for_inflight_states() { assert!(should_auto_resume_resync(ResyncStatusType::ResyncPending)); assert!(should_auto_resume_resync(ResyncStatusType::ResyncStarted)); assert!(!should_auto_resume_resync(ResyncStatusType::NoResync)); assert!(!should_auto_resume_resync(ResyncStatusType::ResyncCanceled)); assert!(!should_auto_resume_resync(ResyncStatusType::ResyncCompleted)); assert!(!should_auto_resume_resync(ResyncStatusType::ResyncFailed)); } #[tokio::test] async fn load_resync_leader_lock_allows_only_one_startup_recovery() { temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))], async { let shared = Arc::new(LoadResyncSharedState { data: StdMutex::new(load_resync_test_metadata()), etag_revision: AtomicUsize::new(1), last_put_preconditions: StdMutex::new(None), last_put_no_lock: AtomicBool::new(false), omit_etag: AtomicBool::new(false), conditional_write_replacements: StdMutex::new(VecDeque::new()), writes: StdMutex::new(Vec::new()), lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()), first_read_started: Notify::new(), delay_first_read: AtomicBool::new(true), hold_first_read: AtomicBool::new(false), allow_first_read: Notify::new(), read_count: AtomicUsize::new(0), write_count: AtomicUsize::new(0), fail_next_write: AtomicBool::new(false), fail_after_write: AtomicBool::new(false), block_next_write: AtomicBool::new(false), write_started: Notify::new(), allow_write: Notify::new(), }); let leader_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await; let skipped_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-b", shared.clone()))).await; let leader = leader_pool.clone(); let leader_task = tokio::spawn(async move { let buckets = vec!["load-resync-lock".to_string()]; leader.load_resync(&buckets, CancellationToken::new()).await }); tokio::time::timeout(Duration::from_secs(1), shared.first_read_started.notified()) .await .expect("leader should start reading persisted resync metadata"); let buckets = vec!["load-resync-lock".to_string()]; skipped_pool .clone() .load_resync(&buckets, CancellationToken::new()) .await .expect("contended load_resync should skip without failing startup"); leader_task .await .expect("leader load_resync task should not panic") .expect("leader load_resync should succeed"); assert_eq!( shared.read_count.load(Ordering::SeqCst), 1, "only the leader node should read persisted resync metadata" ); assert!( leader_pool.resyncer.status_map.read().await.contains_key("load-resync-lock"), "leader node should recover persisted resync status" ); assert!( skipped_pool.resyncer.status_map.read().await.is_empty(), "node that does not hold the leader lock must not populate status_map" ); }) .await; } // ── MrfReplicateEntry encode/decode roundtrips ──────────────────────────── #[test] fn mrf_entry_object_roundtrip() { let vid = Uuid::new_v4(); let entry = MrfReplicateEntry { bucket: "my-bucket".to_string(), object: "path/to/obj".to_string(), version_id: Some(vid), retry_count: 3, size: 1024, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }; let encoded = encode_mrf_file(std::slice::from_ref(&entry)).expect("encode"); let decoded = decode_mrf_file(&encoded).expect("decode"); assert_eq!(decoded.len(), 1); let got = &decoded[0]; assert_eq!(got.bucket, "my-bucket"); assert_eq!(got.object, "path/to/obj"); assert_eq!(got.version_id, Some(vid)); assert_eq!(got.retry_count, 3); assert_eq!(got.size, 1024); assert_eq!(got.op, MrfOpKind::Object); assert_eq!(got.delete_marker_version_id, None); assert!(!got.delete_marker); } #[test] fn mrf_object_replay_source_lookup_discards_missing_objects_and_retries_transient_errors() { assert!(!should_retry_mrf_source_lookup(&EcstoreError::FileNotFound)); assert!(!should_retry_mrf_source_lookup(&EcstoreError::FileVersionNotFound)); assert!(!should_retry_mrf_source_lookup(&EcstoreError::VersionNotFound( "bucket".to_string(), "object".to_string(), "version".to_string(), ))); assert!(should_retry_mrf_source_lookup(&EcstoreError::Unexpected)); } #[test] fn mrf_metadata_replay_source_lookup_discards_missing_objects_and_retries_transient_errors() { for error in [EcstoreError::FileNotFound, EcstoreError::FileVersionNotFound] { assert!(!should_retry_mrf_source_lookup(&error)); } assert!(should_retry_mrf_source_lookup(&EcstoreError::Unexpected)); } #[tokio::test] async fn corrupt_mrf_file_is_quarantined_without_overwriting_recovery_data() { let shared = empty_resync_shared_state(); let corrupt = vec![0xde, 0xad, 0xbe, 0xef]; *shared.data.lock().expect("test data lock should not be poisoned") = corrupt.clone(); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-corrupt", shared.clone()))).await; pool.start_mrf_processor().await; let handle = pool .task_handles .lock() .await .pop() .expect("MRF processor task should be registered"); handle.await.expect("MRF processor should not panic"); let writes = shared.writes.lock().expect("test writes lock should not be poisoned"); let (file, data) = writes.first().expect("corrupt MRF data should be quarantined"); assert!(file.starts_with(MRF_CORRUPT_FILE_PREFIX)); assert_eq!(data, &corrupt); let marker = writes .iter() .find(|(file, _)| file == ReplicationMetadataStore::MRF_REPLICATION_FILE) .expect("active MRF path should be cleared after quarantine"); assert!(marker.1.is_empty(), "the active MRF path should be marked absent"); } #[tokio::test] async fn corrupt_mrf_quarantine_preserves_a_concurrently_replaced_generation() { let shared = empty_resync_shared_state(); let corrupt = vec![0xde, 0xad, 0xbe, 0xef]; let replacement = encode_mrf_file(&[MrfReplicateEntry { bucket: "mrf-replacement".to_string(), object: "new-generation".to_string(), op: MrfOpKind::Object, ..Default::default() }]) .expect("replacement MRF generation should encode"); *shared.data.lock().expect("test data lock should not be poisoned") = corrupt; shared.block_next_write.store(true, Ordering::SeqCst); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-corrupt-race", shared.clone()))).await; let write_started = shared.write_started.notified(); pool.start_mrf_processor().await; tokio::time::timeout(Duration::from_secs(2), write_started) .await .expect("quarantine write should block before its payload is persisted"); *shared.data.lock().expect("test data lock should not be poisoned") = replacement.clone(); shared.allow_write.notify_one(); let handle = pool .task_handles .lock() .await .pop() .expect("MRF processor task should be registered"); handle.await.expect("MRF processor should not panic"); assert_eq!( *shared.data.lock().expect("test data lock should not be poisoned"), replacement, "quarantine cleanup must not clear a newer active MRF generation" ); assert!( !shared .writes .lock() .expect("test writes lock should not be poisoned") .iter() .any(|(file, data)| file == ReplicationMetadataStore::MRF_REPLICATION_FILE && data.is_empty()), "the newer active generation must not be replaced with the empty marker" ); } #[tokio::test] async fn corrupt_mrf_quarantine_retries_without_blocking_new_failures() { temp_env::async_with_vars([("RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS", Some("10"))], async { let shared = empty_resync_shared_state(); shared.fail_next_write.store(true, Ordering::SeqCst); *shared.data.lock().expect("test data lock should not be poisoned") = vec![0xde, 0xad, 0xbe, 0xef]; let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-corrupt-retry", shared.clone()))).await; pool.start_mrf_persister().await; pool.start_mrf_processor().await; pool.mrf_save_tx .send(MrfReplicateEntry { bucket: "mrf-corrupt-retry".to_string(), object: "new-failure".to_string(), op: MrfOpKind::Object, ..Default::default() }) .await .expect("new failure should be staged during corrupt-file recovery"); pool.mrf_save_tx .send(MrfReplicateEntry { bucket: "mrf-corrupt-retry".to_string(), object: "second-failure".to_string(), op: MrfOpKind::Object, ..Default::default() }) .await .expect("persister should drain the first staged failure before recovery completes"); let processor_handle = pool .task_handles .lock() .await .pop() .expect("MRF processor task should be registered"); processor_handle.await.expect("MRF processor should retry quarantine writes"); tokio::time::timeout(Duration::from_secs(2), async { loop { let quarantine_complete = { let writes = shared.writes.lock().expect("test writes lock should not be poisoned"); let quarantined = writes .iter() .any(|(file, data)| file.starts_with(MRF_CORRUPT_FILE_PREFIX) && data == &[0xde, 0xad, 0xbe, 0xef]); let cleared = writes .iter() .any(|(file, data)| file == ReplicationMetadataStore::MRF_REPLICATION_FILE && data.is_empty()); quarantined && cleared }; if quarantine_complete { break; } tokio::task::yield_now().await; } }) .await .expect("quarantine should retry after the injected write failure and clear the active path"); tokio::time::timeout(Duration::from_secs(2), async { loop { let data = shared.data.lock().expect("test data lock should not be poisoned").clone(); if decode_mrf_file(&data).is_ok_and(|entries| entries.iter().any(|entry| entry.object == "new-failure")) { break; } tokio::task::yield_now().await; } }) .await .expect("staged failures should flush after quarantine recovery"); let persister_handle = pool .task_handles .lock() .await .pop() .expect("MRF persister task should be registered"); persister_handle.abort(); }) .await; } #[tokio::test] async fn mrf_startup_staging_does_not_publish_entries_before_flush() { temp_env::async_with_vars([("RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS", Some("10"))], async { let shared = empty_resync_shared_state(); *shared.data.lock().expect("test data lock should not be poisoned") = encode_mrf_file(&[MrfReplicateEntry { bucket: "mrf-durable-seed".to_string(), object: "seed".to_string(), size: 7, op: MrfOpKind::Object, ..Default::default() }]) .expect("seed MRF backlog should encode"); shared.delay_first_read.store(true, Ordering::SeqCst); shared.block_next_write.store(true, Ordering::SeqCst); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-durable-staged", shared.clone()))).await; let read_started = shared.first_read_started.notified(); let write_started = shared.write_started.notified(); pool.start_mrf_persister().await; tokio::time::timeout(Duration::from_secs(2), read_started) .await .expect("startup MRF read should be delayed"); pool.mrf_save_tx .send(MrfReplicateEntry { bucket: "mrf-not-yet-durable".to_string(), object: "staged".to_string(), size: 11, op: MrfOpKind::Object, ..Default::default() }) .await .expect("staged MRF entry should be accepted"); tokio::time::sleep(Duration::from_millis(100)).await; assert_eq!( shared.write_count.load(Ordering::SeqCst), 0, "staged entries must not flush before startup recovery is applied" ); *pool.mrf_recovery_result.lock().await = Some(Vec::new()); pool.mrf_recovery_complete.notify_one(); tokio::time::timeout(Duration::from_secs(3), write_started) .await .expect("first flush should block after startup recovery is applied"); let snapshot = durable_mrf_backlog_summary_snapshot(); assert_eq!( snapshot.buckets.iter().find(|bucket| bucket.bucket == "mrf-durable-seed"), Some(&DurableMrfBucketBacklog { bucket: "mrf-durable-seed".to_string(), count: 1, bytes: 7, }) ); assert!( snapshot.buckets.iter().all(|bucket| bucket.bucket != "mrf-not-yet-durable"), "staged entries must not appear in durable metrics before the first successful flush" ); let handle = pool .task_handles .lock() .await .pop() .expect("MRF persister task should be registered"); handle.abort(); let _ = handle.await; set_durable_mrf_backlog_snapshot(DurableMrfBacklogSnapshot::default()); }) .await; } #[tokio::test] async fn mrf_persister_does_not_eager_flush_before_recovery_snapshot() { temp_env::async_with_vars([("RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS", Some("10"))], async { let shared = empty_resync_shared_state(); *shared.data.lock().expect("test data lock should not be poisoned") = encode_mrf_file(&[MrfReplicateEntry::default()]).expect("seed MRF backlog should encode"); shared.delay_first_read.store(true, Ordering::SeqCst); shared.hold_first_read.store(true, Ordering::SeqCst); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-recovery-gate", shared.clone()))).await; pool.start_mrf_processor().await; tokio::time::timeout(Duration::from_secs(2), shared.first_read_started.notified()) .await .expect("processor startup read should be delayed"); pool.start_mrf_persister().await; tokio::time::timeout(Duration::from_secs(2), async { loop { if shared.read_count.load(Ordering::SeqCst) >= 2 { break; } tokio::task::yield_now().await; } }) .await .expect("persister should finish its startup snapshot while processor is delayed"); for index in 0..1000 { pool.mrf_save_tx .send(MrfReplicateEntry { bucket: "mrf-recovery-gate".to_string(), object: format!("staged-{index}"), op: MrfOpKind::Object, ..Default::default() }) .await .expect("failure should be accepted before recovery completes"); } assert_eq!( shared.write_count.load(Ordering::SeqCst), 0, "the eager 1,000-entry threshold must not flush before recovery snapshot completion" ); shared.allow_first_read.notify_one(); let processor_handle = pool.task_handles.lock().await.remove(0); processor_handle.await.expect("processor recovery should complete"); tokio::time::timeout(Duration::from_secs(3), async { loop { let data = shared.data.lock().expect("test data lock should not be poisoned").clone(); if decode_mrf_file(&data).is_ok_and(|entries| entries.len() == 1001) { break; } tokio::task::yield_now().await; } }) .await .expect("staged failures should flush after recovery snapshot completion"); let persister_handle = pool .task_handles .lock() .await .pop() .expect("MRF persister task should be registered"); persister_handle.abort(); }) .await; } #[tokio::test] async fn mrf_persister_seeds_retained_startup_entries() { assert!( runtime_sources::replication_pool().is_none(), "test requires the runtime replication pool to be unavailable" ); temp_env::async_with_vars([("RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS", Some("10"))], async { let shared = empty_resync_shared_state(); let retained = MrfReplicateEntry { bucket: "mrf-replay-seed".to_string(), object: "retained-delete".to_string(), op: MrfOpKind::Delete, target_arns: vec!["arn:rustfs:replication:target-a".to_string()], ..Default::default() }; *shared.data.lock().expect("test data lock should not be poisoned") = encode_mrf_file(std::slice::from_ref(&retained)).expect("MRF entry should encode"); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-seed", shared.clone()))).await; pool.start_mrf_persister().await; pool.start_mrf_processor().await; let processor_handle = pool .task_handles .lock() .await .pop() .expect("MRF processor task should be registered"); processor_handle.await.expect("MRF processor should not panic"); pool.mrf_save_tx .send(MrfReplicateEntry { bucket: "mrf-replay-seed".to_string(), object: "new-failure".to_string(), op: MrfOpKind::Object, ..Default::default() }) .await .expect("new MRF failure should be accepted"); tokio::time::timeout(Duration::from_secs(2), async { loop { let persisted = { let writes = shared.writes.lock().expect("test writes lock should not be poisoned"); writes .iter() .rev() .find(|(file, _)| file == ReplicationMetadataStore::MRF_REPLICATION_FILE) .map(|(_, data)| decode_mrf_file(data).expect("persisted MRF data should decode")) }; if let Some(entries) = persisted && entries.iter().any(|entry| entry.object == retained.object) && entries.iter().any(|entry| entry.object == "new-failure") { break; } tokio::task::yield_now().await; } }) .await .expect("persister flush should retain startup entries"); let persister_handle = pool .task_handles .lock() .await .pop() .expect("MRF persister task should be registered"); persister_handle.abort(); }) .await; } #[tokio::test] async fn mrf_capped_append_retries_and_retains_existing_backlog() { assert!( runtime_sources::replication_pool().is_none(), "test requires the runtime replication pool to be unavailable" ); temp_env::async_with_vars([("RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS", Some("10"))], async { let shared = empty_resync_shared_state(); let retained = (0..MRF_PENDING_CAP) .map(|index| MrfReplicateEntry { bucket: "mrf-capped-retry".to_string(), object: format!("retained-{index}"), op: MrfOpKind::Object, ..Default::default() }) .collect::>(); *shared.data.lock().expect("test data lock should not be poisoned") = encode_mrf_file(&retained).expect("MRF backlog should encode"); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-capped-retry", shared.clone()))).await; pool.start_mrf_persister().await; pool.start_mrf_processor().await; let processor_handle = pool .task_handles .lock() .await .pop() .expect("MRF processor task should be registered"); processor_handle.await.expect("MRF processor should not panic"); tokio::time::timeout(Duration::from_secs(30), async { loop { if shared.write_count.load(Ordering::SeqCst) > 0 { break; } tokio::task::yield_now().await; } }) .await .expect("startup backlog should be flushed before appending a capped batch"); shared.fail_next_write.store(true, Ordering::SeqCst); pool.mrf_save_tx .send(MrfReplicateEntry { bucket: "mrf-capped-retry".to_string(), object: "new-capped-failure".to_string(), op: MrfOpKind::Object, ..Default::default() }) .await .expect("new capped failure should be accepted"); tokio::time::timeout(Duration::from_secs(30), async { loop { let data = shared.data.lock().expect("test data lock should not be poisoned").clone(); if decode_mrf_file(&data).is_ok_and(|entries| { entries.len() == MRF_PENDING_CAP + 1 && entries.first().is_some_and(|entry| entry.object == "retained-0") && entries.last().is_some_and(|entry| entry.object == "new-capped-failure") }) { break; } tokio::task::yield_now().await; } }) .await .expect("a failed capped append should be retried without dropping either batch"); let persister_handle = pool .task_handles .lock() .await .pop() .expect("MRF persister task should be registered"); persister_handle.abort(); }) .await; } #[tokio::test] async fn mrf_capped_append_recognizes_a_post_commit_error_after_a_concurrent_append() { let shared = empty_resync_shared_state(); let initial = MrfReplicateEntry { bucket: "mrf-append-idempotency".to_string(), object: "retained".to_string(), op: MrfOpKind::Object, ..Default::default() }; let appended = MrfReplicateEntry { object: "new-failure".to_string(), ..initial.clone() }; *shared.data.lock().expect("test data lock should not be poisoned") = encode_mrf_file(std::slice::from_ref(&initial)).expect("initial MRF backlog should encode"); shared.fail_after_write.store(true, Ordering::SeqCst); let storage = Arc::new(LoadResyncNodeStore::new("mrf-append-idempotency", shared.clone())); let mut pending_payload = None; assert_eq!( append_mrf_entries_to_disk(std::slice::from_ref(&appended), &storage, &mut pending_payload, &[]).await, None, "the injected post-commit error should leave the capped batch pending" ); let concurrent = MrfReplicateEntry { object: "concurrent-failure".to_string(), ..initial.clone() }; let mut concurrent_payload = None; assert!( append_mrf_entries_to_disk(std::slice::from_ref(&concurrent), &storage, &mut concurrent_payload, &[]) .await .is_some(), "a concurrent node should be able to append after the ambiguous save" ); assert!( append_mrf_entries_to_disk(std::slice::from_ref(&appended), &storage, &mut pending_payload, &[]) .await .is_some(), "retry should recognize the already-committed payload" ); let entries = decode_mrf_file(&shared.data.lock().expect("test data lock should not be poisoned")) .expect("persisted MRF backlog should decode"); assert_eq!(entries.len(), 3); assert_eq!(entries[0].bucket, initial.bucket); assert_eq!(entries[0].object, initial.object); assert_eq!(entries[1].bucket, appended.bucket); assert_eq!(entries[1].object, appended.object); assert_eq!(entries[2].bucket, concurrent.bucket); assert_eq!(entries[2].object, concurrent.object); assert_eq!( shared.write_count.load(Ordering::SeqCst), 2, "retry must not write the appended MRF batch twice" ); } #[tokio::test] async fn mrf_capped_append_recovers_a_late_corrupt_generation() { let shared = empty_resync_shared_state(); let retained = MrfReplicateEntry { bucket: "mrf-late-corruption".to_string(), object: "retained".to_string(), op: MrfOpKind::Object, ..Default::default() }; let appended = MrfReplicateEntry { bucket: retained.bucket.clone(), object: "appended-after-corruption".to_string(), op: MrfOpKind::Object, ..Default::default() }; *shared.data.lock().expect("test data lock should not be poisoned") = vec![0xde, 0xad, 0xbe, 0xef]; let storage = Arc::new(LoadResyncNodeStore::new("mrf-late-corruption", shared.clone())); let mut pending_payload = None; assert!( append_mrf_entries_to_disk( std::slice::from_ref(&appended), &storage, &mut pending_payload, std::slice::from_ref(&retained), ) .await .is_some(), "a corrupt active generation should be quarantined and rebuilt" ); let writes = shared.writes.lock().expect("test writes lock should not be poisoned"); assert!( writes .iter() .any(|(file, data)| file.starts_with(MRF_CORRUPT_FILE_PREFIX) && data == &[0xde, 0xad, 0xbe, 0xef]), "the corrupt active generation should be retained in quarantine" ); let recovered = decode_mrf_file(&shared.data.lock().expect("test data lock should not be poisoned")) .expect("the active MRF generation should be rebuilt"); assert_eq!(recovered.len(), 2); assert_eq!(recovered[0].object, retained.object); assert_eq!(recovered[1].object, appended.object); } #[tokio::test] async fn mrf_persister_appends_startup_staging_overflow_after_recovery() { assert!( runtime_sources::replication_pool().is_none(), "test requires the runtime replication pool to be unavailable" ); temp_env::async_with_vars([("RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS", Some("10"))], async { let shared = empty_resync_shared_state(); let retained = vec![MrfReplicateEntry::default(); MRF_PENDING_CAP]; *shared.data.lock().expect("test data lock should not be poisoned") = encode_mrf_file(&retained).expect("full startup MRF backlog should encode"); shared.delay_first_read.store(true, Ordering::SeqCst); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-staged-overflow", shared.clone()))).await; let read_started = shared.first_read_started.notified(); pool.start_mrf_persister().await; tokio::time::timeout(Duration::from_secs(2), read_started) .await .expect("persister startup read should be delayed while staging the overflow entry"); pool.mrf_save_tx .send(MrfReplicateEntry { bucket: "mrf-staged-overflow".to_string(), object: "staged-overflow".to_string(), op: MrfOpKind::Object, ..Default::default() }) .await .expect("overflow entry should be staged before startup recovery completes"); *pool.mrf_recovery_result.lock().await = Some(Vec::new()); pool.mrf_recovery_complete.notify_one(); tokio::time::timeout(Duration::from_secs(30), async { loop { let data = shared.data.lock().expect("test data lock should not be poisoned").clone(); if decode_mrf_file(&data).is_ok_and(|entries| { entries.len() == MRF_PENDING_CAP + 1 && entries.last().is_some_and(|entry| entry.object == "staged-overflow") }) { break; } tokio::task::yield_now().await; } }) .await .expect("persister should append staged overflow through its capped recovery path"); let handle = pool .task_handles .lock() .await .pop() .expect("MRF persister task should be registered"); handle.abort(); }) .await; } #[tokio::test] async fn mrf_recovery_shrink_refills_pending_and_flushes_before_shutdown() { assert!( runtime_sources::replication_pool().is_none(), "test requires the runtime replication pool to be unavailable" ); temp_env::async_with_vars([("RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS", Some("60000"))], async { let shared = empty_resync_shared_state(); let retained = vec![MrfReplicateEntry::default(); MRF_PENDING_CAP]; *shared.data.lock().expect("test data lock should not be poisoned") = encode_mrf_file(&retained).expect("full startup MRF backlog should encode"); shared.delay_first_read.store(true, Ordering::SeqCst); shared.block_next_write.store(true, Ordering::SeqCst); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-recovery-shrink", shared.clone()))).await; let read_started = shared.first_read_started.notified(); let write_started = shared.write_started.notified(); pool.start_mrf_persister().await; tokio::time::timeout(Duration::from_secs(2), read_started) .await .expect("startup MRF read should be delayed"); for object in ["staged-overflow-1", "staged-overflow-2", "staged-overflow-3"] { pool.mrf_save_tx .send(MrfReplicateEntry { bucket: "mrf-recovery-shrink".to_string(), object: object.to_string(), op: MrfOpKind::Object, ..Default::default() }) .await .expect("overflow entry should be staged before recovery completes"); } *pool.mrf_recovery_result.lock().await = Some(vec![MrfReplicateEntry::default(); MRF_PENDING_CAP - 2]); pool.mrf_recovery_complete.notify_one(); tokio::time::timeout(Duration::from_secs(5), write_started) .await .expect("recovery shrink should trigger the normal pending flush before shutdown"); assert!( decode_mrf_file(&shared.data.lock().expect("test data lock should not be poisoned")) .expect("the blocked write should leave the old backlog readable") .iter() .all(|entry| !entry.object.starts_with("staged-overflow-")), "the staged overflow must remain pending until the flush completes" ); shared.allow_write.notify_one(); tokio::time::timeout(Duration::from_secs(30), async { loop { if shared.write_count.load(Ordering::SeqCst) >= 2 { break; } tokio::task::yield_now().await; } }) .await .expect("the recovered pending prefix should persist before the task is stopped"); let persisted = shared .writes .lock() .expect("test writes lock should not be poisoned") .iter() .rev() .find(|(file, _)| file == ReplicationMetadataStore::MRF_REPLICATION_FILE) .map(|(_, data)| decode_mrf_file(data).expect("persisted MRF data should decode")) .expect("the capped suffix should be persisted after the pending flush"); assert_eq!(persisted.len(), MRF_PENDING_CAP + 1); assert_eq!(persisted[MRF_PENDING_CAP - 1].object, "staged-overflow-1"); assert_eq!(persisted[MRF_PENDING_CAP].object, "staged-overflow-2"); assert_eq!(persisted.last().expect("capped suffix should be present").object, "staged-overflow-3"); let handle = pool .task_handles .lock() .await .pop() .expect("MRF persister task should be registered"); handle.abort(); }) .await; } #[test] fn mrf_durable_tracker_flushes_only_the_new_suffix() { let retained = MrfReplicateEntry { bucket: "mrf-tracker-suffix".to_string(), size: 3, ..Default::default() }; let appended = MrfReplicateEntry { bucket: retained.bucket.clone(), size: 5, ..Default::default() }; let pending = [retained.clone(), appended]; let mut tracker = durable_mrf_backlog_tracker_from_entries(std::slice::from_ref(&retained)); add_durable_mrf_suffix(&mut tracker, &pending, 1); let snapshot = tracker.into_snapshot(); assert_eq!(snapshot.summary.buckets.len(), 1); assert_eq!(snapshot.summary.buckets[0].count, 2); assert_eq!(snapshot.summary.buckets[0].bytes, 8); } #[test] fn move_staged_mrf_entries_releases_staging_capacity() { let mut pending = vec![MrfReplicateEntry::default(); MRF_PENDING_CAP - 1]; let mut staged = Vec::with_capacity(2); staged.push(MrfReplicateEntry { object: "pending-entry".to_string(), ..Default::default() }); staged.push(MrfReplicateEntry { object: "capped-entry".to_string(), ..Default::default() }); let capped_batch = move_staged_mrf_entries(&mut pending, &mut staged); assert_eq!(pending.len(), MRF_PENDING_CAP); assert_eq!(pending.last().expect("pending prefix should be filled").object, "pending-entry"); assert_eq!(capped_batch.len(), 1); assert_eq!(capped_batch[0].object, "capped-entry"); assert_eq!(staged.capacity(), 0, "the staging allocation should be released after the move"); } #[tokio::test] async fn mrf_delete_replay_result_is_retained_when_runtime_pool_is_unavailable() { assert!( runtime_sources::replication_pool().is_none(), "test requires the runtime replication pool to be unavailable" ); let shared = empty_resync_shared_state(); let entry = MrfReplicateEntry { bucket: "mrf-replay-retry".to_string(), object: "destructive-delete".to_string(), op: MrfOpKind::Delete, target_arns: vec!["arn:rustfs:replication:target-a".to_string()], ..Default::default() }; *shared.data.lock().expect("test data lock should not be poisoned") = encode_mrf_file(std::slice::from_ref(&entry)).expect("MRF entry should encode"); let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("mrf-retry", shared.clone()))).await; pool.start_mrf_processor().await; let handle = pool .task_handles .lock() .await .pop() .expect("MRF processor task should be registered"); handle.await.expect("MRF processor should not panic"); let retained = pool .mrf_recovery_result .lock() .await .take() .expect("processor should publish retry entries") .pop() .expect("the unavailable runtime pool should retain the entry"); assert_eq!(retained.bucket, entry.bucket); assert_eq!(retained.object, entry.object); assert_eq!(retained.version_id, entry.version_id); assert_eq!(retained.target_arns, entry.target_arns); } #[test] fn mrf_entry_delete_marker_roundtrip() { let dm_vid = Uuid::new_v4(); // A specific, non-now() nanosecond timestamp: replay must preserve this exact value // instead of stamping the replica with the current time (backlog#867). let mtime_nanos = 1_705_312_200_123_456_789i64; let entry = MrfReplicateEntry { bucket: "del-bucket".to_string(), object: "key".to_string(), version_id: None, retry_count: 0, size: 0, op: MrfOpKind::Delete, force_delete: false, delete_marker_version_id: Some(dm_vid), delete_marker: true, delete_marker_mtime: Some(mtime_nanos), target_arns: Vec::new(), ..Default::default() }; let encoded = encode_mrf_file(std::slice::from_ref(&entry)).expect("encode"); let decoded = decode_mrf_file(&encoded).expect("decode"); assert_eq!(decoded.len(), 1); let got = &decoded[0]; assert_eq!(got.bucket, "del-bucket"); assert_eq!(got.object, "key"); assert_eq!(got.version_id, None); assert_eq!(got.op, MrfOpKind::Delete); assert_eq!(got.delete_marker_version_id, Some(dm_vid)); assert!(got.delete_marker); assert_eq!( got.delete_marker_mtime, Some(mtime_nanos), "delete-marker mtime must survive the MRF disk round-trip" ); } #[test] fn mrf_entry_versioned_delete_roundtrip() { let vid = Uuid::new_v4(); let entry = MrfReplicateEntry { bucket: "ver-bucket".to_string(), object: "versioned-key".to_string(), version_id: Some(vid), retry_count: 0, size: 0, op: MrfOpKind::Delete, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }; let encoded = encode_mrf_file(&[entry]).expect("encode"); let decoded = decode_mrf_file(&encoded).expect("decode"); assert_eq!(decoded.len(), 1); let got = &decoded[0]; assert_eq!(got.op, MrfOpKind::Delete); assert_eq!(got.version_id, Some(vid)); assert_eq!(got.delete_marker_version_id, None); assert!(!got.delete_marker); } #[test] fn mrf_entry_mixed_batch_roundtrip() { let obj_vid = Uuid::new_v4(); let del_dm_vid = Uuid::new_v4(); let entries = vec![ MrfReplicateEntry { bucket: "b".to_string(), object: "obj".to_string(), version_id: Some(obj_vid), retry_count: 1, size: 512, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }, MrfReplicateEntry { bucket: "b".to_string(), object: "del".to_string(), version_id: None, retry_count: 0, size: 0, op: MrfOpKind::Delete, force_delete: false, delete_marker_version_id: Some(del_dm_vid), delete_marker: true, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }, ]; let encoded = encode_mrf_file(&entries).expect("encode"); let decoded = decode_mrf_file(&encoded).expect("decode"); assert_eq!(decoded.len(), 2); assert_eq!(decoded[0].op, MrfOpKind::Object); assert_eq!(decoded[0].version_id, Some(obj_vid)); assert_eq!(decoded[1].op, MrfOpKind::Delete); assert_eq!(decoded[1].delete_marker_version_id, Some(del_dm_vid)); assert!(decoded[1].delete_marker); } // ── Recovery replay routing ─────────────────────────────────────────────── #[test] fn mrf_entry_op_routes_correctly() { // Object entries must have op=Object so the processor calls get_object_info + heal. let obj_entry = MrfReplicateEntry { bucket: "b".to_string(), object: "o".to_string(), version_id: None, retry_count: 0, size: 0, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }; assert_eq!(obj_entry.op, MrfOpKind::Object); // Delete entries must have op=Delete so the processor calls schedule_replication_delete. let del_entry = MrfReplicateEntry { bucket: "b".to_string(), object: "o".to_string(), version_id: None, retry_count: 0, size: 0, op: MrfOpKind::Delete, force_delete: false, delete_marker_version_id: Some(Uuid::new_v4()), delete_marker: true, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }; assert_eq!(del_entry.op, MrfOpKind::Delete); // Entries written by old code (before the op field existed) must deserialise as Object // so existing recovery behaviour is preserved. let legacy_entry = MrfReplicateEntry { bucket: "b".to_string(), object: "o".to_string(), version_id: None, retry_count: 0, size: 0, op: MrfOpKind::default(), force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }; assert_eq!(legacy_entry.op, MrfOpKind::Object, "legacy default must be Object"); } #[test] fn mrf_legacy_file_without_op_field_decoded_as_object() { // Hand-build the exact bytes a pre-MrfOpKind binary would have written to disk. // The old MrfReplicateEntry had only 4 persisted keys (versionID is omitted when // None due to skip_serializing_if): bucket, object, retryCount, size. // There is no "op", "deleteMarker", or "deleteMarkerVersionID" key. // // This proves that #[serde(default)] on the `op` field carries real weight: // if you remove that attribute, rmp_serde will return an error on this payload // and the test will fail. let mut msgpack = Vec::new(); // Outer: array of 1 (the Vec) rmp::encode::write_array_len(&mut msgpack, 1).unwrap(); // Inner: named map with the 4 original fields only — no "op", no "deleteMarker*" rmp::encode::write_map_len(&mut msgpack, 4).unwrap(); rmp::encode::write_str(&mut msgpack, "bucket").unwrap(); rmp::encode::write_str(&mut msgpack, "old-bucket").unwrap(); rmp::encode::write_str(&mut msgpack, "object").unwrap(); rmp::encode::write_str(&mut msgpack, "old-key").unwrap(); rmp::encode::write_str(&mut msgpack, "retryCount").unwrap(); rmp::encode::write_i32(&mut msgpack, 2).unwrap(); rmp::encode::write_str(&mut msgpack, "size").unwrap(); rmp::encode::write_i64(&mut msgpack, 100).unwrap(); // Prepend the MRF file header: format=1 (LE u16) || version=1 (LE u16) let mut data = Vec::with_capacity(4 + msgpack.len()); data.extend_from_slice(&1u16.to_le_bytes()); // MRF_META_FORMAT data.extend_from_slice(&1u16.to_le_bytes()); // MRF_META_VERSION data.extend_from_slice(&msgpack); let decoded = decode_mrf_file(&data).expect("legacy payload must decode without error"); assert_eq!(decoded.len(), 1); let entry = &decoded[0]; assert_eq!(entry.bucket, "old-bucket"); assert_eq!(entry.object, "old-key"); assert_eq!(entry.retry_count, 2); assert_eq!(entry.size, 100); assert_eq!(entry.version_id, None); // The "op" key was absent — #[serde(default)] must fill in MrfOpKind::Object. assert_eq!(entry.op, MrfOpKind::Object, "missing op key must default to Object"); assert!(!entry.delete_marker); assert_eq!(entry.delete_marker_version_id, None); // The "deleteMarkerMtime" key was absent in old files — #[serde(default)] must fill in // None so replay falls back to the current time (backlog#867 backward compatibility). assert_eq!(entry.delete_marker_mtime, None, "missing deleteMarkerMtime key must default to None"); assert!(entry.target_arns.is_empty(), "old MRF entries must not be attributed to a target"); } #[test] fn durable_mrf_snapshot_reads_restart_backlog_and_valid_empty_state() { let entries = vec![MrfReplicateEntry { bucket: "restart-bucket".to_string(), object: "object".to_string(), version_id: None, retry_count: 1, size: 512, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }]; let encoded = encode_mrf_file(&entries).expect("durable MRF backlog should encode"); let recovered = durable_mrf_backlog_from_read(Ok(encoded)); assert!(recovered.available); assert_eq!(recovered.entries.len(), 1); assert_eq!(recovered.entries[0].bucket, "restart-bucket"); assert_eq!(recovered.entries[0].size, 512); let missing_file = durable_mrf_backlog_from_read(Err(EcstoreError::ConfigNotFound)); assert!(missing_file.available); assert!(missing_file.entries.is_empty()); } #[test] fn durable_mrf_summary_aggregates_entries_by_bucket_for_obs() { let snapshot = durable_mrf_backlog_summary_from_sizes([("b1".to_string(), 1024), ("b1".to_string(), 512), ("b2".to_string(), 0)]); let summary = snapshot.summary; assert!(summary.available); let buckets = summary .buckets .into_iter() .map(|bucket| (bucket.bucket.clone(), bucket)) .collect::>(); assert_eq!(buckets["b1"].count, 2); assert_eq!(buckets["b1"].bytes, 1536); assert_eq!(buckets["b2"].count, 1); assert_eq!(buckets["b2"].bytes, 0); assert!(snapshot.targets.is_empty()); } #[test] fn durable_mrf_summary_aggregates_target_backlog_without_attributing_legacy_entries() { let entries = vec![ MrfReplicateEntry { bucket: "b1".to_string(), object: "object-a".to_string(), version_id: None, retry_count: 0, size: 1024, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: vec!["arn:target-a".to_string(), "arn:target-b".to_string()], ..Default::default() }, MrfReplicateEntry { bucket: "b1".to_string(), object: "object-b".to_string(), version_id: None, retry_count: 0, size: 512, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: vec!["arn:target-a".to_string()], ..Default::default() }, MrfReplicateEntry { bucket: "b1".to_string(), object: "legacy-object".to_string(), version_id: None, retry_count: 0, size: 256, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }, ]; let snapshot = durable_mrf_backlog_summary_from_entries(&entries); let summary = snapshot.summary; assert!(summary.available); let buckets = summary .buckets .into_iter() .map(|bucket| (bucket.bucket.clone(), bucket)) .collect::>(); assert_eq!(buckets["b1"].count, 3); assert_eq!(buckets["b1"].bytes, 1792); let targets = snapshot .targets .into_iter() .map(|target| ((target.bucket.clone(), target.target_arn.clone()), target)) .collect::>(); let target_a = &targets[&("b1".to_string(), "arn:target-a".to_string())]; assert_eq!(target_a.count, 2); assert_eq!(target_a.bytes, 1536); let target_b = &targets[&("b1".to_string(), "arn:target-b".to_string())]; assert_eq!(target_b.count, 1); assert_eq!(target_b.bytes, 1024); } #[test] fn durable_mrf_summary_marks_invalid_sizes_unavailable() { let invalid = durable_mrf_backlog_summary_from_sizes([("bucket".to_string(), -1)]); let summary = invalid.summary; assert!(!summary.available); assert!(summary.buckets.is_empty()); assert!(invalid.targets.is_empty()); } #[test] fn durable_mrf_snapshot_marks_corrupt_or_invalid_data_unavailable() { let corrupt = durable_mrf_backlog_from_read(Ok(vec![0, 1, 2])); assert!(!corrupt.available); assert!(corrupt.entries.is_empty()); let negative = encode_mrf_file(&[MrfReplicateEntry { bucket: "bucket".to_string(), object: "object".to_string(), version_id: None, retry_count: 0, size: -1, op: MrfOpKind::Object, force_delete: false, delete_marker_version_id: None, delete_marker: false, delete_marker_mtime: None, target_arns: Vec::new(), ..Default::default() }]) .expect("invalid persisted entry should still encode for boundary testing"); let invalid = durable_mrf_backlog_from_read(Ok(negative)); assert!(!invalid.available); assert!(invalid.entries.is_empty()); } #[test] fn force_delete_replay_requires_local_commit_and_keeps_persisted_targets() { let operation_id = Uuid::new_v4(); let pending = MrfReplicateEntry { bucket: "source".to_string(), object: "logs/".to_string(), target_arns: vec!["arn:target:old-generation".to_string()], force_delete_id: Some(operation_id), force_delete_generation: Some(11), force_delete_local_commit: false, op: MrfOpKind::Delete, ..Default::default() }; assert!(!should_replay_force_delete_intent(&pending)); let mut committed = pending; committed.force_delete_local_commit = true; let recovered = decode_mrf_file(&encode_mrf_file(&[committed.clone()]).expect("force-delete intent should encode")) .expect("force-delete intent should decode"); assert!(should_replay_force_delete_intent(&recovered[0])); assert_eq!(recovered[0].target_arns, vec!["arn:target:old-generation"]); assert_eq!(recovered[0].force_delete_generation, Some(11)); committed.target_arns.clear(); assert!(!should_replay_force_delete_intent(&committed)); } #[tokio::test] async fn force_delete_intent_append_commit_and_cleanup_are_idempotent() { let shared = empty_resync_shared_state(); let storage = Arc::new(LoadResyncNodeStore::new("force-delete-journal", shared.clone())); let operation_id = Uuid::new_v4(); let entry = MrfReplicateEntry { bucket: "source".to_string(), object: "logs/".to_string(), target_arns: vec!["arn:target:stable".to_string()], force_delete_id: Some(operation_id), force_delete_generation: Some(12), op: MrfOpKind::Delete, ..Default::default() }; persist_force_delete_intent(storage.clone(), entry.clone()) .await .expect("first journal append should succeed"); let preconditions = shared .last_put_preconditions .lock() .expect("test preconditions lock should not be poisoned") .clone() .expect("first journal append should be conditional"); assert_eq!(preconditions.if_none_match_value(), Some("*")); assert_eq!(preconditions.if_match_value(), None); assert!(!shared.last_put_no_lock.load(Ordering::SeqCst)); persist_force_delete_intent(storage.clone(), entry) .await .expect("duplicate journal append should be a no-op"); let data = ReplicationConfigStore::read(storage.clone(), ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE) .await .expect("journal should be readable"); let entries = decode_mrf_file(&data).expect("journal should decode"); assert_eq!(entries.len(), 1); assert!(!entries[0].force_delete_local_commit); assert!(!should_replay_force_delete_intent(&entries[0])); commit_force_delete_intent(storage.clone(), operation_id) .await .expect("commit marker should persist"); let preconditions = shared .last_put_preconditions .lock() .expect("test preconditions lock should not be poisoned") .clone() .expect("commit marker should be conditional"); assert_eq!(preconditions.if_none_match_value(), None); assert_eq!(preconditions.if_match_value(), Some("mrf-1")); assert!(!shared.last_put_no_lock.load(Ordering::SeqCst)); commit_force_delete_intent(storage.clone(), operation_id) .await .expect("duplicate commit marker should be a no-op"); let data = ReplicationConfigStore::read(storage.clone(), ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE) .await .expect("committed journal should be readable"); let entries = decode_mrf_file(&data).expect("committed journal should decode"); assert!(should_replay_force_delete_intent(&entries[0])); assert_eq!(entries[0].target_arns, vec!["arn:target:stable"]); complete_force_delete_intent(storage.clone(), operation_id) .await .expect("journal cleanup should succeed"); complete_force_delete_intent(storage, operation_id) .await .expect("duplicate journal cleanup should be a no-op"); } #[tokio::test] async fn force_delete_intent_cleanup_retries_after_a_stale_journal_snapshot() { let shared = empty_resync_shared_state(); let storage = Arc::new(LoadResyncNodeStore::new("force-delete-journal", shared.clone())); let operation_id = Uuid::new_v4(); let mut entry = MrfReplicateEntry { bucket: "source".to_string(), object: "original".to_string(), force_delete_id: Some(operation_id), op: MrfOpKind::Delete, ..Default::default() }; persist_force_delete_intent(storage.clone(), entry.clone()) .await .expect("journal append should succeed"); commit_force_delete_intent(storage.clone(), operation_id) .await .expect("journal commit should succeed"); entry.force_delete_local_commit = true; let concurrent = MrfReplicateEntry { bucket: "source".to_string(), object: "concurrent".to_string(), force_delete_id: Some(Uuid::new_v4()), op: MrfOpKind::Delete, ..Default::default() }; shared .conditional_write_replacements .lock() .expect("test replacement lock should not be poisoned") .push_back(encode_mrf_file(&[entry, concurrent.clone()]).expect("concurrent journal entries should encode")); complete_force_delete_intent(storage.clone(), operation_id) .await .expect("cleanup should retry after a concurrent journal update"); let data = ReplicationConfigStore::read(storage, ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE) .await .expect("journal should remain readable"); let entries = decode_mrf_file(&data).expect("journal should decode"); assert_eq!(entries.len(), 1, "cleanup must preserve only the concurrent journal entry"); assert_eq!(entries[0].force_delete_id, concurrent.force_delete_id); assert_eq!(entries[0].object, concurrent.object); } #[tokio::test] async fn force_delete_intent_commit_retries_past_the_bounded_cas_conflict_limit() { let shared = empty_resync_shared_state(); let storage = Arc::new(LoadResyncNodeStore::new("force-delete-journal", shared.clone())); let operation_id = Uuid::new_v4(); let entry = MrfReplicateEntry { bucket: "source".to_string(), object: "original".to_string(), force_delete_id: Some(operation_id), op: MrfOpKind::Delete, ..Default::default() }; persist_force_delete_intent(storage.clone(), entry.clone()) .await .expect("journal append should succeed"); { let mut replacements = shared .conditional_write_replacements .lock() .expect("test replacement lock should not be poisoned"); for object in ["first", "second", "third"] { let mut replacement = entry.clone(); replacement.object = object.to_string(); replacements.push_back(encode_mrf_file(&[replacement]).expect("concurrent journal entry should encode")); } } commit_force_delete_intent(storage.clone(), operation_id) .await .expect("commit marker must retry until it is durable"); let data = ReplicationConfigStore::read(storage, ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE) .await .expect("journal should remain readable"); let entries = decode_mrf_file(&data).expect("journal should decode"); assert_eq!(entries.len(), 1); assert!(entries[0].force_delete_local_commit); assert_eq!(entries[0].force_delete_id, Some(operation_id)); } #[tokio::test] async fn force_delete_intent_rejects_existing_journal_without_an_etag() { let shared = empty_resync_shared_state(); let storage = Arc::new(LoadResyncNodeStore::new("force-delete-journal", shared.clone())); let operation_id = Uuid::new_v4(); let entry = MrfReplicateEntry { bucket: "source".to_string(), object: "original".to_string(), force_delete_id: Some(operation_id), op: MrfOpKind::Delete, ..Default::default() }; persist_force_delete_intent(storage.clone(), entry) .await .expect("journal append should succeed"); let writes_before = shared.write_count.load(Ordering::SeqCst); shared.omit_etag.store(true, Ordering::SeqCst); let err = commit_force_delete_intent(storage.clone(), operation_id) .await .expect_err("missing ETag must reject journal mutation"); assert!(err.to_string().contains("no ETag")); assert_eq!(shared.write_count.load(Ordering::SeqCst), writes_before); let data = ReplicationConfigStore::read(storage, ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE) .await .expect("journal should remain readable"); let entries = decode_mrf_file(&data).expect("journal should decode"); assert!(!entries[0].force_delete_local_commit); } #[test] fn force_delete_journal_rejects_a_lost_transaction_lease() { let err = ensure_force_delete_journal_lock_held(true).expect_err("lost transaction lease must fence the journal write"); assert!(err.to_string().contains("lock lost")); } #[tokio::test] async fn force_delete_journal_rejects_a_stale_conditional_write() { let shared = empty_resync_shared_state(); let storage = Arc::new(LoadResyncNodeStore::new("force-delete-journal", shared)); let file = ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE; let original = MrfReplicateEntry { bucket: "source".to_string(), object: "original".to_string(), force_delete_id: Some(Uuid::new_v4()), op: MrfOpKind::Delete, ..Default::default() }; ReplicationConfigStore::save( storage.clone(), file, encode_mrf_file(&[original]).expect("initial journal entry should encode"), ) .await .expect("initial journal write should succeed"); let (_, object_info) = ReplicationConfigStore::read_no_lock_with_metadata(storage.clone(), file) .await .expect("journal snapshot should include an ETag"); let stale_preconditions = HTTPPreconditions { if_match: object_info.etag, ..Default::default() }; let replacement = MrfReplicateEntry { bucket: "source".to_string(), object: "replacement".to_string(), force_delete_id: Some(Uuid::new_v4()), op: MrfOpKind::Delete, ..Default::default() }; let replacement_data = encode_mrf_file(&[replacement]).expect("replacement journal entry should encode"); ReplicationConfigStore::save(storage.clone(), file, replacement_data.clone()) .await .expect("concurrent journal write should succeed"); let err = ReplicationConfigStore::save_conditional( storage.clone(), file, encode_mrf_file(&[]).expect("empty journal should encode"), stale_preconditions, ) .await .expect_err("stale journal snapshot must not overwrite newer data"); assert_eq!(err, EcstoreError::PreconditionFailed); assert_eq!( ReplicationConfigStore::read(storage, file) .await .expect("newer journal data should remain readable"), replacement_data ); } }