mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +00:00
test(ecstore): add shared MockWarmBackend test utility for lifecycle and tier tests (#4716)
* test(ecstore): extract shared MockWarmBackend into a test-util feature (backlog#1148 ilm-6) The tier/lifecycle integration tests carried two byte-for-byte copies of an in-memory WarmBackend mock — one in crates/scanner/tests and one in rustfs/src/app — plus duplicated register_mock_tier and polling helpers. Both implemented the same ecstore WarmBackend trait. Consolidate them into ecstore behind a new `test-util` feature, exposed via the `rustfs_ecstore::api::tier::test_util` facade: - MockWarmBackend: in-memory WarmBackend with an operation log (for ordering assertions such as "local delete precedes remote remove") and fault injection (FaultConfig): unreachable, HTTP 5xx, credential rejection, injected latency, plus external_remove to simulate an out-of-band remote deletion. - register_mock_tier / register_mock_tier_backend: register the mock into any TierConfigMgr handle (the global manager used by scanner tests or a per-instance one used by the app tests). - xl.meta transition assertion helpers: read_transition_meta, assert_transition_meta_consistent (cross-shard consistency of the status/tier/remote-key/remote-version-id tuple plus free-version count), and free_version_count. - polling helpers: wait_for_remote_absence, wait_for_object_count, wait_for_free_version_absence. Both existing copies now consume this single definition; `rg 'struct MockWarmBackend'` collapses to one. The feature is enabled only from [dev-dependencies], so it never links into the production binary (resolver 3). Designed for downstream ilm-8 (restore lifecycle) and ilm-11 (tier fault injection matrix). Coordinates with #4706 (ilm-2), which adds op-logging to the scanner mock — that op-logging is now part of this shared surface, so #4706 should rebase onto it. Refs rustfs/backlog#1148 (ilm-6), rustfs/backlog#1155. * test(ecstore): fix shared MockWarmBackend usage after main merge - Access stored objects via MockWarmBackend::contains() instead of the now private inner objects map (fixes E0609 after the shared test-util refactor). - Drop dead ReadCloser/ReaderImpl/DiskAPI imports and the unused transition_api test re-exports the mock extraction left behind. - Reword the scanner/rustfs test-util dependency comments so they no longer embed the literal rustfs_ecstore:: path that trips the ECStore architecture-migration guard.
This commit is contained in:
@@ -34,6 +34,10 @@ workspace = true
|
||||
default = []
|
||||
rio-v2 = ["dep:rustfs-rio-v2"]
|
||||
hotpath = ["dep:hotpath", "hotpath/hotpath", "rustfs-filemeta/hotpath", "rustfs-rio/hotpath"]
|
||||
# Exposes shared lifecycle/tier test utilities (MockWarmBackend, fault
|
||||
# injection, xl.meta transition assertions) via `api::tier::test_util`.
|
||||
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
|
||||
test-util = []
|
||||
|
||||
[dependencies]
|
||||
hotpath = { workspace = true, optional = true }
|
||||
|
||||
@@ -429,4 +429,13 @@ pub mod tier {
|
||||
WarmBackend, WarmBackendGetOpts, WarmBackendImpl, build_transition_put_options, check_warm_backend, new_warm_backend,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::services::tier::test_util::{
|
||||
FaultConfig, MockStoredObject, MockWarmBackend, MockWarmOp, TransitionMeta, assert_transition_meta_consistent,
|
||||
free_version_count, read_transition_meta, register_mock_tier, register_mock_tier_backend,
|
||||
wait_for_free_version_absence,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util;
|
||||
pub mod tier;
|
||||
pub mod tier_admin;
|
||||
pub mod tier_config;
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
// 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.
|
||||
|
||||
//! Shared test utilities for tier / lifecycle integration tests.
|
||||
//!
|
||||
//! Gated behind the `test-util` feature (`rustfs/backlog#1148` ilm-6). This
|
||||
//! module used to be duplicated verbatim in two places — the scanner
|
||||
//! integration tests (`crates/scanner/tests/lifecycle_integration_test.rs`)
|
||||
//! and the app-layer transition tests
|
||||
//! (`rustfs/src/app/lifecycle_transition_api_test.rs`). Both now consume this
|
||||
//! single definition. Consuming it from an external test crate requires
|
||||
//! enabling the feature as a dev-dependency, for example:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dev-dependencies]
|
||||
//! rustfs-ecstore = { workspace = true, features = ["test-util"] }
|
||||
//! ```
|
||||
//!
|
||||
//! and importing through the crate facade
|
||||
//! (`rustfs_ecstore::api::tier::test_util`).
|
||||
//!
|
||||
//! What it provides:
|
||||
//!
|
||||
//! - [`MockWarmBackend`]: an in-memory [`WarmBackend`] implementation backed by
|
||||
//! a `HashMap`. It records an operation log (for ordering assertions such as
|
||||
//! "local delete precedes remote remove") and supports fault injection
|
||||
//! ([`FaultConfig`]): unreachable backend, HTTP 5xx, credential rejection, and
|
||||
//! injected latency. It also exposes [`MockWarmBackend::external_remove`] to
|
||||
//! simulate a remote object being deleted out-of-band.
|
||||
//! - [`register_mock_tier`]: register a [`MockWarmBackend`] as a tier in a
|
||||
//! [`TierConfigMgr`] handle (works with both the global manager and a
|
||||
//! per-instance manager).
|
||||
//! - xl.meta transition-state assertion helpers ([`read_transition_meta`],
|
||||
//! [`assert_transition_meta_consistent`], [`free_version_count`]): read the
|
||||
//! on-disk `xl.meta` and assert the `(status, tier, remote object, remote
|
||||
//! version id)` tuple plus free-version count, cross-checking every shard
|
||||
//! disk for consistency.
|
||||
//! - Polling helpers ([`MockWarmBackend::wait_for_remote_absence`],
|
||||
//! [`MockWarmBackend::wait_for_object_count`],
|
||||
//! [`wait_for_free_version_absence`]) so tests never assert a single read
|
||||
//! against asynchronously-applied state.
|
||||
//!
|
||||
//! Downstream consumers: ilm-8 (restore lifecycle) and ilm-11 (tier fault
|
||||
//! injection matrix) build directly on this surface.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::transition_api::{ReadCloser, ReaderImpl};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::disk::{DiskAPI, DiskOption, STORAGE_FORMAT_FILE, new_disk};
|
||||
use crate::services::tier::tier::TierConfigMgr;
|
||||
use crate::services::tier::tier_config::{TierConfig, TierMinIO, TierType};
|
||||
use crate::services::tier::warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options};
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use rustfs_utils::path::path_join_buf;
|
||||
|
||||
/// Default polling cadence used by the `wait_for_*` helpers.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// A fault to inject into [`MockWarmBackend`] operations.
|
||||
///
|
||||
/// Faults are checked at the start of every trait method. When set, the backend
|
||||
/// returns a representative [`std::io::Error`] instead of performing the
|
||||
/// operation, letting tests exercise transition/restore/sweep error paths
|
||||
/// without a real remote tier. Latency, if set, is applied before the fault
|
||||
/// check so slow-and-failing behaviour can be modelled together.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FaultConfig {
|
||||
/// Simulate a backend that cannot be reached (connection refused).
|
||||
pub unreachable: bool,
|
||||
/// Simulate an HTTP 5xx server error on every operation.
|
||||
pub server_error: bool,
|
||||
/// Simulate rejected / rotated credentials (permission denied).
|
||||
pub reject_credentials: bool,
|
||||
/// Inject latency before each operation resolves.
|
||||
pub latency: Option<Duration>,
|
||||
}
|
||||
|
||||
impl FaultConfig {
|
||||
fn error(&self) -> Option<std::io::Error> {
|
||||
use std::io::{Error, ErrorKind};
|
||||
if self.unreachable {
|
||||
return Some(Error::new(ErrorKind::ConnectionRefused, "mock warm backend unreachable"));
|
||||
}
|
||||
if self.reject_credentials {
|
||||
return Some(Error::new(ErrorKind::PermissionDenied, "mock warm backend rejected credentials"));
|
||||
}
|
||||
if self.server_error {
|
||||
return Some(Error::other("mock warm backend returned HTTP 503"));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A single operation recorded by [`MockWarmBackend`].
|
||||
///
|
||||
/// Ordering assertions (for example, "the local delete lands before any remote
|
||||
/// remove" from the expire/GET race regression) read this log via
|
||||
/// [`MockWarmBackend::op_log`]. [`MockWarmOp::ExternalRemove`] is only produced
|
||||
/// by [`MockWarmBackend::external_remove`], never by the trait `remove`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MockWarmOp {
|
||||
Put { object: String },
|
||||
Get { object: String },
|
||||
Remove { object: String },
|
||||
ExternalRemove { object: String },
|
||||
InUse,
|
||||
}
|
||||
|
||||
/// A stored object inside [`MockWarmBackend`].
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MockStoredObject {
|
||||
pub bytes: Vec<u8>,
|
||||
pub metadata: HashMap<String, String>,
|
||||
pub remote_version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockWarmBackendInner {
|
||||
objects: Mutex<HashMap<String, MockStoredObject>>,
|
||||
faults: Mutex<FaultConfig>,
|
||||
op_log: Mutex<Vec<MockWarmOp>>,
|
||||
}
|
||||
|
||||
/// In-memory [`WarmBackend`] for lifecycle / tiering integration tests.
|
||||
///
|
||||
/// Cloning shares the same underlying storage, fault configuration, and
|
||||
/// operation log (everything is behind an [`Arc`]), so a clone registered in a
|
||||
/// [`TierConfigMgr`] observes the same state as the handle held by the test.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MockWarmBackend {
|
||||
inner: Arc<MockWarmBackendInner>,
|
||||
}
|
||||
|
||||
impl MockWarmBackend {
|
||||
/// Create an empty backend with no faults injected.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// ---- fault injection -------------------------------------------------
|
||||
|
||||
/// Replace the entire fault configuration.
|
||||
pub async fn set_faults(&self, faults: FaultConfig) {
|
||||
*self.inner.faults.lock().await = faults;
|
||||
}
|
||||
|
||||
/// Toggle "backend unreachable" (connection refused on every operation).
|
||||
pub async fn set_unreachable(&self, unreachable: bool) {
|
||||
self.inner.faults.lock().await.unreachable = unreachable;
|
||||
}
|
||||
|
||||
/// Toggle "HTTP 5xx" server errors on every operation.
|
||||
pub async fn set_server_error(&self, server_error: bool) {
|
||||
self.inner.faults.lock().await.server_error = server_error;
|
||||
}
|
||||
|
||||
/// Toggle "credential rejected" (permission denied) on every operation.
|
||||
pub async fn set_reject_credentials(&self, reject: bool) {
|
||||
self.inner.faults.lock().await.reject_credentials = reject;
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) injected latency applied before each op.
|
||||
pub async fn set_latency(&self, latency: Option<Duration>) {
|
||||
self.inner.faults.lock().await.latency = latency;
|
||||
}
|
||||
|
||||
/// Clear all injected faults, restoring healthy behaviour.
|
||||
pub async fn clear_faults(&self) {
|
||||
*self.inner.faults.lock().await = FaultConfig::default();
|
||||
}
|
||||
|
||||
async fn precondition(&self) -> Result<(), std::io::Error> {
|
||||
let (latency, error) = {
|
||||
let faults = self.inner.faults.lock().await;
|
||||
(faults.latency, faults.error())
|
||||
};
|
||||
if let Some(latency) = latency {
|
||||
tokio::time::sleep(latency).await;
|
||||
}
|
||||
match error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- operation log ---------------------------------------------------
|
||||
|
||||
async fn record(&self, op: MockWarmOp) {
|
||||
self.inner.op_log.lock().await.push(op);
|
||||
}
|
||||
|
||||
/// Snapshot of every operation the backend has performed, in order.
|
||||
pub async fn op_log(&self) -> Vec<MockWarmOp> {
|
||||
self.inner.op_log.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Clear the operation log without touching stored objects or faults.
|
||||
pub async fn clear_op_log(&self) {
|
||||
self.inner.op_log.lock().await.clear();
|
||||
}
|
||||
|
||||
/// Number of trait `remove` calls recorded (excludes `external_remove`).
|
||||
pub async fn remove_count(&self) -> usize {
|
||||
self.inner
|
||||
.op_log
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|op| matches!(op, MockWarmOp::Remove { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Number of `get` calls recorded — useful to assert restore reads hit the
|
||||
/// local copy rather than the remote tier.
|
||||
pub async fn get_count(&self) -> usize {
|
||||
self.inner
|
||||
.op_log
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|op| matches!(op, MockWarmOp::Get { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Number of `put` calls recorded.
|
||||
pub async fn put_count(&self) -> usize {
|
||||
self.inner
|
||||
.op_log
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|op| matches!(op, MockWarmOp::Put { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
// ---- storage inspection ---------------------------------------------
|
||||
|
||||
/// Whether the backend currently stores `object`.
|
||||
pub async fn contains(&self, object: &str) -> bool {
|
||||
self.inner.objects.lock().await.contains_key(object)
|
||||
}
|
||||
|
||||
/// Number of objects currently stored.
|
||||
pub async fn object_count(&self) -> usize {
|
||||
self.inner.objects.lock().await.len()
|
||||
}
|
||||
|
||||
/// A clone of the stored object, if present.
|
||||
pub async fn stored(&self, object: &str) -> Option<MockStoredObject> {
|
||||
self.inner.objects.lock().await.get(object).cloned()
|
||||
}
|
||||
|
||||
/// A clone of the raw bytes stored for `object`, if present.
|
||||
pub async fn bytes(&self, object: &str) -> Option<Vec<u8>> {
|
||||
self.inner.objects.lock().await.get(object).map(|o| o.bytes.clone())
|
||||
}
|
||||
|
||||
/// A clone of the metadata stored for `object`, if present.
|
||||
pub async fn metadata(&self, object: &str) -> Option<HashMap<String, String>> {
|
||||
self.inner.objects.lock().await.get(object).map(|o| o.metadata.clone())
|
||||
}
|
||||
|
||||
/// Simulate the remote object being deleted out-of-band (for example by an
|
||||
/// operator or lifecycle rule on the remote tier), bypassing the trait
|
||||
/// `remove`. Records [`MockWarmOp::ExternalRemove`].
|
||||
pub async fn external_remove(&self, object: &str) {
|
||||
self.inner.objects.lock().await.remove(object);
|
||||
self.record(MockWarmOp::ExternalRemove {
|
||||
object: object.to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// ---- polling helpers -------------------------------------------------
|
||||
|
||||
/// Poll until `object` is absent from the backend, or `timeout` elapses.
|
||||
/// Returns `true` if the object disappeared within the budget.
|
||||
pub async fn wait_for_remote_absence(&self, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if !self.contains(object).await {
|
||||
return true;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll until the backend holds exactly `expected` objects, or `timeout`
|
||||
/// elapses. Returns `true` if the count was reached within the budget.
|
||||
pub async fn wait_for_object_count(&self, expected: usize, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if self.object_count().await == expected {
|
||||
return true;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- internal helpers -----------------------------------------------
|
||||
|
||||
async fn put_bytes(&self, object: &str, bytes: Vec<u8>, metadata: HashMap<String, String>) -> String {
|
||||
let remote_version_id = Uuid::new_v4().to_string();
|
||||
self.inner.objects.lock().await.insert(
|
||||
object.to_string(),
|
||||
MockStoredObject {
|
||||
bytes,
|
||||
metadata,
|
||||
remote_version_id: remote_version_id.clone(),
|
||||
},
|
||||
);
|
||||
remote_version_id
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, reader: ReaderImpl) -> Result<Vec<u8>, std::io::Error> {
|
||||
match reader {
|
||||
ReaderImpl::Body(bytes) => Ok(bytes.to_vec()),
|
||||
ReaderImpl::ObjectBody(mut reader) => {
|
||||
let mut buf = Vec::new();
|
||||
reader.stream.read_to_end(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WarmBackend for MockWarmBackend {
|
||||
async fn put(&self, object: &str, r: ReaderImpl, _length: i64) -> Result<String, std::io::Error> {
|
||||
self.precondition().await?;
|
||||
let bytes = self.read_bytes(r).await?;
|
||||
let version = self.put_bytes(object, bytes, HashMap::new()).await;
|
||||
self.record(MockWarmOp::Put {
|
||||
object: object.to_string(),
|
||||
})
|
||||
.await;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn put_with_meta(
|
||||
&self,
|
||||
object: &str,
|
||||
r: ReaderImpl,
|
||||
_length: i64,
|
||||
meta: HashMap<String, String>,
|
||||
) -> Result<String, std::io::Error> {
|
||||
self.precondition().await?;
|
||||
let bytes = self.read_bytes(r).await?;
|
||||
// Mirror the real transition put: promote content headers and drop the
|
||||
// internal replication / object-lock defaults so tests can assert that
|
||||
// transitioned objects don't inherit them.
|
||||
let opts = build_transition_put_options(String::new(), meta);
|
||||
let mut metadata = opts.user_metadata.clone();
|
||||
if !opts.content_type.is_empty() {
|
||||
metadata.insert("content-type".to_string(), opts.content_type.clone());
|
||||
}
|
||||
if !opts.content_encoding.is_empty() {
|
||||
metadata.insert("content-encoding".to_string(), opts.content_encoding.clone());
|
||||
}
|
||||
if !opts.cache_control.is_empty() {
|
||||
metadata.insert("cache-control".to_string(), opts.cache_control.clone());
|
||||
}
|
||||
if !opts.internal.replication_status.as_str().is_empty() {
|
||||
metadata.insert(
|
||||
"x-amz-replication-status".to_string(),
|
||||
opts.internal.replication_status.as_str().to_string(),
|
||||
);
|
||||
}
|
||||
if !opts.legalhold.as_str().is_empty() {
|
||||
metadata.insert("x-amz-object-lock-legal-hold".to_string(), opts.legalhold.as_str().to_string());
|
||||
}
|
||||
let version = self.put_bytes(object, bytes, metadata).await;
|
||||
self.record(MockWarmOp::Put {
|
||||
object: object.to_string(),
|
||||
})
|
||||
.await;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
async fn get(&self, object: &str, _rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
|
||||
self.precondition().await?;
|
||||
self.record(MockWarmOp::Get {
|
||||
object: object.to_string(),
|
||||
})
|
||||
.await;
|
||||
let objects = self.inner.objects.lock().await;
|
||||
let Some(stored) = objects.get(object) else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "mock object not found"));
|
||||
};
|
||||
let bytes = &stored.bytes;
|
||||
|
||||
let start = opts.start_offset.max(0) as usize;
|
||||
let end = if opts.length > 0 {
|
||||
start.saturating_add(opts.length as usize).min(bytes.len())
|
||||
} else {
|
||||
bytes.len()
|
||||
};
|
||||
|
||||
Ok(tokio::io::BufReader::new(Cursor::new(bytes[start.min(bytes.len())..end].to_vec())))
|
||||
}
|
||||
|
||||
async fn remove(&self, object: &str, _rv: &str) -> Result<(), std::io::Error> {
|
||||
self.precondition().await?;
|
||||
self.inner.objects.lock().await.remove(object);
|
||||
self.record(MockWarmOp::Remove {
|
||||
object: object.to_string(),
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn in_use(&self) -> Result<bool, std::io::Error> {
|
||||
self.precondition().await?;
|
||||
self.record(MockWarmOp::InUse).await;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register `backend` as a MinIO-typed tier named `tier_name` in the tier
|
||||
/// config manager reachable through `handle`, returning a clone of the backend
|
||||
/// that shares its state.
|
||||
///
|
||||
/// Works with any [`TierConfigMgr`] handle — the global one
|
||||
/// (`rustfs_ecstore::api::runtime::global_tier_config_mgr`) used by the scanner
|
||||
/// integration tests, or a per-instance one used by the app-layer tests.
|
||||
pub async fn register_mock_tier(handle: &Arc<RwLock<TierConfigMgr>>, tier_name: &str) -> MockWarmBackend {
|
||||
let backend = MockWarmBackend::new();
|
||||
register_mock_tier_backend(handle, tier_name, backend.clone()).await;
|
||||
backend
|
||||
}
|
||||
|
||||
/// Like [`register_mock_tier`] but registers an existing `backend` (for example
|
||||
/// one that already had faults injected before wiring it up).
|
||||
pub async fn register_mock_tier_backend(handle: &Arc<RwLock<TierConfigMgr>>, tier_name: &str, backend: MockWarmBackend) {
|
||||
let mut tier_config_mgr = handle.write().await;
|
||||
tier_config_mgr.tiers.insert(
|
||||
tier_name.to_string(),
|
||||
TierConfig {
|
||||
version: "v1".to_string(),
|
||||
tier_type: TierType::MinIO,
|
||||
name: tier_name.to_string(),
|
||||
minio: Some(TierMinIO {
|
||||
access_key: "minioadmin".to_string(),
|
||||
secret_key: "minioadmin".to_string(),
|
||||
bucket: "mock-tier".to_string(),
|
||||
endpoint: "http://127.0.0.1:0".to_string(),
|
||||
prefix: format!("mock/{}/", Uuid::new_v4()),
|
||||
region: String::new(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
tier_config_mgr.driver_cache.insert(tier_name.to_string(), Box::new(backend));
|
||||
}
|
||||
|
||||
/// The transition-state tuple read from an on-disk `xl.meta`, plus the object's
|
||||
/// free-version count.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TransitionMeta {
|
||||
/// `transition_status` (e.g. `"complete"`), empty when not transitioned.
|
||||
pub status: String,
|
||||
/// The tier name the object was transitioned to.
|
||||
pub tier: String,
|
||||
/// The remote object key (`transitioned_objname`).
|
||||
pub remote_object: String,
|
||||
/// The remote version id (`transition_version_id`), if any.
|
||||
pub remote_version_id: Option<String>,
|
||||
/// Number of free versions retained for the object.
|
||||
pub free_version_count: usize,
|
||||
}
|
||||
|
||||
async fn open_disk(disk_path: &Path) -> Option<crate::disk::DiskStore> {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str()?).ok()?;
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Count the free versions retained for `object` on a single disk's `xl.meta`.
|
||||
///
|
||||
/// The free-version metadata removal lands asynchronously after the remote
|
||||
/// object disappears, so callers typically poll via
|
||||
/// [`wait_for_free_version_absence`] instead of asserting a single read.
|
||||
pub async fn free_version_count(disk_path: &Path, bucket: &str, object: &str) -> usize {
|
||||
let Some(disk) = open_disk(disk_path).await else {
|
||||
return 0;
|
||||
};
|
||||
let Ok(data) = disk
|
||||
.read_metadata(bucket, &path_join_buf(&[object, STORAGE_FORMAT_FILE]))
|
||||
.await
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
let Ok(meta) = FileMeta::load(&data) else {
|
||||
return 0;
|
||||
};
|
||||
meta.get_file_info_versions(bucket, object, false)
|
||||
.map(|v| v.free_versions.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read the transition-state tuple for `object` from a single disk's `xl.meta`.
|
||||
///
|
||||
/// Returns `None` if the metadata cannot be read or decoded. The transition
|
||||
/// fields are taken from the newest version that carries a transition record;
|
||||
/// if no version is transitioned, they are taken from the current version (and
|
||||
/// will be empty).
|
||||
pub async fn read_transition_meta(disk_path: &Path, bucket: &str, object: &str) -> Option<TransitionMeta> {
|
||||
let disk = open_disk(disk_path).await?;
|
||||
let data = disk
|
||||
.read_metadata(bucket, &path_join_buf(&[object, STORAGE_FORMAT_FILE]))
|
||||
.await
|
||||
.ok()?;
|
||||
let meta = FileMeta::load(&data).ok()?;
|
||||
let versions = meta.get_file_info_versions(bucket, object, false).ok()?;
|
||||
let free_version_count = versions.free_versions.len();
|
||||
|
||||
let fi = versions
|
||||
.versions
|
||||
.iter()
|
||||
.find(|v| !v.transition_status.is_empty() || !v.transitioned_objname.is_empty())
|
||||
.or_else(|| versions.versions.first())?;
|
||||
|
||||
Some(TransitionMeta {
|
||||
status: fi.transition_status.clone(),
|
||||
tier: fi.transition_tier.clone(),
|
||||
remote_object: fi.transitioned_objname.clone(),
|
||||
remote_version_id: fi.transition_version_id.map(|id| id.to_string()),
|
||||
free_version_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that every disk in `disk_paths` reports the same transition tuple for
|
||||
/// `object`, returning that tuple. Panics with a descriptive message if any
|
||||
/// disk is missing the object or disagrees — this is the shard-consistency
|
||||
/// check required by ilm-6 (the `(status, tier, remote key, remote version id)`
|
||||
/// four-tuple plus free-version count must match across all erasure shards).
|
||||
pub async fn assert_transition_meta_consistent<P: AsRef<Path>>(disk_paths: &[P], bucket: &str, object: &str) -> TransitionMeta {
|
||||
assert!(!disk_paths.is_empty(), "assert_transition_meta_consistent needs at least one disk");
|
||||
|
||||
let mut expected: Option<TransitionMeta> = None;
|
||||
for disk_path in disk_paths {
|
||||
let disk_path = disk_path.as_ref();
|
||||
let meta = read_transition_meta(disk_path, bucket, object)
|
||||
.await
|
||||
.unwrap_or_else(|| panic!("missing xl.meta for {bucket}/{object} on disk {}", disk_path.display()));
|
||||
match &expected {
|
||||
None => expected = Some(meta),
|
||||
Some(expected) => assert_eq!(
|
||||
*expected,
|
||||
meta,
|
||||
"transition metadata mismatch for {bucket}/{object} on disk {} (expected {expected:?}, got {meta:?})",
|
||||
disk_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
expected.expect("at least one disk was inspected")
|
||||
}
|
||||
|
||||
/// Poll until `object` retains no free versions on `disk_path`, or `timeout`
|
||||
/// elapses. Returns `true` if the free versions drained within the budget.
|
||||
pub async fn wait_for_free_version_absence(disk_path: &Path, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if free_version_count(disk_path, bucket, object).await == 0 {
|
||||
return true;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ serial_test = { workspace = true }
|
||||
temp-env = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
# Enables the shared MockWarmBackend / xl.meta assertion helpers exposed via
|
||||
# the ecstore `api::tier::test_util` facade module (rustfs/backlog#1148 ilm-6).
|
||||
rustfs-ecstore = { workspace = true, features = ["test-util"] }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -14,27 +14,23 @@
|
||||
|
||||
use futures::FutureExt;
|
||||
use rustfs_config::ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT;
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use rustfs_scanner::scanner_folder::ScannerItem;
|
||||
use rustfs_scanner::scanner_io::ScannerIODisk;
|
||||
use rustfs_scanner::{
|
||||
ScannerObjectInfo as ObjectInfo, ScannerObjectOptions as ObjectOptions, ScannerPutObjReader as PutObjReader,
|
||||
scanner::init_data_scanner,
|
||||
};
|
||||
use rustfs_utils::path::path_join_buf;
|
||||
use s3s::dto::RestoreRequest;
|
||||
use serial_test::serial;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
io::Cursor,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Once, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
@@ -42,13 +38,13 @@ use uuid::Uuid;
|
||||
mod storage_api;
|
||||
|
||||
use storage_api::lifecycle::{
|
||||
BUCKET_LIFECYCLE_CONFIG, BucketOperations, BucketOptions, BucketVersioningSys, CompletePart, DiskAPI as _, DiskOption,
|
||||
ECStore, EcstoreError, Endpoint, EndpointServerPools, Endpoints, IlmAction, LcEvent, LcEventSrc, ListOperations as _,
|
||||
MakeBucketOptions, MultipartOperations as _, ObjectIO as _, ObjectOperations as _, PoolEndpoints, ReadCloser, ReaderImpl,
|
||||
STORAGE_FORMAT_FILE, ScannerWarmBackend, TierConfig, TierMinIO, TierType, TransitionOptions, WarmBackendGetOpts,
|
||||
build_transition_put_options, enqueue_transition_for_existing_objects, expire_transitioned_object, get_bucket_metadata,
|
||||
get_global_tier_config_mgr, init_background_expiry, init_bucket_metadata_sys, init_local_disks, is_err_object_not_found,
|
||||
is_err_version_not_found, new_disk, path2_bucket_object_with_base_path, update_bucket_metadata,
|
||||
BUCKET_LIFECYCLE_CONFIG, BucketOperations, BucketOptions, BucketVersioningSys, CompletePart, DiskOption, ECStore,
|
||||
EcstoreError, Endpoint, EndpointServerPools, Endpoints, IlmAction, LcEvent, LcEventSrc, ListOperations as _,
|
||||
MakeBucketOptions, MockWarmBackend, MultipartOperations as _, ObjectIO as _, ObjectOperations as _, PoolEndpoints,
|
||||
STORAGE_FORMAT_FILE, TransitionOptions, assert_transition_meta_consistent, enqueue_transition_for_existing_objects,
|
||||
expire_transitioned_object, free_version_count, get_bucket_metadata, get_global_tier_config_mgr, init_background_expiry,
|
||||
init_bucket_metadata_sys, init_local_disks, is_err_object_not_found, is_err_version_not_found, new_disk,
|
||||
path2_bucket_object_with_base_path, register_mock_tier_util, update_bucket_metadata, wait_for_free_version_absence,
|
||||
};
|
||||
|
||||
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
|
||||
@@ -397,67 +393,6 @@ async fn wait_for_object_absence(ecstore: &Arc<ECStore>, bucket: &str, object: &
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_remote_absence(backend: &MockWarmBackend, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if !backend.objects.lock().await.contains_key(object) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn free_version_count(disk_path: &Path, bucket: &str, object: &str) -> usize {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("failed to open local disk");
|
||||
let data = disk
|
||||
.read_metadata(bucket, &path_join_buf(&[object, STORAGE_FORMAT_FILE]))
|
||||
.await;
|
||||
let Ok(data) = data else {
|
||||
return 0;
|
||||
};
|
||||
let meta = FileMeta::load(&data).expect("failed to load file metadata");
|
||||
meta.get_file_info_versions(bucket, object, false)
|
||||
.expect("failed to decode file info versions")
|
||||
.free_versions
|
||||
.len()
|
||||
}
|
||||
|
||||
/// The free-version metadata removal lands asynchronously after the remote
|
||||
/// object disappears, so poll instead of asserting a single read.
|
||||
async fn wait_for_free_version_absence(disk_path: &Path, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if free_version_count(disk_path, bucket, object).await == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn object_version_count(ecstore: &Arc<ECStore>, bucket: &str, object: &str) -> usize {
|
||||
let mut marker = None;
|
||||
let mut version_marker = None;
|
||||
@@ -499,22 +434,6 @@ async fn wait_for_version_count(ecstore: &Arc<ECStore>, bucket: &str, object: &s
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_remote_object_count(backend: &MockWarmBackend, expected: usize, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if backend.objects.lock().await.len() == expected {
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn scan_object_with_lifecycle(disk_path: &Path, bucket: &str, object: &str) {
|
||||
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
|
||||
endpoint.set_pool_index(0);
|
||||
@@ -595,147 +514,11 @@ async fn scan_object_metadata(disk_path: &Path, bucket: &str, object: &str) {
|
||||
disk.get_size(item).await.expect("scanner get_size should succeed");
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MockStoredObject {
|
||||
bytes: Vec<u8>,
|
||||
metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MockWarmBackend {
|
||||
objects: Arc<Mutex<HashMap<String, MockStoredObject>>>,
|
||||
// Ordered log of remote-tier mutating operations observed by this backend
|
||||
// (e.g. `remove:<obj>`). Used by the expire/GET race regression test to
|
||||
// assert that `expire_transitioned_object` never issues a synchronous
|
||||
// remote-tier removal (the #3491 local-first ordering contract).
|
||||
ops_log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MockWarmBackend {
|
||||
async fn remote_remove_count(&self) -> usize {
|
||||
self.ops_log
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|op| op.starts_with("remove:"))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
impl MockWarmBackend {
|
||||
async fn put_bytes(&self, object: &str, bytes: Vec<u8>, metadata: HashMap<String, String>) -> String {
|
||||
self.objects
|
||||
.lock()
|
||||
.await
|
||||
.insert(object.to_string(), MockStoredObject { bytes, metadata });
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, reader: ReaderImpl) -> Result<Vec<u8>, std::io::Error> {
|
||||
match reader {
|
||||
ReaderImpl::Body(bytes) => Ok(bytes.to_vec()),
|
||||
ReaderImpl::ObjectBody(mut reader) => {
|
||||
let mut buf = Vec::new();
|
||||
reader.stream.read_to_end(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ScannerWarmBackend for MockWarmBackend {
|
||||
async fn put(&self, object: &str, r: ReaderImpl, _length: i64) -> Result<String, std::io::Error> {
|
||||
let bytes = self.read_bytes(r).await?;
|
||||
Ok(self.put_bytes(object, bytes, HashMap::new()).await)
|
||||
}
|
||||
|
||||
async fn put_with_meta(
|
||||
&self,
|
||||
object: &str,
|
||||
r: ReaderImpl,
|
||||
_length: i64,
|
||||
meta: HashMap<String, String>,
|
||||
) -> Result<String, std::io::Error> {
|
||||
let bytes = self.read_bytes(r).await?;
|
||||
let opts = build_transition_put_options(String::new(), meta);
|
||||
let mut metadata = opts.user_metadata.clone();
|
||||
if !opts.content_type.is_empty() {
|
||||
metadata.insert("content-type".to_string(), opts.content_type.clone());
|
||||
}
|
||||
if !opts.content_encoding.is_empty() {
|
||||
metadata.insert("content-encoding".to_string(), opts.content_encoding.clone());
|
||||
}
|
||||
if !opts.cache_control.is_empty() {
|
||||
metadata.insert("cache-control".to_string(), opts.cache_control.clone());
|
||||
}
|
||||
if !opts.internal.replication_status.as_str().is_empty() {
|
||||
metadata.insert(
|
||||
"x-amz-replication-status".to_string(),
|
||||
opts.internal.replication_status.as_str().to_string(),
|
||||
);
|
||||
}
|
||||
if !opts.legalhold.as_str().is_empty() {
|
||||
metadata.insert("x-amz-object-lock-legal-hold".to_string(), opts.legalhold.as_str().to_string());
|
||||
}
|
||||
Ok(self.put_bytes(object, bytes, metadata).await)
|
||||
}
|
||||
|
||||
async fn get(&self, object: &str, _rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
|
||||
let objects = self.objects.lock().await;
|
||||
let Some(stored) = objects.get(object) else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "mock object not found"));
|
||||
};
|
||||
let bytes = &stored.bytes;
|
||||
|
||||
let start = opts.start_offset.max(0) as usize;
|
||||
let end = if opts.length > 0 {
|
||||
start.saturating_add(opts.length as usize).min(bytes.len())
|
||||
} else {
|
||||
bytes.len()
|
||||
};
|
||||
|
||||
Ok(tokio::io::BufReader::new(Cursor::new(bytes[start.min(bytes.len())..end].to_vec())))
|
||||
}
|
||||
|
||||
async fn remove(&self, object: &str, _rv: &str) -> Result<(), std::io::Error> {
|
||||
self.ops_log.lock().await.push(format!("remove:{object}"));
|
||||
self.objects.lock().await.remove(object);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn in_use(&self) -> Result<bool, std::io::Error> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the shared [`MockWarmBackend`] into the global tier config manager
|
||||
/// used by the scanner integration tests. Thin wrapper over the shared
|
||||
/// `register_mock_tier` helper (rustfs/backlog#1148 ilm-6).
|
||||
async fn register_mock_tier(tier_name: &str) -> MockWarmBackend {
|
||||
let backend = MockWarmBackend::default();
|
||||
let tier_config_mgr_handle = get_global_tier_config_mgr();
|
||||
let mut tier_config_mgr = tier_config_mgr_handle.write().await;
|
||||
tier_config_mgr.tiers.insert(
|
||||
tier_name.to_string(),
|
||||
TierConfig {
|
||||
version: "v1".to_string(),
|
||||
tier_type: TierType::MinIO,
|
||||
name: tier_name.to_string(),
|
||||
minio: Some(TierMinIO {
|
||||
access_key: "minioadmin".to_string(),
|
||||
secret_key: "minioadmin".to_string(),
|
||||
bucket: "mock-tier".to_string(),
|
||||
endpoint: "http://127.0.0.1:0".to_string(),
|
||||
prefix: format!("mock/{}/", Uuid::new_v4()),
|
||||
region: String::new(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
tier_config_mgr
|
||||
.driver_cache
|
||||
.insert(tier_name.to_string(), Box::new(backend.clone()));
|
||||
backend
|
||||
register_mock_tier_util(&get_global_tier_config_mgr(), tier_name).await
|
||||
}
|
||||
|
||||
async fn wait_for_transition(ecstore: &Arc<ECStore>, bucket: &str, object: &str, timeout: Duration) -> Option<ObjectInfo> {
|
||||
@@ -842,11 +625,11 @@ mod serial_tests {
|
||||
.expect("object should transition before expiry");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"transitioned remote object should exist in the mock tier before expiry"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.remote_remove_count().await,
|
||||
backend.remove_count().await,
|
||||
0,
|
||||
"no remote-tier removal should have happened before expiry"
|
||||
);
|
||||
@@ -924,13 +707,13 @@ mod serial_tests {
|
||||
// remote `remove` was issued. Reverting to remote-first ordering makes
|
||||
// both assertions fail.
|
||||
assert_eq!(
|
||||
backend.remote_remove_count().await,
|
||||
backend.remove_count().await,
|
||||
0,
|
||||
"expire_transitioned_object must NOT issue a synchronous remote-tier removal (local-first \
|
||||
ordering, #3491); remote cleanup is deferred to free-version recovery"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"remote tier object must still exist immediately after expiry (deferred cleanup, #3491)"
|
||||
);
|
||||
|
||||
@@ -953,7 +736,7 @@ mod serial_tests {
|
||||
#[serial]
|
||||
#[ignore = "FAILING on main: excluded from the serial ILM lane pending a fix, see rustfs/backlog#1148 (ilm-1 partial)"]
|
||||
async fn test_transition_and_restore_flows() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&tier_name).await;
|
||||
@@ -993,11 +776,11 @@ mod serial_tests {
|
||||
|
||||
assert_eq!(put_info.transitioned_object.status, "complete");
|
||||
assert_eq!(put_info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(&put_info.transitioned_object.name));
|
||||
assert!(backend.contains(&put_info.transitioned_object.name).await);
|
||||
{
|
||||
let stored = backend.objects.lock().await;
|
||||
let transitioned = stored
|
||||
.get(&put_info.transitioned_object.name)
|
||||
let transitioned = backend
|
||||
.stored(&put_info.transitioned_object.name)
|
||||
.await
|
||||
.expect("transitioned object should be present in mock backend");
|
||||
assert_eq!(transitioned.metadata.get("content-type"), Some(&"text/plain".to_string()));
|
||||
assert!(
|
||||
@@ -1010,6 +793,12 @@ mod serial_tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-shard xl.meta transition assertion helper (rustfs/backlog#1148 ilm-6):
|
||||
// every disk must agree on the transition tuple for the object.
|
||||
let put_meta = assert_transition_meta_consistent(&disk_paths, put_bucket.as_str(), put_object).await;
|
||||
assert_eq!(put_meta.status, "complete");
|
||||
assert_eq!(put_meta.tier, tier_name);
|
||||
|
||||
let multipart_bucket = format!("test-immediate-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let multipart_object = "test/multipart.txt";
|
||||
|
||||
@@ -1063,13 +852,7 @@ mod serial_tests {
|
||||
|
||||
assert_eq!(multipart_info.transitioned_object.status, "complete");
|
||||
assert_eq!(multipart_info.transitioned_object.tier, tier_name);
|
||||
assert!(
|
||||
backend
|
||||
.objects
|
||||
.lock()
|
||||
.await
|
||||
.contains_key(&multipart_info.transitioned_object.name)
|
||||
);
|
||||
assert!(backend.contains(&multipart_info.transitioned_object.name).await);
|
||||
|
||||
let src_bucket = format!("test-immediate-copy-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let dst_bucket = format!("test-immediate-copy-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
@@ -1114,7 +897,7 @@ mod serial_tests {
|
||||
|
||||
assert_eq!(copy_info.transitioned_object.status, "complete");
|
||||
assert_eq!(copy_info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(©_info.transitioned_object.name));
|
||||
assert!(backend.contains(©_info.transitioned_object.name).await);
|
||||
|
||||
let bucket_name = format!("test-lifecycle-update-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object_name = "test/existing.txt";
|
||||
@@ -1137,7 +920,7 @@ mod serial_tests {
|
||||
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(&info.transitioned_object.name));
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
|
||||
let bucket_name = format!("test-restore-mpu-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let object_name = "test/restore.txt";
|
||||
@@ -1284,7 +1067,7 @@ mod serial_tests {
|
||||
.await
|
||||
.expect("object should transition before overwrite");
|
||||
let stale_remote_object = transitioned.transitioned_object.name.clone();
|
||||
assert!(backend.objects.lock().await.contains_key(&stale_remote_object));
|
||||
assert!(backend.contains(&stale_remote_object).await);
|
||||
|
||||
ecstore
|
||||
.delete_object(bucket_name.as_str(), object_name, ObjectOptions::default())
|
||||
@@ -1296,7 +1079,7 @@ mod serial_tests {
|
||||
"deleting a transitioned null version should leave a free version for async cleanup"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&stale_remote_object),
|
||||
backend.contains(&stale_remote_object).await,
|
||||
"stale transitioned remote object should still exist before scanner fallback runs"
|
||||
);
|
||||
|
||||
@@ -1304,7 +1087,9 @@ mod serial_tests {
|
||||
scan_object_metadata(&disk_paths[0], bucket_name.as_str(), object_name).await;
|
||||
|
||||
assert!(
|
||||
wait_for_remote_absence(&backend, &stale_remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
backend
|
||||
.wait_for_remote_absence(&stale_remote_object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await,
|
||||
"scanner should enqueue stale free-version cleanup for the transitioned remote object"
|
||||
);
|
||||
assert!(
|
||||
@@ -1344,7 +1129,7 @@ mod serial_tests {
|
||||
.await
|
||||
.expect("object should transition after compensation backfill");
|
||||
let stale_remote_object = transitioned.transitioned_object.name.clone();
|
||||
assert!(backend.objects.lock().await.contains_key(&stale_remote_object));
|
||||
assert!(backend.contains(&stale_remote_object).await);
|
||||
|
||||
ecstore
|
||||
.delete_object(bucket_name.as_str(), object_name, ObjectOptions::default())
|
||||
@@ -1356,7 +1141,7 @@ mod serial_tests {
|
||||
"deleting a compensation-transitioned null version should leave a free version for async cleanup"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&stale_remote_object),
|
||||
backend.contains(&stale_remote_object).await,
|
||||
"stale transitioned remote object should still exist before scanner cleanup runs"
|
||||
);
|
||||
|
||||
@@ -1364,7 +1149,9 @@ mod serial_tests {
|
||||
scan_object_metadata(&disk_paths[0], bucket_name.as_str(), object_name).await;
|
||||
|
||||
assert!(
|
||||
wait_for_remote_absence(&backend, &stale_remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
backend
|
||||
.wait_for_remote_absence(&stale_remote_object, TRANSITION_WAIT_TIMEOUT)
|
||||
.await,
|
||||
"scanner should clean stale remote object even after immediate compensation transitioned it"
|
||||
);
|
||||
assert!(
|
||||
@@ -1400,7 +1187,7 @@ mod serial_tests {
|
||||
.await
|
||||
.expect("object should transition after immediate compensation backfill");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
assert!(backend.objects.lock().await.contains_key(&remote_object));
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
enqueue_transition_for_existing_objects(ecstore.clone(), bucket_name.as_str())
|
||||
.await
|
||||
@@ -1413,7 +1200,7 @@ mod serial_tests {
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert_eq!(info.transitioned_object.name, remote_object);
|
||||
assert!(backend.objects.lock().await.contains_key(&remote_object));
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
@@ -1490,7 +1277,7 @@ mod serial_tests {
|
||||
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(&info.transitioned_object.name));
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
|
||||
scan_object_with_lifecycle(&disk_paths[0], bucket_name.as_str(), object_name).await;
|
||||
|
||||
@@ -1578,7 +1365,7 @@ mod serial_tests {
|
||||
scan_object_with_lifecycle(&disk_paths[0], bucket_name.as_str(), object_name).await;
|
||||
|
||||
assert!(
|
||||
wait_for_remote_object_count(&backend, 2, TRANSITION_WAIT_TIMEOUT).await,
|
||||
backend.wait_for_object_count(2, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"noncurrent transition should still move the previous version into the remote tier"
|
||||
);
|
||||
}
|
||||
@@ -1610,7 +1397,7 @@ mod serial_tests {
|
||||
.await
|
||||
.expect("current version should transition after compensation backfill");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
assert!(backend.objects.lock().await.contains_key(&remote_object));
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
ecstore
|
||||
.delete_object(
|
||||
@@ -1626,7 +1413,7 @@ mod serial_tests {
|
||||
"versioned delete modeled with versioned flags should create a delete marker"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"creating a delete marker should not remove the transitioned remote object version"
|
||||
);
|
||||
}
|
||||
@@ -1658,7 +1445,7 @@ mod serial_tests {
|
||||
.await
|
||||
.expect("current version should transition after compensation backfill");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
assert!(backend.objects.lock().await.contains_key(&remote_object));
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
ecstore
|
||||
.delete_object(
|
||||
@@ -1674,7 +1461,7 @@ mod serial_tests {
|
||||
"modeled versioned delete should create delete marker before cleanup"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"delete marker creation should not remove transitioned remote object"
|
||||
);
|
||||
|
||||
@@ -1689,7 +1476,7 @@ mod serial_tests {
|
||||
"delete marker should remain before DelMarkerExpiration due time"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"pre-due delete marker lifecycle scan should not remove transitioned remote object"
|
||||
);
|
||||
|
||||
@@ -1703,7 +1490,7 @@ mod serial_tests {
|
||||
"expired object delete marker lifecycle should eventually clean up the delete marker"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"delete marker lifecycle cleanup should not remove transitioned remote object"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,15 +23,17 @@ pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::{
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::bucket::versioning_sys::BucketVersioningSys;
|
||||
pub(crate) use rustfs_ecstore::api::capacity::path2_bucket_object_with_base_path;
|
||||
pub(crate) use rustfs_ecstore::api::client::transition_api::{ReadCloser, ReaderImpl};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskAPI, DiskOption, STORAGE_FORMAT_FILE, endpoint::Endpoint, new_disk};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskOption, STORAGE_FORMAT_FILE, endpoint::Endpoint, new_disk};
|
||||
pub(crate) use rustfs_ecstore::api::error::{Error as EcstoreError, is_err_object_not_found, is_err_version_not_found};
|
||||
pub(crate) use rustfs_ecstore::api::layout::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
pub(crate) use rustfs_ecstore::api::runtime::global_tier_config_mgr as get_global_tier_config_mgr;
|
||||
pub(crate) use rustfs_ecstore::api::storage::{ECStore, init_local_disks};
|
||||
pub(crate) use rustfs_ecstore::api::tier::tier_config::{TierConfig, TierMinIO, TierType};
|
||||
pub(crate) use rustfs_ecstore::api::tier::warm_backend::{
|
||||
WarmBackend as ScannerWarmBackend, WarmBackendGetOpts, build_transition_put_options,
|
||||
// Shared lifecycle/tier test utilities (rustfs/backlog#1148 ilm-6). The mock
|
||||
// backend and xl.meta assertion helpers now live in ecstore behind the
|
||||
// `test-util` feature instead of being copied into this crate.
|
||||
pub(crate) use rustfs_ecstore::api::tier::test_util::{
|
||||
MockWarmBackend, assert_transition_meta_consistent, free_version_count, register_mock_tier as register_mock_tier_util,
|
||||
wait_for_free_version_absence,
|
||||
};
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
|
||||
@@ -42,11 +44,11 @@ pub(crate) mod lifecycle {
|
||||
};
|
||||
|
||||
pub(crate) use super::{
|
||||
BUCKET_LIFECYCLE_CONFIG, BucketVersioningSys, DiskAPI, DiskOption, ECStore, EcstoreError, Endpoint, EndpointServerPools,
|
||||
Endpoints, IlmAction, LcEvent, LcEventSrc, PoolEndpoints, ReadCloser, ReaderImpl, STORAGE_FORMAT_FILE,
|
||||
ScannerWarmBackend, TierConfig, TierMinIO, TierType, TransitionOptions, WarmBackendGetOpts, build_transition_put_options,
|
||||
enqueue_transition_for_existing_objects, expire_transitioned_object, get_bucket_metadata, get_global_tier_config_mgr,
|
||||
init_background_expiry, init_bucket_metadata_sys, init_local_disks, is_err_object_not_found, is_err_version_not_found,
|
||||
new_disk, path2_bucket_object_with_base_path, update_bucket_metadata,
|
||||
BUCKET_LIFECYCLE_CONFIG, BucketVersioningSys, DiskOption, ECStore, EcstoreError, Endpoint, EndpointServerPools,
|
||||
Endpoints, IlmAction, LcEvent, LcEventSrc, MockWarmBackend, PoolEndpoints, STORAGE_FORMAT_FILE, TransitionOptions,
|
||||
assert_transition_meta_consistent, enqueue_transition_for_existing_objects, expire_transitioned_object,
|
||||
free_version_count, get_bucket_metadata, get_global_tier_config_mgr, init_background_expiry, init_bucket_metadata_sys,
|
||||
init_local_disks, is_err_object_not_found, is_err_version_not_found, new_disk, path2_bucket_object_with_base_path,
|
||||
register_mock_tier_util, update_bucket_metadata, wait_for_free_version_absence,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,6 +215,9 @@ opentelemetry_sdk = { workspace = true }
|
||||
rsa = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
criterion = { workspace = true, features = ["html_reports"] }
|
||||
# Enables the shared MockWarmBackend / xl.meta assertion helpers exposed via
|
||||
# the ecstore `api::tier::test_util` facade module (rustfs/backlog#1148 ilm-6).
|
||||
rustfs-ecstore = { workspace = true, features = ["test-util"] }
|
||||
|
||||
[build-dependencies]
|
||||
http.workspace = true
|
||||
|
||||
@@ -16,7 +16,6 @@ use super::storage_api::test::bucket::{
|
||||
lifecycle,
|
||||
metadata::{BUCKET_LIFECYCLE_CONFIG, OBJECT_LOCK_CONFIG},
|
||||
metadata_sys,
|
||||
transition_api::{ReadCloser, ReaderImpl},
|
||||
};
|
||||
use super::storage_api::test::contract::{
|
||||
bucket::{BucketOperations, BucketOptions, MakeBucketOptions},
|
||||
@@ -26,7 +25,7 @@ use super::storage_api::test::contract::{
|
||||
};
|
||||
use super::storage_api::test::ecfs::FS;
|
||||
use super::storage_api::test::object_utils::to_s3s_etag;
|
||||
use super::storage_api::test::runtime::{AppWarmBackend, TierConfig, TierType, WarmBackendGetOpts};
|
||||
use super::storage_api::test::runtime::{MockWarmBackend, register_mock_tier as register_mock_tier_util};
|
||||
use super::storage_api::test::{
|
||||
ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints, StorageObjectInfo as ObjectInfo,
|
||||
StorageObjectOptions as ObjectOptions, StoragePutObjReader as PutObjReader,
|
||||
@@ -44,17 +43,15 @@ use rustfs_utils::http::{SUFFIX_FORCE_DELETE, insert_header};
|
||||
use s3s::{S3Request, dto::*};
|
||||
use serial_test::serial;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
convert::Infallible,
|
||||
env, fs as stdfs,
|
||||
io::Cursor,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Once, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{Barrier, Mutex};
|
||||
use tokio::sync::Barrier;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -285,90 +282,11 @@ fn del_marker_expiration_lifecycle_configuration(days: i32) -> BucketLifecycleCo
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct MockWarmBackend {
|
||||
objects: Arc<Mutex<HashMap<String, Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl MockWarmBackend {
|
||||
async fn put_bytes(&self, object: &str, bytes: Vec<u8>) -> String {
|
||||
self.objects.lock().await.insert(object.to_string(), bytes);
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, reader: ReaderImpl) -> Result<Vec<u8>, std::io::Error> {
|
||||
match reader {
|
||||
ReaderImpl::Body(bytes) => Ok(bytes.to_vec()),
|
||||
ReaderImpl::ObjectBody(mut reader) => {
|
||||
let mut buf = Vec::new();
|
||||
reader.stream.read_to_end(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AppWarmBackend for MockWarmBackend {
|
||||
async fn put(&self, object: &str, r: ReaderImpl, _length: i64) -> Result<String, std::io::Error> {
|
||||
let bytes = self.read_bytes(r).await?;
|
||||
Ok(self.put_bytes(object, bytes).await)
|
||||
}
|
||||
|
||||
async fn put_with_meta(
|
||||
&self,
|
||||
object: &str,
|
||||
r: ReaderImpl,
|
||||
_length: i64,
|
||||
_meta: HashMap<String, String>,
|
||||
) -> Result<String, std::io::Error> {
|
||||
let bytes = self.read_bytes(r).await?;
|
||||
Ok(self.put_bytes(object, bytes).await)
|
||||
}
|
||||
|
||||
async fn get(&self, object: &str, _rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
|
||||
let objects = self.objects.lock().await;
|
||||
let Some(bytes) = objects.get(object) else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "mock object not found"));
|
||||
};
|
||||
|
||||
let start = opts.start_offset.max(0) as usize;
|
||||
let end = if opts.length > 0 {
|
||||
start.saturating_add(opts.length as usize).min(bytes.len())
|
||||
} else {
|
||||
bytes.len()
|
||||
};
|
||||
|
||||
Ok(tokio::io::BufReader::new(Cursor::new(bytes[start.min(bytes.len())..end].to_vec())))
|
||||
}
|
||||
|
||||
async fn remove(&self, object: &str, _rv: &str) -> Result<(), std::io::Error> {
|
||||
self.objects.lock().await.remove(object);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn in_use(&self) -> Result<bool, std::io::Error> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the shared [`MockWarmBackend`] into this instance's tier config
|
||||
/// manager. Thin wrapper over the shared `register_mock_tier` helper
|
||||
/// (rustfs/backlog#1148 ilm-6).
|
||||
async fn register_mock_tier(tier_name: &str) -> MockWarmBackend {
|
||||
let backend = MockWarmBackend::default();
|
||||
let tier_config_mgr_handle = current_tier_config_handle();
|
||||
let mut tier_config_mgr = tier_config_mgr_handle.write().await;
|
||||
tier_config_mgr.tiers.insert(
|
||||
tier_name.to_string(),
|
||||
TierConfig {
|
||||
version: "v1".to_string(),
|
||||
tier_type: TierType::MinIO,
|
||||
name: tier_name.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
tier_config_mgr
|
||||
.driver_cache
|
||||
.insert(tier_name.to_string(), Box::new(backend.clone()));
|
||||
backend
|
||||
register_mock_tier_util(¤t_tier_config_handle(), tier_name).await
|
||||
}
|
||||
|
||||
async fn wait_for_transition(ecstore: &Arc<ECStore>, bucket: &str, object: &str, timeout: Duration) -> Option<ObjectInfo> {
|
||||
@@ -441,22 +359,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_remote_absence(backend: &MockWarmBackend, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
if !backend.objects.lock().await.contains_key(object) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_object_absence(ecstore: &Arc<ECStore>, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
@@ -851,7 +753,7 @@ async fn put_and_copy_object_transition_immediately_via_usecases() {
|
||||
|
||||
assert_eq!(put_info.transitioned_object.status, "complete");
|
||||
assert_eq!(put_info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(&put_info.transitioned_object.name));
|
||||
assert!(backend.contains(&put_info.transitioned_object.name).await);
|
||||
|
||||
let src_bucket = format!("test-api-copy-src-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
let dst_bucket = format!("test-api-copy-dst-{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||
@@ -887,7 +789,7 @@ async fn put_and_copy_object_transition_immediately_via_usecases() {
|
||||
|
||||
assert_eq!(copy_info.transitioned_object.status, "complete");
|
||||
assert_eq!(copy_info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(©_info.transitioned_object.name));
|
||||
assert!(backend.contains(©_info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
@@ -944,7 +846,7 @@ async fn complete_multipart_upload_transitions_immediately_via_usecase() {
|
||||
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(&info.transitioned_object.name));
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
@@ -993,13 +895,7 @@ async fn get_transitioned_object_uses_remote_codec_fallback_path() {
|
||||
assert_eq!(transitioned.transitioned_object.status, "complete");
|
||||
assert_eq!(transitioned.transitioned_object.tier, tier_name);
|
||||
assert!(!transitioned.transitioned_object.name.is_empty());
|
||||
assert!(
|
||||
backend
|
||||
.objects
|
||||
.lock()
|
||||
.await
|
||||
.contains_key(&transitioned.transitioned_object.name)
|
||||
);
|
||||
assert!(backend.contains(&transitioned.transitioned_object.name).await);
|
||||
|
||||
let actual = read_object_bytes(&ecstore, bucket.as_str(), object).await;
|
||||
assert_eq!(actual, payload);
|
||||
@@ -1036,7 +932,7 @@ async fn delete_transitioned_object_removes_remote_tier_copy_via_usecase() {
|
||||
.expect("object should transition before delete usecase runs");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.objects.lock().await.contains_key(&remote_object));
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let mut req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
@@ -1058,7 +954,7 @@ async fn delete_transitioned_object_removes_remote_tier_copy_via_usecase() {
|
||||
);
|
||||
|
||||
assert!(
|
||||
wait_for_remote_absence(&backend, &remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
backend.wait_for_remote_absence(&remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"transitioned object should be removed from remote tier after delete usecase"
|
||||
);
|
||||
}
|
||||
@@ -1134,7 +1030,7 @@ async fn immediate_transition_timeout_eventually_completes_via_compensation() {
|
||||
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(&info.transitioned_object.name));
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
@@ -1184,7 +1080,7 @@ async fn compensation_driven_copy_still_completes_transition() {
|
||||
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(&info.transitioned_object.name));
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
@@ -1244,7 +1140,7 @@ async fn compensation_driven_complete_multipart_upload_still_transitions() {
|
||||
|
||||
assert_eq!(info.transitioned_object.status, "complete");
|
||||
assert_eq!(info.transitioned_object.tier, tier_name);
|
||||
assert!(backend.objects.lock().await.contains_key(&info.transitioned_object.name));
|
||||
assert!(backend.contains(&info.transitioned_object.name).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
@@ -1276,7 +1172,7 @@ async fn compensation_driven_transition_still_cleans_remote_tier_on_delete() {
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.objects.lock().await.contains_key(&remote_object));
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let mut req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
@@ -1298,7 +1194,7 @@ async fn compensation_driven_transition_still_cleans_remote_tier_on_delete() {
|
||||
);
|
||||
|
||||
assert!(
|
||||
wait_for_remote_absence(&backend, &remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
backend.wait_for_remote_absence(&remote_object, TRANSITION_WAIT_TIMEOUT).await,
|
||||
"transitioned object should be removed from remote tier after delete usecase"
|
||||
);
|
||||
}
|
||||
@@ -1332,7 +1228,7 @@ async fn compensation_driven_versioned_delete_still_creates_delete_marker() {
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.objects.lock().await.contains_key(&remote_object));
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
@@ -1352,7 +1248,7 @@ async fn compensation_driven_versioned_delete_still_creates_delete_marker() {
|
||||
"versioned delete should create a delete marker after compensation-driven transition"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"creating a delete marker should not remove the transitioned remote object version"
|
||||
);
|
||||
}
|
||||
@@ -1387,7 +1283,7 @@ async fn compensation_driven_delete_marker_still_honors_lifecycle_cleanup() {
|
||||
.expect("object should eventually transition after compensation backfill");
|
||||
let remote_object = transitioned.transitioned_object.name.clone();
|
||||
|
||||
assert!(backend.objects.lock().await.contains_key(&remote_object));
|
||||
assert!(backend.contains(&remote_object).await);
|
||||
|
||||
let req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
@@ -1407,7 +1303,7 @@ async fn compensation_driven_delete_marker_still_honors_lifecycle_cleanup() {
|
||||
"versioned delete should create a delete marker before lifecycle cleanup"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"delete marker creation should keep the transitioned remote object version"
|
||||
);
|
||||
|
||||
@@ -1429,7 +1325,7 @@ async fn compensation_driven_delete_marker_still_honors_lifecycle_cleanup() {
|
||||
"delete marker should remain visible after lifecycle update until cleanup completes"
|
||||
);
|
||||
assert!(
|
||||
backend.objects.lock().await.contains_key(&remote_object),
|
||||
backend.contains(&remote_object).await,
|
||||
"delete marker lifecycle cleanup should not remove the transitioned remote object version"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,14 +145,11 @@ pub(crate) mod runtime {
|
||||
pub(crate) type TierConfigMgr = crate::storage::storage_api::TierConfigMgr;
|
||||
pub(crate) type TransitionState = crate::storage::storage_api::TransitionState;
|
||||
|
||||
// Shared MockWarmBackend + register_mock_tier helper (rustfs/backlog#1148 ilm-6).
|
||||
// The mock (and the tier types it used to need) now live in ecstore behind
|
||||
// the `test-util` feature.
|
||||
#[cfg(test)]
|
||||
pub(crate) type TierConfig = crate::storage::storage_api::ecstore_tier::tier_config::TierConfig;
|
||||
#[cfg(test)]
|
||||
pub(crate) type TierType = crate::storage::storage_api::ecstore_tier::tier_config::TierType;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::storage_api::ecstore_tier::warm_backend::WarmBackend as AppWarmBackend;
|
||||
#[cfg(test)]
|
||||
pub(crate) type WarmBackendGetOpts = crate::storage::storage_api::ecstore_tier::warm_backend::WarmBackendGetOpts;
|
||||
pub(crate) use crate::storage::storage_api::ecstore_tier::test_util::{MockWarmBackend, register_mock_tier};
|
||||
|
||||
pub(crate) fn set_global_storage_class(cfg: StorageClassConfig) {
|
||||
crate::storage::storage_api::ecstore_config::set_global_storage_class(cfg);
|
||||
@@ -807,12 +804,6 @@ pub(crate) mod bucket {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod transition_api {
|
||||
pub(crate) type ReadCloser = crate::storage::storage_api::ecstore_client::transition_api::ReadCloser;
|
||||
pub(crate) type ReaderImpl = crate::storage::storage_api::ecstore_client::transition_api::ReaderImpl;
|
||||
}
|
||||
|
||||
pub(crate) mod versioning_sys {
|
||||
pub(crate) type BucketVersioningSys = crate::storage::storage_api::ecstore_bucket::versioning_sys::BucketVersioningSys;
|
||||
}
|
||||
|
||||
@@ -335,8 +335,6 @@ pub(crate) mod ecstore_capacity {
|
||||
}
|
||||
|
||||
pub(crate) mod ecstore_client {
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::client::transition_api;
|
||||
pub(crate) use rustfs_ecstore::api::client::{admin_handler_utils, object_api_utils};
|
||||
}
|
||||
|
||||
@@ -464,9 +462,11 @@ pub(crate) mod ecstore_storage {
|
||||
|
||||
pub(crate) mod ecstore_tier {
|
||||
pub(crate) use rustfs_ecstore::api::tier::tier::TierConfigMgr;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::tier::warm_backend;
|
||||
pub(crate) use rustfs_ecstore::api::tier::{tier, tier_admin, tier_config, tier_handlers};
|
||||
// Shared lifecycle/tier test utilities behind ecstore's `test-util` feature
|
||||
// (rustfs/backlog#1148 ilm-6). Only linked into test builds.
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::tier::test_util;
|
||||
}
|
||||
|
||||
pub(crate) const BUCKET_ACCELERATE_CONFIG: &str = ecstore_bucket::metadata::BUCKET_ACCELERATE_CONFIG;
|
||||
|
||||
Reference in New Issue
Block a user