Files
rustfs/crates/ecstore/src/bucket/replication/replication_pool.rs
T

5770 lines
235 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use 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<MrfReplicateEntry>,
}
#[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<DurableMrfBucketBacklog>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct DurableMrfBacklogSnapshot {
summary: DurableMrfBacklogSummary,
targets: Vec<DurableMrfTargetBacklog>,
}
#[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<MrfBucketBacklogObservability>,
}
static DURABLE_MRF_BACKLOG_SUMMARY: LazyLock<StdRwLock<DurableMrfBacklogSummary>> =
LazyLock::new(|| StdRwLock::new(DurableMrfBacklogSummary::default()));
static DURABLE_MRF_TARGET_BACKLOG: LazyLock<StdRwLock<Vec<DurableMrfTargetBacklog>>> =
LazyLock::new(|| StdRwLock::new(Vec::new()));
static MRF_BACKLOG_OBSERVABILITY: LazyLock<StdRwLock<MrfBacklogObservabilityTracker>> =
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<String, DurableMrfBucketBacklog>,
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<MrfReplicateEntry>, staged: &mut Vec<MrfReplicateEntry>) -> Vec<MrfReplicateEntry> {
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<String, MrfBucketBacklogObservability>,
}
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<Item = &'a MrfReplicateEntry>, 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<Item = &'a MrfReplicateEntry>,
) -> 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<I>(entries: I) -> DurableMrfBacklogSnapshot
where
I: IntoIterator<Item = (String, i64)>,
{
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<DurableMrfTargetBacklog> {
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<Vec<u8>, 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<S: ReplicationObjectIO>(storage: Arc<S>) -> DurableMrfBacklog {
durable_mrf_backlog_from_read(ReplicationConfigStore::read(storage, ReplicationMetadataStore::MRF_REPLICATION_FILE).await)
}
pub async fn persist_force_delete_intent<S: ReplicationStorage>(
storage: Arc<S>,
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<S: ReplicationStorage>(
storage: Arc<S>,
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<S: ReplicationStorage>(
storage: Arc<S>,
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<S, F>(storage: Arc<S>, mut update: F) -> Result<(), EcstoreError>
where
S: ReplicationStorage,
F: FnMut(&mut Vec<MrfReplicateEntry>, bool) -> Result<bool, EcstoreError>,
{
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<S: ReplicationObjectIO>(
storage: Arc<S>,
file: &str,
) -> Result<(Vec<MrfReplicateEntry>, 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<S: ReplicationStorage>(
storage: Arc<S>,
file: &str,
guard: &rustfs_lock::NamespaceLockGuard,
entries: Vec<MrfReplicateEntry>,
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<S: ReplicationStorage>(
storage: Arc<S>,
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::<ResyncActiveConflictError>()
.map(|conflict| conflict.active_resync_id.as_str()),
_ => None,
}
}
/// Main replication pool structure
#[derive(Debug)]
pub struct ReplicationPool<S: ReplicationStorage> {
// Atomic counters for active workers
active_workers: Arc<AtomicI32>,
active_lrg_workers: Arc<AtomicI32>,
active_mrf_workers: Arc<AtomicI32>,
storage: Arc<S>,
// Configuration
priority: RwLock<ReplicationPriority>,
max_workers: RwLock<usize>,
max_l_workers: RwLock<usize>,
// Statistics
stats: Arc<ReplicationStats>,
// Worker channels
workers: RwLock<Vec<Sender<ReplicationOperation>>>,
lrg_workers: RwLock<Vec<Sender<ReplicationOperation>>>,
// MRF (Most Recent Failures) channels
mrf_replica_tx: Sender<ReplicationOperation>,
// Shared among N MRF workers; Arc allows spawning more than one worker.
mrf_replica_rx: Arc<Mutex<Receiver<ReplicationOperation>>>,
mrf_save_tx: Sender<MrfReplicateEntry>,
mrf_save_rx: Mutex<Option<Receiver<MrfReplicateEntry>>>,
mrf_recovery_complete: Arc<Notify>,
mrf_recovery_result: Arc<Mutex<Option<Vec<MrfReplicateEntry>>>>,
// 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<Vec<JoinHandle<()>>>,
// Replication resyncer for handling bucket resync operations
resyncer: Arc<ReplicationResyncer>,
}
impl<S: ReplicationStorage> ReplicationPool<S> {
/// Creates a new replication pool with specified options
pub async fn new(opts: ReplicationPoolOpts, stats: Arc<ReplicationStats>, storage: Arc<S>) -> Arc<Self> {
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<Mutex<...>>.
// 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<usize>,
max_l_workers: Option<usize>,
) {
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<Sender<ReplicationOperation>> {
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<Sender<ReplicationOperation>> {
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<ReplicationOperation>,
active_counter: Arc<AtomicI32>,
stats: Arc<ReplicationStats>,
) {
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<ReplicationOperation>,
active_counter: Arc<AtomicI32>,
stats: Arc<ReplicationStats>,
storage: Arc<S>,
) {
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<ReplicationOperation>,
active_counter: Arc<AtomicI32>,
stats: Arc<ReplicationStats>,
) {
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<Self>,
cancellation_token: CancellationToken,
buckets: Vec<String>,
) -> 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<BucketReplicationResyncStatus, EcstoreError> {
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<Self>, 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<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
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<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
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<Self>, 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<Self>, buckets: Vec<String>, 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<Self>,
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<AtomicI32>,
}
impl ActiveWorkerGuard {
fn new(counter: Arc<AtomicI32>) -> 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<ReplicationStats>,
bucket: String,
size: i64,
is_delete_marker: bool,
op_type: ReplicationType,
target_arns: Vec<String>,
}
impl ReplicationBacklogGuard {
fn for_object(stats: Arc<ReplicationStats>, 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<ReplicationStats>, 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<S: ReplicationStorage>(
operation: ReplicationOperation,
stats: Arc<ReplicationStats>,
storage: Arc<S>,
) {
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<MrfReplicateEntry>,
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<S: ReplicationStorage>(storage: &Arc<S>, 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<S: ReplicationStorage>(entries: &[MrfReplicateEntry], storage: &Arc<S>) -> Option<u64> {
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<S: ReplicationStorage>(
corrupt_generation: &[u8],
entries_to_append: &[MrfReplicateEntry],
known_pending: &[MrfReplicateEntry],
storage: &Arc<S>,
pending_payload: &mut Option<PendingMrfAppend>,
started: Instant,
) -> Option<u64> {
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<S: ReplicationStorage>(
entries_to_append: &[MrfReplicateEntry],
storage: &Arc<S>,
pending_payload: &mut Option<PendingMrfAppend>,
known_pending: &[MrfReplicateEntry],
) -> Option<u64> {
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(&current))
{
return Some(duration_millis_u64(started.elapsed()));
}
let mut entries = match decode_mrf_file(&current) {
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(&current, 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<S: ReplicationObjectIO>(
bucket: &str,
obj_api: Arc<S>,
) -> Result<BucketReplicationResyncStatus, EcstoreError> {
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<BucketReplicationResyncStatus, EcstoreError>;
async fn cancel_bucket_resync(&self, opts: ResyncOpts) -> Result<(), EcstoreError>;
async fn admit_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError>;
async fn activate_bucket_resync(self: Arc<Self>, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError>;
async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError>;
async fn init_resync(
self: Arc<Self>,
cancellation_token: CancellationToken,
buckets: Vec<String>,
) -> Result<(), EcstoreError>;
}
// Implement the trait for ReplicationPool
#[async_trait::async_trait]
impl<S: ReplicationStorage> ReplicationPoolTrait for ReplicationPool<S> {
fn active_workers(&self) -> i32 {
ReplicationPool::<S>::active_workers(self)
}
fn active_mrf_workers(&self) -> i32 {
ReplicationPool::<S>::active_mrf_workers(self)
}
fn active_lrg_workers(&self) -> i32 {
ReplicationPool::<S>::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<BucketReplicationResyncStatus, EcstoreError> {
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<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
self.admit_bucket_resync(opts).await
}
async fn activate_bucket_resync(self: Arc<Self>, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError> {
self.activate_bucket_resync(opts, recovering).await
}
async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError> {
self.start_bucket_resync(opts).await
}
async fn init_resync(
self: Arc<Self>,
cancellation_token: CancellationToken,
buckets: Vec<String>,
) -> 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<S: ReplicationStorage>(storage: Arc<S>) {
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<DynReplicationPool>
})
.await;
assert!(runtime_sources::replication_runtime_initialized());
}
pub fn get_global_replication_pool() -> Option<Arc<DynReplicationPool>> {
runtime_sources::replication_pool()
}
pub fn get_global_replication_stats() -> Option<Arc<ReplicationStats>> {
runtime_sources::replication_stats()
}
pub(crate) async fn schedule_replication<S: ReplicationStorage>(
oi: ObjectInfo,
o: Arc<S>,
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<ObjectInfo>;
type TestListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
type TestObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, EcstoreError>;
struct LoadResyncSharedState {
data: StdMutex<Vec<u8>>,
etag_revision: AtomicUsize,
last_put_preconditions: StdMutex<Option<HTTPPreconditions>>,
last_put_no_lock: AtomicBool,
omit_etag: AtomicBool,
conditional_write_replacements: StdMutex<VecDeque<Vec<u8>>>,
writes: StdMutex<Vec<(String, Vec<u8>)>>,
lock_manager: Arc<rustfs_lock::GlobalLockManager>,
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<LoadResyncSharedState>,
}
impl LoadResyncNodeStore {
fn new(owner: &str, shared: Arc<LoadResyncSharedState>) -> 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<Self::RangeSpec>,
_h: Self::HeaderMap,
_opts: &Self::ObjectOptions,
) -> Result<Self::GetObjectReader, Self::Error> {
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<Self::ObjectInfo, Self::Error> {
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<Self::ObjectInfo, Self::Error> {
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<Self::ObjectInfo, Self::Error> {
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<Self::ObjectInfo, Self::Error> {
Err(EcstoreError::NotImplemented)
}
async fn delete_objects(
&self,
_bucket: &str,
_objects: Vec<Self::ObjectToDelete>,
_opts: Self::ObjectOptions,
) -> (Vec<Self::DeletedObject>, Vec<Option<Self::Error>>) {
(Vec::new(), vec![Some(EcstoreError::NotImplemented)])
}
async fn put_object_metadata(
&self,
_bucket: &str,
_object: &str,
_opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error> {
Err(EcstoreError::NotImplemented)
}
async fn get_object_tags(
&self,
_bucket: &str,
_object: &str,
_opts: &Self::ObjectOptions,
) -> Result<String, Self::Error> {
Err(EcstoreError::NotImplemented)
}
async fn put_object_tags(
&self,
_bucket: &str,
_object: &str,
_tags: &str,
_opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error> {
Err(EcstoreError::NotImplemented)
}
async fn delete_object_tags(
&self,
_bucket: &str,
_object: &str,
_opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error> {
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<Self>,
_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<TestObjectInfoOrErr>;
async fn list_objects_v2(
self: Arc<Self>,
_bucket: &str,
_prefix: &str,
_continuation_token: Option<String>,
_delimiter: Option<String>,
_max_keys: i32,
_fetch_owner: bool,
_start_after: Option<String>,
_incl_deleted: bool,
) -> Result<Self::ListObjectsV2Info, Self::Error> {
Err(EcstoreError::NotImplemented)
}
async fn list_object_versions(
self: Arc<Self>,
_bucket: &str,
_prefix: &str,
_marker: Option<String>,
_version_marker: Option<String>,
_delimiter: Option<String>,
_max_keys: i32,
) -> Result<Self::ListObjectVersionsInfo, Self::Error> {
Err(EcstoreError::NotImplemented)
}
async fn walk(
self: Arc<Self>,
_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<Self::NamespaceLock, Self::Error> {
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<LoadResyncNodeStore>) -> Arc<ReplicationPool<LoadResyncNodeStore>> {
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<LoadResyncNodeStore>, 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<LoadResyncNodeStore>, 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<LoadResyncNodeStore>, 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<u8> {
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<LoadResyncSharedState> {
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<LoadResyncSharedState>,
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::<Vec<_>>();
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::<Vec<_>>();
*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<MrfReplicateEntry>)
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::<HashMap<_, _>>();
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::<HashMap<_, _>>();
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::<HashMap<_, _>>();
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
);
}
}