mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cbe4a8465 | |||
| 8bd6d8c4db | |||
| 8f2b91f79b | |||
| c2b2b4ffd4 | |||
| 4ceed58be4 | |||
| 507faf3a6a | |||
| 1607e9a376 | |||
| f06b004f2d | |||
| 41546dee5d | |||
| 7d3f5545e7 | |||
| d293ed71e5 | |||
| 114bb4acec | |||
| 29272480bd | |||
| e136e95a20 | |||
| f4ce1a8b3a | |||
| 9681f19bec | |||
| 20a9c12f86 | |||
| 86e969f63c | |||
| 51272d34dd | |||
| cd1363d519 | |||
| 5142775387 | |||
| 2ed08c8bad | |||
| 60a0b1d6e7 | |||
| 170a4c7640 | |||
| fc98dbb654 | |||
| e091a7e702 | |||
| eec0e0e056 | |||
| 1c5c28842a | |||
| 16ca65ccab | |||
| ecefb5644b | |||
| f473b6dbf8 | |||
| af6545b689 | |||
| a66b568ba0 | |||
| 232190a205 | |||
| f52a389652 | |||
| 73cd1b5be2 | |||
| 40bf9f0425 | |||
| eb0384c225 | |||
| 0e015360cc | |||
| 2bd1df3075 | |||
| 9935911e93 | |||
| 99fb77b164 | |||
| ebff02304d | |||
| 57eaa8228d | |||
| aa0a374aa4 | |||
| 1ec1a8d90d | |||
| 28b3ecf547 | |||
| 33341d5fcf | |||
| 2a43e021c9 |
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=ae412c27e2e43f3fda3e0c21ef616b48a6403354faab8db8d4154202e3c4eec5
|
||||
sha256-linux=ef3be856bd3257c2c369428f66a48ad073dad8187229fe95840a600a81edf22b
|
||||
sha256-darwin=88ee9684ece0e27294f2b3f0c9c8fe62890feff76aa47279d42dab0af3196fe2
|
||||
sha256-linux=d13337936af6778b1d2b2b255ae7fd350fdec94034be46daf738bd577653f799
|
||||
|
||||
@@ -113,9 +113,10 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
||||
# does not cross nextest process boundaries, so keep these tests in one group.
|
||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||
# one group.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^kms::kms_vault_test::/)'
|
||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||
test-group = 'e2e-vault'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -409,7 +410,7 @@ path = "junit.xml"
|
||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
|
||||
# it for those, so e2e-full does not double-run it.
|
||||
# * #[ignore]d tests — nextest skips them by default (no --run-ignored); the
|
||||
# manual-localhost:9000 reliant/policy tests are ci-13's migration.
|
||||
# manual-localhost:9000 reliant tests are ci-13's migration.
|
||||
#
|
||||
# Each e2e test spawns its own single-node rustfs server on a random port with
|
||||
# an isolated temp dir (crates/e2e_test/src/common.rs), so the set is
|
||||
@@ -453,5 +454,5 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^kms::kms_vault_test::/)'
|
||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||
test-group = 'e2e-vault'
|
||||
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
- name: Wait for RustFS ready
|
||||
run: |
|
||||
for _ in {1..120}; do
|
||||
if curl -sf "http://${S3_HOST}:${S3_PORT}/health" >/dev/null 2>&1; then
|
||||
if curl -sf "http://${S3_HOST}:${S3_PORT}/health/ready" >/dev/null 2>&1; then
|
||||
echo "RustFS is ready"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- name: Wait for RustFS ready
|
||||
run: |
|
||||
for _ in {1..60}; do
|
||||
if curl -sf http://127.0.0.1:9000/health >/dev/null 2>&1; then
|
||||
if curl -sf http://127.0.0.1:9000/health/ready >/dev/null 2>&1; then
|
||||
echo "RustFS is ready"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
Generated
+1
-1
@@ -9202,7 +9202,7 @@ dependencies = [
|
||||
"mime_guess",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"p256 0.13.2",
|
||||
"p256 0.14.0",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
|
||||
@@ -200,6 +200,7 @@ hmac = { version = "0.13.0" }
|
||||
jsonwebtoken = { version = "11.0.0" }
|
||||
openidconnect = { default-features = false, version = "4.0" }
|
||||
pbkdf2 = "0.13.0"
|
||||
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
|
||||
rsa = { version = "=0.10.0-rc.18" }
|
||||
rustls = { default-features = false, version = "0.23.43" }
|
||||
rustls-native-certs = "0.8"
|
||||
@@ -209,6 +210,7 @@ sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9.0" }
|
||||
proptest = "1"
|
||||
|
||||
# Time and Date
|
||||
chrono = { version = "0.4.45" }
|
||||
|
||||
@@ -168,6 +168,19 @@ pub const DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED: bool = false;
|
||||
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_WRITE);
|
||||
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED);
|
||||
|
||||
/// Request writing pool metadata version 2.
|
||||
///
|
||||
/// This remains ineffective until [`ENV_POOL_META_V2_FLEET_CONFIRMED`] is also enabled.
|
||||
pub const ENV_POOL_META_V2_WRITE: &str = "RUSTFS_POOL_META_V2_WRITE";
|
||||
pub const DEFAULT_POOL_META_V2_WRITE: bool = false;
|
||||
|
||||
/// Operator-attested confirmation that every pool metadata reader and writer understands version 2.
|
||||
pub const ENV_POOL_META_V2_FLEET_CONFIRMED: &str = "RUSTFS_POOL_META_V2_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_POOL_META_V2_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V2_WRITE);
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V2_FLEET_CONFIRMED);
|
||||
|
||||
// =============================================================================
|
||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||
// =============================================================================
|
||||
@@ -736,4 +749,10 @@ mod remote_version_state_tests {
|
||||
"RUSTFS_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_meta_v2_gate_uses_stable_environment_names() {
|
||||
assert_eq!(super::ENV_POOL_META_V2_WRITE, "RUSTFS_POOL_META_V2_WRITE");
|
||||
assert_eq!(super::ENV_POOL_META_V2_FLEET_CONFIRMED, "RUSTFS_POOL_META_V2_FLEET_CONFIRMED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +573,10 @@ pub struct DataUsageInfo {
|
||||
/// cycle (or retained a compatible last-known-good cache).
|
||||
#[serde(default)]
|
||||
pub usage_snapshot_partial: bool,
|
||||
/// Durable marker for a first-start bootstrap that has not produced an
|
||||
/// authoritative usage snapshot yet.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub usage_snapshot_bootstrap_pending: bool,
|
||||
/// Deprecated kept here for backward compatibility reasons
|
||||
pub bucket_sizes: HashMap<String, u64>,
|
||||
/// Per-disk snapshot information when available
|
||||
@@ -1962,7 +1966,8 @@ impl DataUsageInfo {
|
||||
|
||||
/// Whether this snapshot authoritatively covers every reported bucket.
|
||||
pub fn is_complete_bucket_usage_snapshot(&self) -> bool {
|
||||
self.usage_snapshot_complete
|
||||
!self.usage_snapshot_bootstrap_pending
|
||||
&& self.usage_snapshot_complete
|
||||
&& self.last_update.is_some()
|
||||
&& u64::try_from(self.buckets_usage.len()).ok() == Some(self.buckets_count)
|
||||
}
|
||||
@@ -1971,7 +1976,8 @@ impl DataUsageInfo {
|
||||
/// admin display. Partial data is accepted only with unique set states,
|
||||
/// a plan digest for every state, and at least one usable generation.
|
||||
pub fn is_valid_partial_snapshot(&self) -> bool {
|
||||
if !self.usage_snapshot_partial
|
||||
if self.usage_snapshot_bootstrap_pending
|
||||
|| !self.usage_snapshot_partial
|
||||
|| self.usage_snapshot_converged != Some(false)
|
||||
|| self.last_update.is_none()
|
||||
|| self.scanner_cycle.is_none()
|
||||
|
||||
@@ -72,7 +72,7 @@ The reason string on each attribute is the classifier. Current classes:
|
||||
|
||||
- **Needs a pre-started server** — `"requires running RustFS server at
|
||||
localhost:9000"` / `"Connects to existing rustfs server"`. These are the
|
||||
`reliant/*` and `policy/test_runner` tests; start a server first (e.g.
|
||||
`reliant/*` tests; start a server first (e.g.
|
||||
[`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh)) or use
|
||||
`--run-ignored`.
|
||||
- **Heavy / external tool** — `"Starts a rustfs server; enable when running
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client, rustfs_binary_path};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use http::header::HOST;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
@@ -368,10 +369,15 @@ mod tests {
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"stale root must be rejected on the admin API after rotation, body: {body}"
|
||||
);
|
||||
let s3_old = s3_client_with(&env, &old_ak, &old_sk).list_buckets().send().await;
|
||||
assert!(
|
||||
s3_old.is_err(),
|
||||
"stale root must be rejected on the S3 plane after rotation, got: {s3_old:?}"
|
||||
let s3_old = s3_client_with(&env, &old_ak, &old_sk)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect_err("stale root must be rejected on the S3 plane after rotation");
|
||||
assert_eq!(
|
||||
s3_old.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidAccessKeyId"),
|
||||
"stale root must receive InvalidAccessKeyId after rotation: {s3_old:?}"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::common::{
|
||||
RustFSTestEnvironment, admin_ok, admin_request, admin_request_with_session_token, build_test_sts_client, init_logging,
|
||||
};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use reqwest::StatusCode;
|
||||
@@ -411,8 +412,13 @@ async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
||||
.key("before-attach")
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await;
|
||||
assert!(denied.is_err(), "user without a policy must not be able to write to {bucket}");
|
||||
.await
|
||||
.expect_err("user without a policy must not be able to write to the bucket");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"user without a policy must receive AccessDenied: {denied:?}"
|
||||
);
|
||||
|
||||
// --- attach policy: the credential actually gains S3 access -----------------
|
||||
admin_ok(
|
||||
@@ -499,13 +505,19 @@ async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await;
|
||||
if revoked.is_err() {
|
||||
break;
|
||||
match revoked {
|
||||
Ok(_) if tokio::time::Instant::now() >= deadline => {
|
||||
return Err("deleted service account credential still works".into());
|
||||
}
|
||||
Ok(_) => sleep(Duration::from_millis(500)).await,
|
||||
Err(error) => {
|
||||
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||
if matches!(code, Some("AccessDenied" | "InvalidAccessKeyId")) {
|
||||
break;
|
||||
}
|
||||
return Err(format!("deleted service account must fail with an authorization error, got {error:?}").into());
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err("deleted service account credential still works".into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Disable then remove the user; the credential must stop working.
|
||||
@@ -525,13 +537,19 @@ async fn test_admin_user_policy_service_account_crud_lifecycle() -> TestResult {
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await;
|
||||
if disabled.is_err() {
|
||||
break;
|
||||
match disabled {
|
||||
Ok(_) if tokio::time::Instant::now() >= deadline => {
|
||||
return Err("disabled user credential still works".into());
|
||||
}
|
||||
Ok(_) => sleep(Duration::from_millis(500)).await,
|
||||
Err(error) => {
|
||||
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||
if matches!(code, Some("AccessDenied" | "InvalidAccessKeyId")) {
|
||||
break;
|
||||
}
|
||||
return Err(format!("disabled user must fail with an authorization error, got {error:?}").into());
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err("disabled user credential still works".into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
admin_ok(
|
||||
|
||||
@@ -199,8 +199,18 @@ mod tests {
|
||||
);
|
||||
|
||||
// And the object must not have been stored.
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await;
|
||||
assert!(head.is_err(), "Object must not exist after a rejected mismatched-checksum PutObject");
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Object must not exist after a rejected mismatched-checksum PutObject");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"Rejected mismatched-checksum PutObject absence probe must return HTTP 404, got {error:?}"
|
||||
);
|
||||
info!("PASSED: PutObject rejects mismatched SHA256 and stores nothing");
|
||||
}
|
||||
|
||||
@@ -552,8 +562,18 @@ mod tests {
|
||||
msg.contains("BadDigest") || msg.to_lowercase().contains("digest") || msg.to_lowercase().contains("checksum"),
|
||||
"{header}: expected a BadDigest/checksum error, got: {msg}"
|
||||
);
|
||||
let head = client.head_object().bucket(bucket).key(&bad_key).send().await;
|
||||
assert!(head.is_err(), "{header}: nothing must be stored after a rejected PutObject");
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(&bad_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("nothing must be stored after a rejected PutObject");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"{header}: rejected PutObject absence probe must return HTTP 404, got {error:?}"
|
||||
);
|
||||
|
||||
info!("PASSED additional-checksum verify-on-write: {header}");
|
||||
}
|
||||
|
||||
@@ -310,6 +310,17 @@ pub fn rustfs_binary_path() -> PathBuf {
|
||||
rustfs_binary_path_with_features(requested_rustfs_build_features().as_deref())
|
||||
}
|
||||
|
||||
fn resolve_rustfs_binary_path(workspace: &Path, configured_target_dir: Option<&Path>) -> PathBuf {
|
||||
let mut path = match configured_target_dir {
|
||||
Some(path) if path.is_absolute() => path.to_path_buf(),
|
||||
Some(path) => workspace.join(path),
|
||||
None => workspace.join("target"),
|
||||
};
|
||||
path.push(if cfg!(debug_assertions) { "debug" } else { "release" });
|
||||
path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
|
||||
path
|
||||
}
|
||||
|
||||
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
|
||||
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
|
||||
@@ -317,11 +328,9 @@ pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> Pat
|
||||
}
|
||||
let requested_features = requested_features.and_then(normalize_rustfs_build_features);
|
||||
|
||||
let mut binary_path = workspace_root();
|
||||
binary_path.push("target");
|
||||
let profile_dir = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
binary_path.push(profile_dir);
|
||||
binary_path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
|
||||
let workspace = workspace_root();
|
||||
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
||||
let binary_path = resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref());
|
||||
|
||||
let features_match = binary_features_match(&binary_path, requested_features.as_deref());
|
||||
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
|
||||
@@ -338,7 +347,7 @@ pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> Pat
|
||||
}
|
||||
|
||||
info!("Building RustFS binary to ensure it's up to date...");
|
||||
build_rustfs_binary(requested_features.as_deref());
|
||||
build_rustfs_binary(requested_features.as_deref(), &binary_path);
|
||||
|
||||
info!("Using RustFS binary at {:?}", binary_path);
|
||||
binary_path
|
||||
@@ -440,7 +449,7 @@ fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bo
|
||||
}
|
||||
|
||||
/// Build the RustFS binary using cargo
|
||||
fn build_rustfs_binary(requested_features: Option<&str>) {
|
||||
fn build_rustfs_binary(requested_features: Option<&str>, binary_path: &Path) {
|
||||
let workspace = workspace_root();
|
||||
info!("Building RustFS binary from workspace: {:?}", workspace);
|
||||
|
||||
@@ -476,11 +485,7 @@ fn build_rustfs_binary(requested_features: Option<&str>) {
|
||||
panic!("Failed to build RustFS binary. Error: {stderr}");
|
||||
}
|
||||
|
||||
let mut binary_path = workspace;
|
||||
binary_path.push("target");
|
||||
binary_path.push(if cfg!(debug_assertions) { "debug" } else { "release" });
|
||||
binary_path.push(format!("rustfs{}", std::env::consts::EXE_SUFFIX));
|
||||
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
|
||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
||||
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
|
||||
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
|
||||
}
|
||||
@@ -1776,6 +1781,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
||||
let workspace = Path::new("workspace");
|
||||
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
let binary = format!("rustfs{}", std::env::consts::EXE_SUFFIX);
|
||||
assert_eq!(
|
||||
resolve_rustfs_binary_path(workspace, None),
|
||||
workspace.join("target").join(profile).join(&binary)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_rustfs_binary_path(workspace, Some(Path::new("custom-target"))),
|
||||
workspace.join("custom-target").join(profile).join(&binary)
|
||||
);
|
||||
|
||||
let absolute = std::env::temp_dir().join("rustfs-e2e-custom-target");
|
||||
assert_eq!(
|
||||
resolve_rustfs_binary_path(workspace, Some(&absolute)),
|
||||
absolute.join(profile).join(binary)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_feature_enables_any_required_feature() {
|
||||
assert!(rustfs_build_feature_enabled(Some("full"), "sftp"));
|
||||
|
||||
@@ -4,7 +4,8 @@ use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tracing::info;
|
||||
|
||||
@@ -31,30 +32,58 @@ fn generate_high_ratio_binary_data(size: usize, seed: u8) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> {
|
||||
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> io::Result<Vec<PathBuf>> {
|
||||
let bucket_path = PathBuf::from(temp_dir).join(bucket);
|
||||
let mut part_files = Vec::new();
|
||||
|
||||
fn scan_dir(dir: &PathBuf, target: &str, results: &mut Vec<PathBuf>) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, target, results);
|
||||
} else if path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with("part."))
|
||||
.unwrap_or(false)
|
||||
&& path.to_string_lossy().contains(target)
|
||||
{
|
||||
results.push(path);
|
||||
fn scan_dir(dir: &Path, target: &str, results: &mut Vec<PathBuf>) -> io::Result<()> {
|
||||
let entries = fs::read_dir(dir)
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("failed to read {}: {error}", dir.display())))?;
|
||||
for entry in entries {
|
||||
let entry = entry
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("failed to read entry in {}: {error}", dir.display())))?;
|
||||
let path = entry.path();
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("failed to inspect {}: {error}", path.display())))?;
|
||||
if file_type.is_dir() {
|
||||
scan_dir(&path, target, results)?;
|
||||
} else if path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with("part."))
|
||||
.unwrap_or(false)
|
||||
&& path.to_string_lossy().contains(target)
|
||||
{
|
||||
if !file_type.is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("expected regular part file at {}", path.display()),
|
||||
));
|
||||
}
|
||||
results.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
scan_dir(&bucket_path, object_key, &mut part_files);
|
||||
part_files
|
||||
scan_dir(&bucket_path, object_key, &mut part_files)?;
|
||||
Ok(part_files)
|
||||
}
|
||||
|
||||
fn part_files_total_size(part_files: &[PathBuf]) -> io::Result<u64> {
|
||||
part_files.iter().try_fold(0_u64, |total, path| {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("failed to stat {}: {error}", path.display())))?;
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("expected regular part file at {}", path.display()),
|
||||
));
|
||||
}
|
||||
total
|
||||
.checked_add(metadata.len())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "on-disk part size overflow"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -123,8 +152,9 @@ async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error +
|
||||
let content_length = head_response.content_length().unwrap_or(0);
|
||||
assert_eq!(content_length as usize, original_size, "Content-Length should be original size");
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, COMPRESSION_TEST_BUCKET, object_key);
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let part_files = find_part_files(&env.temp_dir, COMPRESSION_TEST_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the compressed object");
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
|
||||
assert!(
|
||||
total_physical_size < original_size as u64,
|
||||
@@ -246,9 +276,9 @@ async fn test_compression_multipart_roundtrip() -> Result<(), Box<dyn std::error
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MULTIPART_COMPRESSION_BUCKET, object_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MULTIPART_COMPRESSION_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
|
||||
@@ -366,9 +396,9 @@ async fn test_compression_multipart_high_ratio_binary_roundtrip() -> Result<(),
|
||||
|
||||
// This pattern compresses to roughly 1/50 of its logical size, so a comfortably loose 2x
|
||||
// margin still proves the parts were stored compressed rather than raw or double-encoded.
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_HIGH_RATIO_BUCKET, object_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_HIGH_RATIO_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size as u64) / 2,
|
||||
"Physical size {total_physical_size} should be far below the logical size {total_size} for high-ratio data"
|
||||
@@ -522,9 +552,9 @@ async fn test_compression_multipart_upload_part_copy_roundtrip() -> Result<(), B
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_COPY_COMPRESSION_BUCKET, target_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_COPY_COMPRESSION_BUCKET, target_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the copied object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (copied part compression applied)"
|
||||
@@ -585,9 +615,9 @@ async fn test_compression_multipart_three_parts_part_number_gets() -> Result<(),
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_THREE_PARTS_BUCKET, object_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_THREE_PARTS_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
|
||||
@@ -734,9 +764,9 @@ async fn test_compression_multipart_sse_s3_roundtrip() -> Result<(), Box<dyn std
|
||||
"HEAD must report SSE-S3"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_SSE_COMPRESSION_BUCKET, object_key);
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_SSE_COMPRESSION_BUCKET, object_key)?;
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
let total_physical_size = part_files_total_size(&part_files)?;
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (compress-then-encrypt applied)"
|
||||
|
||||
@@ -59,7 +59,6 @@ where
|
||||
/// Regression test for data usage accuracy (issue #1012).
|
||||
/// Launches rustfs, writes 1000 objects, then asserts admin data usage reports the full count.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server and requires awscurl; enable when running full E2E"]
|
||||
async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -86,28 +85,20 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
|
||||
usage
|
||||
.buckets_usage
|
||||
.get(TEST_BUCKET)
|
||||
.map(|bucket_usage| usage.objects_total_count >= 1000 && bucket_usage.objects_count >= 1000)
|
||||
.map(|bucket_usage| usage.objects_total_count == 1000 && bucket_usage.objects_count == 1000)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Assert total object count and per-bucket count are not truncated
|
||||
// Assert total object count and per-bucket count are exact.
|
||||
let bucket_usage = usage
|
||||
.buckets_usage
|
||||
.get(TEST_BUCKET)
|
||||
.cloned()
|
||||
.expect("bucket usage should exist");
|
||||
|
||||
assert!(
|
||||
usage.objects_total_count >= 1000,
|
||||
"total object count should be at least 1000, got {}",
|
||||
usage.objects_total_count
|
||||
);
|
||||
assert!(
|
||||
bucket_usage.objects_count >= 1000,
|
||||
"bucket object count should be at least 1000, got {}",
|
||||
bucket_usage.objects_count
|
||||
);
|
||||
assert_eq!(usage.objects_total_count, 1000, "total object count should be exact");
|
||||
assert_eq!(bucket_usage.objects_count, 1000, "bucket object count should be exact");
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
@@ -116,7 +107,6 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
|
||||
/// Regression test for issue #3898.
|
||||
/// Versioned buckets should expose versions and delete markers through admin data usage.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server and requires awscurl; enable when running full E2E"]
|
||||
async fn data_usage_reports_versioned_objects_and_delete_markers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
|
||||
@@ -117,9 +117,18 @@ mod tests {
|
||||
);
|
||||
|
||||
// Verify HEAD returns 404
|
||||
let head = client.head_object().bucket(bucket).key("to-delete.txt").send().await;
|
||||
|
||||
assert!(head.is_err(), "RT-05 FAIL: HEAD on deleted object should return error, got success");
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("to-delete.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("RT-05 FAIL: HEAD on deleted object should return 404, got success");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"RT-05 FAIL: HEAD on deleted object must return HTTP 404, got {error:?}"
|
||||
);
|
||||
|
||||
info!("RT-05 PASS: delete correctly removes object from LIST and HEAD");
|
||||
Ok(())
|
||||
@@ -414,9 +423,18 @@ mod tests {
|
||||
|
||||
// All HEAD requests should return 404
|
||||
for key in &keys {
|
||||
let head = client.head_object().bucket(bucket).key(*key).send().await;
|
||||
|
||||
assert!(head.is_err(), "RT-05f FAIL: HEAD on deleted key '{key}' should return error");
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("RT-05f FAIL: HEAD on deleted key should return 404, got success");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"RT-05f FAIL: HEAD on deleted key '{key}' must return HTTP 404, got {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// LIST should be empty
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, awscurl_delete, awscurl_post_sts_form_urlencoded, awscurl_put, init_logging};
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{Delete, ObjectIdentifier, Tag, Tagging};
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
@@ -208,10 +209,17 @@ async fn test_e2e_iam_policy_existing_object_tag_get_object() -> Result<(), Box<
|
||||
let _ = out.body.collect().await?;
|
||||
|
||||
put_object_tag_kv(&admin, &bucket, key, "security", "private").await?;
|
||||
let denied = uclient.get_object().bucket(&bucket).key(key).send().await;
|
||||
assert!(
|
||||
denied.is_err(),
|
||||
"GetObject must be denied when ExistingObjectTag no longer matches IAM policy"
|
||||
let denied = uclient
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("GetObject must be denied when ExistingObjectTag no longer matches IAM policy");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"IAM ExistingObjectTag mismatch must return AccessDenied: {denied:?}"
|
||||
);
|
||||
|
||||
cleanup_bucket_and_object(&admin, &bucket, key).await;
|
||||
@@ -245,8 +253,13 @@ async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), B
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await;
|
||||
assert!(deny_before.is_err(), "without bucket policy, user must be denied");
|
||||
.await
|
||||
.expect_err("without bucket policy, user must be denied");
|
||||
assert_eq!(
|
||||
deny_before.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"missing bucket policy must return AccessDenied: {deny_before:?}"
|
||||
);
|
||||
|
||||
let bp = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
@@ -268,8 +281,18 @@ async fn test_e2e_bucket_policy_existing_object_tag_get_object() -> Result<(), B
|
||||
let _ = ok.body.collect().await?;
|
||||
|
||||
put_object_tag_kv(&admin, &bucket, key, "security", "private").await?;
|
||||
let denied = uclient.get_object().bucket(&bucket).key(key).send().await;
|
||||
assert!(denied.is_err(), "GetObject must fail when tag no longer satisfies bucket policy");
|
||||
let denied = uclient
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("GetObject must fail when tag no longer satisfies bucket policy");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"bucket-policy ExistingObjectTag mismatch must return AccessDenied: {denied:?}"
|
||||
);
|
||||
|
||||
cleanup_bucket_and_object(&admin, &bucket, key).await;
|
||||
admin_remove_user(&env, &user).await;
|
||||
@@ -335,10 +358,17 @@ async fn test_e2e_sts_assume_role_session_policy_existing_object_tag() -> Result
|
||||
let _ = ok.body.collect().await?;
|
||||
|
||||
put_object_tag_kv(&parent_client, &bucket, key, "security", "private").await?;
|
||||
let denied = session_client.get_object().bucket(&bucket).key(key).send().await;
|
||||
assert!(
|
||||
denied.is_err(),
|
||||
"session policy must deny GetObject when ExistingObjectTag no longer matches"
|
||||
let denied = session_client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("session policy must deny GetObject when ExistingObjectTag no longer matches");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"STS ExistingObjectTag mismatch must return AccessDenied: {denied:?}"
|
||||
);
|
||||
|
||||
cleanup_bucket_and_object(&admin, &bucket, key).await;
|
||||
|
||||
@@ -432,7 +432,6 @@ async fn test_configured_local_kms_admin_and_versioned_cleanup() -> TestResult {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a Vault binary"]
|
||||
async fn test_configured_vault_kms_admin_and_versioned_cleanup() -> TestResult {
|
||||
let mut env = VaultTestEnvironment::new().await?;
|
||||
env.start_vault().await?;
|
||||
|
||||
@@ -393,11 +393,10 @@ async fn test_kms_multipart_upload_interruption() -> Result<(), Box<dyn std::err
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test KMS resilience to temporary resource constraints
|
||||
/// Test concurrent KMS encryption requests
|
||||
#[tokio::test]
|
||||
async fn test_kms_resource_constraints() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
async fn test_kms_concurrent_encryption_requests() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("🧪 Testing KMS behavior under resource constraints");
|
||||
|
||||
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||
let _default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||
@@ -431,29 +430,27 @@ async fn test_kms_resource_constraints() -> Result<(), Box<dyn std::error::Error
|
||||
}
|
||||
|
||||
// Wait for all uploads to complete
|
||||
let mut successful_uploads = 0;
|
||||
let mut failed_uploads = 0;
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for task in upload_tasks {
|
||||
let (object_key, result) = task.await.unwrap();
|
||||
let (object_key, result) = task.await?;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
successful_uploads += 1;
|
||||
info!("✅ Rapid upload {} succeeded", object_key);
|
||||
}
|
||||
Err(e) => {
|
||||
failed_uploads += 1;
|
||||
warn!("❌ Rapid upload {} failed: {}", object_key, e);
|
||||
failures.push(format!("{object_key}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("📊 Rapid upload results: {} succeeded, {} failed", successful_uploads, failed_uploads);
|
||||
|
||||
// We expect most uploads to succeed even under load
|
||||
assert!(successful_uploads >= 7, "Expected at least 7/10 rapid uploads to succeed");
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"all 10 concurrent KMS uploads must succeed; failures: {}",
|
||||
failures.join("; ")
|
||||
);
|
||||
|
||||
kms_env.base_env.delete_test_bucket(TEST_BUCKET).await?;
|
||||
info!("✅ Resource constraints test completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -131,8 +131,18 @@ mod tests {
|
||||
|
||||
// DELETE through the raw key removes the normalized object.
|
||||
client.delete_object().bucket(bucket).key("//keyname").send().await?;
|
||||
let result = client.get_object().bucket(bucket).key("keyname").send().await;
|
||||
assert!(result.is_err(), "object must be gone after DELETE with raw key");
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("keyname")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("object must be gone after DELETE with raw key");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"GET after DELETE with raw key must return HTTP 404, got {error:?}"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
info!("Test completed successfully");
|
||||
|
||||
@@ -11,29 +11,20 @@ The tests cover the following AWS policy variable scenarios:
|
||||
3. **Variable concatenation** - Combining variables with static text like `prefix-${aws:username}-suffix`
|
||||
4. **Nested variables** - Complex nested variable patterns like `${${aws:username}-test}`
|
||||
5. **Deny scenarios** - Testing deny policies with variables
|
||||
6. **STS credentials** - Variable resolution inherited by temporary credentials
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- RustFS server binary
|
||||
- `awscurl` utility for admin API calls
|
||||
- AWS SDK for Rust (included in the project)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Policy Tests Using Unified Test Runner
|
||||
|
||||
```bash
|
||||
# Run all policy tests with comprehensive reporting
|
||||
# Note: Requires a RustFS server running on localhost:9000
|
||||
cargo test -p e2e_test policy::test_runner::test_policy_full_suite -- --nocapture --ignored --test-threads=1
|
||||
|
||||
# Run only critical policy tests
|
||||
cargo test -p e2e_test policy::test_runner::test_policy_critical_suite -- --nocapture --ignored --test-threads=1
|
||||
```
|
||||
|
||||
### Run All Policy Tests
|
||||
|
||||
```bash
|
||||
# From the project root directory
|
||||
cargo test -p e2e_test policy:: -- --nocapture --ignored --test-threads=1
|
||||
```
|
||||
cargo test -p e2e_test policy:: -- --nocapture
|
||||
```
|
||||
|
||||
Each test starts an isolated RustFS server on a dynamically allocated local port and cleans it up afterward.
|
||||
|
||||
@@ -18,5 +18,3 @@
|
||||
//! including single-value, multi-value, and nested variable scenarios.
|
||||
|
||||
mod policy_variables_test;
|
||||
mod test_env;
|
||||
mod test_runner;
|
||||
|
||||
@@ -14,14 +14,17 @@
|
||||
|
||||
//! Tests for AWS IAM policy variables with single-value, multi-value, and nested scenarios
|
||||
|
||||
use crate::common::{awscurl_delete, awscurl_put, init_logging};
|
||||
use crate::policy::test_env::PolicyTestEnvironment;
|
||||
use crate::common::{
|
||||
RustFSTestEnvironment, awscurl_delete, awscurl_put, build_test_s3_config, build_test_sts_client, init_logging,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use tracing::info;
|
||||
|
||||
/// Helper function to create a regular user with given credentials
|
||||
async fn create_user(
|
||||
env: &PolicyTestEnvironment,
|
||||
env: &RustFSTestEnvironment,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -36,20 +39,9 @@ async fn create_user(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to create an STS user with given credentials
|
||||
async fn create_sts_user(
|
||||
env: &PolicyTestEnvironment,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// For STS, we create a regular user first, then use it to assume roles
|
||||
create_user(env, username, password).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to create and attach a policy
|
||||
async fn create_and_attach_policy(
|
||||
env: &PolicyTestEnvironment,
|
||||
env: &RustFSTestEnvironment,
|
||||
policy_name: &str,
|
||||
username: &str,
|
||||
policy_document: serde_json::Value,
|
||||
@@ -70,9 +62,9 @@ async fn create_and_attach_policy(
|
||||
}
|
||||
|
||||
/// Helper function to clean up test resources
|
||||
async fn cleanup_user_and_policy(env: &PolicyTestEnvironment, username: &str, policy_name: &str) {
|
||||
async fn cleanup_user_and_policy(env: &RustFSTestEnvironment, username: &str, policy_name: &str) {
|
||||
// Create admin client for cleanup
|
||||
let admin_client = env.create_s3_client(&env.access_key, &env.secret_key);
|
||||
let admin_client = env.create_s3_client();
|
||||
|
||||
// Delete buckets that might have been created by this user
|
||||
let bucket_patterns = [
|
||||
@@ -84,7 +76,7 @@ async fn cleanup_user_and_policy(env: &PolicyTestEnvironment, username: &str, po
|
||||
format!("{username}-test"),
|
||||
format!("{username}-sts-bucket"),
|
||||
format!("{username}-service-bucket"),
|
||||
"private-test-bucket".to_string(), // For deny test
|
||||
format!("{username}-private-bucket"),
|
||||
];
|
||||
|
||||
// Try to delete objects and buckets
|
||||
@@ -121,24 +113,18 @@ async fn cleanup_user_and_policy(env: &PolicyTestEnvironment, username: &str, po
|
||||
|
||||
/// Test AWS policy variables with single-value scenarios
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server; enable when running full E2E"]
|
||||
pub async fn test_aws_policy_variables_single_value() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
test_aws_policy_variables_single_value_impl().await
|
||||
}
|
||||
|
||||
/// Implementation function for single-value policy variables test
|
||||
pub async fn test_aws_policy_variables_single_value_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting AWS policy variables single-value test");
|
||||
|
||||
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
test_aws_policy_variables_single_value_impl_with_env(&env).await
|
||||
}
|
||||
|
||||
/// Implementation function for single-value policy variables test with shared environment
|
||||
pub async fn test_aws_policy_variables_single_value_impl_with_env(
|
||||
env: &PolicyTestEnvironment,
|
||||
async fn test_aws_policy_variables_single_value_impl_with_env(
|
||||
env: &RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create test user
|
||||
let test_user = "testuser1";
|
||||
@@ -198,9 +184,7 @@ pub async fn test_aws_policy_variables_single_value_impl_with_env(
|
||||
awscurl_put(&attach_policy_url, "", &env.access_key, &env.secret_key).await?;
|
||||
|
||||
// Create S3 client for test user
|
||||
let test_client = env.create_s3_client(test_user, test_password);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let test_client = env.create_s3_client_with_credentials(test_user, test_password);
|
||||
|
||||
// Test 1: User should be able to list buckets (allowed by policy)
|
||||
info!("Test 1: User listing buckets");
|
||||
@@ -257,11 +241,13 @@ pub async fn test_aws_policy_variables_single_value_impl_with_env(
|
||||
// Test 6: User should NOT be able to create bucket NOT matching username pattern
|
||||
info!("Test 6: User attempting to create bucket NOT matching pattern");
|
||||
let other_bucket_name = "other-user-bucket";
|
||||
let create_other_result = test_client.create_bucket().bucket(other_bucket_name).send().await;
|
||||
if create_other_result.is_ok() {
|
||||
cleanup().await;
|
||||
return Err("User should NOT be able to create bucket NOT matching username pattern".into());
|
||||
}
|
||||
let denied = test_client
|
||||
.create_bucket()
|
||||
.bucket(other_bucket_name)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a bucket outside the username pattern must be denied");
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
|
||||
// Cleanup
|
||||
info!("Cleaning up test resources");
|
||||
@@ -273,24 +259,18 @@ pub async fn test_aws_policy_variables_single_value_impl_with_env(
|
||||
|
||||
/// Test AWS policy variables with multi-value scenarios
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server; enable when running full E2E"]
|
||||
pub async fn test_aws_policy_variables_multi_value() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
test_aws_policy_variables_multi_value_impl().await
|
||||
}
|
||||
|
||||
/// Implementation function for multi-value policy variables test
|
||||
pub async fn test_aws_policy_variables_multi_value_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting AWS policy variables multi-value test");
|
||||
|
||||
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
test_aws_policy_variables_multi_value_impl_with_env(&env).await
|
||||
}
|
||||
|
||||
/// Implementation function for multi-value policy variables test with shared environment
|
||||
pub async fn test_aws_policy_variables_multi_value_impl_with_env(
|
||||
env: &PolicyTestEnvironment,
|
||||
async fn test_aws_policy_variables_multi_value_impl_with_env(
|
||||
env: &RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create test user
|
||||
let test_user = "testuser2";
|
||||
@@ -338,7 +318,7 @@ pub async fn test_aws_policy_variables_multi_value_impl_with_env(
|
||||
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
|
||||
|
||||
// Create S3 client for test user
|
||||
let test_client = env.create_s3_client(test_user, test_password);
|
||||
let test_client = env.create_s3_client_with_credentials(test_user, test_password);
|
||||
|
||||
// Test 1: User should be able to create buckets matching any of the multi-value patterns
|
||||
info!("Test 1: User creating first bucket matching multi-value pattern");
|
||||
@@ -368,11 +348,13 @@ pub async fn test_aws_policy_variables_multi_value_impl_with_env(
|
||||
// Test 4: User should NOT be able to create bucket NOT matching any multi-value pattern
|
||||
info!("Test 4: User attempting to create bucket NOT matching any pattern");
|
||||
let other_bucket_name = format!("{test_user}-other-bucket");
|
||||
let create_other_result = test_client.create_bucket().bucket(&other_bucket_name).send().await;
|
||||
if create_other_result.is_ok() {
|
||||
cleanup().await;
|
||||
return Err("User should NOT be able to create bucket NOT matching any multi-value pattern".into());
|
||||
}
|
||||
let denied = test_client
|
||||
.create_bucket()
|
||||
.bucket(&other_bucket_name)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a bucket outside all allowed patterns must be denied");
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
|
||||
// Test 5: User should be able to list objects in their allowed buckets
|
||||
info!("Test 5: User listing objects in allowed buckets");
|
||||
@@ -398,24 +380,18 @@ pub async fn test_aws_policy_variables_multi_value_impl_with_env(
|
||||
|
||||
/// Test AWS policy variables with variable concatenation
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server; enable when running full E2E"]
|
||||
pub async fn test_aws_policy_variables_concatenation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
test_aws_policy_variables_concatenation_impl().await
|
||||
}
|
||||
|
||||
/// Implementation function for concatenation policy variables test
|
||||
pub async fn test_aws_policy_variables_concatenation_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting AWS policy variables concatenation test");
|
||||
|
||||
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
test_aws_policy_variables_concatenation_impl_with_env(&env).await
|
||||
}
|
||||
|
||||
/// Implementation function for concatenation policy variables test with shared environment
|
||||
pub async fn test_aws_policy_variables_concatenation_impl_with_env(
|
||||
env: &PolicyTestEnvironment,
|
||||
async fn test_aws_policy_variables_concatenation_impl_with_env(
|
||||
env: &RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create test user
|
||||
let test_user = "testuser3";
|
||||
@@ -455,10 +431,7 @@ pub async fn test_aws_policy_variables_concatenation_impl_with_env(
|
||||
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
|
||||
|
||||
// Create S3 client for test user
|
||||
let test_client = env.create_s3_client(test_user, test_password);
|
||||
|
||||
// Add a small delay to allow policy to propagate
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let test_client = env.create_s3_client_with_credentials(test_user, test_password);
|
||||
|
||||
// Test: User should be able to create bucket matching concatenated pattern
|
||||
info!("Test: User creating bucket matching concatenated pattern");
|
||||
@@ -487,41 +460,30 @@ pub async fn test_aws_policy_variables_concatenation_impl_with_env(
|
||||
|
||||
/// Test AWS policy variables with nested scenarios
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server; enable when running full E2E"]
|
||||
pub async fn test_aws_policy_variables_nested() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
test_aws_policy_variables_nested_impl().await
|
||||
}
|
||||
|
||||
/// Implementation function for nested policy variables test
|
||||
pub async fn test_aws_policy_variables_nested_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting AWS policy variables nested test");
|
||||
|
||||
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
test_aws_policy_variables_nested_impl_with_env(&env).await
|
||||
}
|
||||
|
||||
/// Test AWS policy variables with STS temporary credentials
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server; enable when running full E2E"]
|
||||
pub async fn test_aws_policy_variables_sts() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
test_aws_policy_variables_sts_impl().await
|
||||
}
|
||||
|
||||
/// Implementation function for STS policy variables test
|
||||
pub async fn test_aws_policy_variables_sts_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting AWS policy variables STS test");
|
||||
|
||||
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
test_aws_policy_variables_sts_impl_with_env(&env).await
|
||||
}
|
||||
|
||||
/// Implementation function for nested policy variables test with shared environment
|
||||
pub async fn test_aws_policy_variables_nested_impl_with_env(
|
||||
env: &PolicyTestEnvironment,
|
||||
async fn test_aws_policy_variables_nested_impl_with_env(
|
||||
env: &RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create test user
|
||||
let test_user = "testuser4";
|
||||
@@ -561,10 +523,7 @@ pub async fn test_aws_policy_variables_nested_impl_with_env(
|
||||
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
|
||||
|
||||
// Create S3 client for test user
|
||||
let test_client = env.create_s3_client(test_user, test_password);
|
||||
|
||||
// Add a small delay to allow policy to propagate
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let test_client = env.create_s3_client_with_credentials(test_user, test_password);
|
||||
|
||||
// Test nested variable resolution
|
||||
info!("Test: Nested variable resolution");
|
||||
@@ -581,14 +540,14 @@ pub async fn test_aws_policy_variables_nested_impl_with_env(
|
||||
return Err(format!("User should be able to create bucket with nested variable: {e}").into());
|
||||
}
|
||||
|
||||
// Verify bucket creation fails with unresolved variable
|
||||
let unresolved_bucket = format!("${{}}-test {test_user}");
|
||||
let create_unresolved = test_client.create_bucket().bucket(&unresolved_bucket).send().await;
|
||||
|
||||
if create_unresolved.is_ok() {
|
||||
cleanup().await;
|
||||
return Err("User should NOT be able to create bucket with unresolved variable".into());
|
||||
}
|
||||
// Verify a valid bucket name outside the resolved resource is denied.
|
||||
let denied = test_client
|
||||
.create_bucket()
|
||||
.bucket("other-user-test")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a bucket outside the resolved nested variable must be denied");
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
|
||||
// Cleanup
|
||||
info!("Cleaning up test resources");
|
||||
@@ -598,9 +557,8 @@ pub async fn test_aws_policy_variables_nested_impl_with_env(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Implementation function for STS policy variables test with shared environment
|
||||
pub async fn test_aws_policy_variables_sts_impl_with_env(
|
||||
env: &PolicyTestEnvironment,
|
||||
async fn test_aws_policy_variables_sts_impl_with_env(
|
||||
env: &RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create test user for STS
|
||||
let test_user = "testuser-sts";
|
||||
@@ -612,8 +570,7 @@ pub async fn test_aws_policy_variables_sts_impl_with_env(
|
||||
cleanup_user_and_policy(env, test_user, policy_name).await;
|
||||
};
|
||||
|
||||
// Create STS user
|
||||
create_sts_user(env, test_user, test_password).await?;
|
||||
create_user(env, test_user, test_password).await?;
|
||||
|
||||
// Create policy with STS-compatible variables
|
||||
let policy_document = serde_json::json!({
|
||||
@@ -624,6 +581,11 @@ pub async fn test_aws_policy_variables_sts_impl_with_env(
|
||||
"Action": ["s3:ListAllMyBuckets"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:AssumeRole"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:CreateBucket"],
|
||||
@@ -631,7 +593,12 @@ pub async fn test_aws_policy_variables_sts_impl_with_env(
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListBucket", "s3:PutObject", "s3:GetObject"],
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": [format!("arn:aws:s3:::{}-sts-bucket", "${aws:username}")]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:PutObject", "s3:GetObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{}-sts-bucket/*", "${aws:username}")]
|
||||
}
|
||||
]
|
||||
@@ -639,11 +606,22 @@ pub async fn test_aws_policy_variables_sts_impl_with_env(
|
||||
|
||||
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
|
||||
|
||||
// Create S3 client for test user
|
||||
let test_client = env.create_s3_client(test_user, test_password);
|
||||
|
||||
// Add a small delay to allow policy to propagate
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let assumed = build_test_sts_client(&env.url, test_user, test_password, None, "policy-variable-sts")
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/policy-variable")
|
||||
.role_session_name("policy-variable-e2e")
|
||||
.send()
|
||||
.await?;
|
||||
let credentials = assumed
|
||||
.credentials()
|
||||
.ok_or("AssumeRole response should contain temporary credentials")?;
|
||||
let test_client = Client::from_conf(build_test_s3_config(
|
||||
&env.url,
|
||||
credentials.access_key_id(),
|
||||
credentials.secret_access_key(),
|
||||
Some(credentials.session_token()),
|
||||
"policy-variable-sts-session",
|
||||
));
|
||||
|
||||
// Test: User should be able to create bucket matching STS pattern
|
||||
info!("Test: User creating bucket matching STS pattern");
|
||||
@@ -699,24 +677,18 @@ pub async fn test_aws_policy_variables_sts_impl_with_env(
|
||||
|
||||
/// Test AWS policy variables with deny scenarios
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "Starts a rustfs server; enable when running full E2E"]
|
||||
pub async fn test_aws_policy_variables_deny() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
test_aws_policy_variables_deny_impl().await
|
||||
}
|
||||
|
||||
/// Implementation function for deny policy variables test
|
||||
pub async fn test_aws_policy_variables_deny_impl() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting AWS policy variables deny test");
|
||||
|
||||
let env = PolicyTestEnvironment::with_address("127.0.0.1:9000").await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
test_aws_policy_variables_deny_impl_with_env(&env).await
|
||||
}
|
||||
|
||||
/// Implementation function for deny policy variables test with shared environment
|
||||
pub async fn test_aws_policy_variables_deny_impl_with_env(
|
||||
env: &PolicyTestEnvironment,
|
||||
async fn test_aws_policy_variables_deny_impl_with_env(
|
||||
env: &RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create test user
|
||||
let test_user = "testuser5";
|
||||
@@ -759,10 +731,7 @@ pub async fn test_aws_policy_variables_deny_impl_with_env(
|
||||
create_and_attach_policy(env, policy_name, test_user, policy_document).await?;
|
||||
|
||||
// Create S3 client for test user
|
||||
let test_client = env.create_s3_client(test_user, test_password);
|
||||
|
||||
// Add a small delay to allow policy to propagate
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
let test_client = env.create_s3_client_with_credentials(test_user, test_password);
|
||||
|
||||
// Test 1: User should be able to create bucket matching username pattern
|
||||
info!("Test 1: User creating bucket matching username pattern");
|
||||
@@ -775,12 +744,14 @@ pub async fn test_aws_policy_variables_deny_impl_with_env(
|
||||
|
||||
// Test 2: User should NOT be able to create bucket with "private" in the name (deny rule)
|
||||
info!("Test 2: User attempting to create bucket with 'private' in name (should be denied)");
|
||||
let private_bucket_name = "private-test-bucket";
|
||||
let create_private_result = test_client.create_bucket().bucket(private_bucket_name).send().await;
|
||||
if create_private_result.is_ok() {
|
||||
cleanup().await;
|
||||
return Err("User should NOT be able to create bucket with 'private' in name due to deny rule".into());
|
||||
}
|
||||
let private_bucket_name = format!("{test_user}-private-bucket");
|
||||
let denied = test_client
|
||||
.create_bucket()
|
||||
.bucket(&private_bucket_name)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the explicit deny must reject a matching bucket name");
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
|
||||
// Cleanup
|
||||
info!("Cleaning up test resources");
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! Custom test environment for policy variables tests
|
||||
//!
|
||||
//! This module provides a custom test environment that doesn't automatically
|
||||
//! stop servers when destroyed, addressing the server stopping issue.
|
||||
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::{Config, Credentials, Region};
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{info, warn};
|
||||
|
||||
// Default credentials
|
||||
const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||
const DEFAULT_SECRET_KEY: &str = "rustfsadmin";
|
||||
|
||||
/// Custom test environment that doesn't automatically stop servers
|
||||
pub struct PolicyTestEnvironment {
|
||||
pub temp_dir: String,
|
||||
pub address: String,
|
||||
pub url: String,
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
}
|
||||
|
||||
impl PolicyTestEnvironment {
|
||||
/// Create a new test environment with specific address
|
||||
/// This environment won't stop any server when dropped
|
||||
pub async fn with_address(address: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let temp_dir = format!("/tmp/rustfs_policy_test_{}", uuid::Uuid::new_v4());
|
||||
tokio::fs::create_dir_all(&temp_dir).await?;
|
||||
|
||||
let url = format!("http://{address}");
|
||||
|
||||
Ok(Self {
|
||||
temp_dir,
|
||||
address: address.to_string(),
|
||||
url,
|
||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an AWS S3 client configured for this RustFS instance
|
||||
pub fn create_s3_client(&self, access_key: &str, secret_key: &str) -> Client {
|
||||
let credentials = Credentials::new(access_key, secret_key, None, None, "policy-test");
|
||||
let config = Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(&self.url)
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
Client::from_conf(config)
|
||||
}
|
||||
|
||||
/// Wait for RustFS server to be ready by checking TCP connectivity
|
||||
pub async fn wait_for_server_ready(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!("Waiting for RustFS server to be ready on {}", self.address);
|
||||
|
||||
for i in 0..30 {
|
||||
if TcpStream::connect(&self.address).is_ok() {
|
||||
info!("✅ RustFS server is ready after {} attempts", i + 1);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if i == 29 {
|
||||
return Err("RustFS server failed to become ready within 30 seconds".into());
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Drop trait that doesn't stop servers
|
||||
impl Drop for PolicyTestEnvironment {
|
||||
fn drop(&mut self) {
|
||||
// Clean up temp directory only, don't stop any server
|
||||
if let Err(e) = std::fs::remove_dir_all(&self.temp_dir) {
|
||||
warn!("Failed to clean up temp directory {}: {}", self.temp_dir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
// 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 crate::common::init_logging;
|
||||
use crate::policy::test_env::PolicyTestEnvironment;
|
||||
use std::time::Instant;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Test case definition
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestDefinition {
|
||||
pub name: String,
|
||||
pub is_critical: bool,
|
||||
}
|
||||
|
||||
impl TestDefinition {
|
||||
pub fn new(name: impl Into<String>, is_critical: bool) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
is_critical,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test result
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestResult {
|
||||
pub test_name: String,
|
||||
pub success: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl TestResult {
|
||||
pub fn success(test_name: String) -> Self {
|
||||
Self {
|
||||
test_name,
|
||||
success: true,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failure(test_name: String, error: String) -> Self {
|
||||
Self {
|
||||
test_name,
|
||||
success: false,
|
||||
error_message: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test suite configuration
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TestSuiteConfig {
|
||||
pub include_critical_only: bool,
|
||||
}
|
||||
|
||||
/// Policy test suite
|
||||
pub struct PolicyTestSuite {
|
||||
tests: Vec<TestDefinition>,
|
||||
config: TestSuiteConfig,
|
||||
}
|
||||
|
||||
impl PolicyTestSuite {
|
||||
/// Create default test suite
|
||||
pub fn new() -> Self {
|
||||
let tests = vec![
|
||||
TestDefinition::new("test_aws_policy_variables_single_value", true),
|
||||
TestDefinition::new("test_aws_policy_variables_multi_value", true),
|
||||
TestDefinition::new("test_aws_policy_variables_concatenation", true),
|
||||
TestDefinition::new("test_aws_policy_variables_nested", true),
|
||||
TestDefinition::new("test_aws_policy_variables_deny", true),
|
||||
TestDefinition::new("test_aws_policy_variables_sts", true),
|
||||
];
|
||||
|
||||
Self {
|
||||
tests,
|
||||
config: TestSuiteConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure test suite
|
||||
pub fn with_config(mut self, config: TestSuiteConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Run test suite
|
||||
pub async fn run_test_suite(&self) -> Vec<TestResult> {
|
||||
init_logging();
|
||||
info!("Starting Policy Variables test suite");
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Create test environment
|
||||
let env = match PolicyTestEnvironment::with_address("127.0.0.1:9000").await {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
error!("Failed to create test environment: {}", e);
|
||||
return vec![TestResult::failure("env_creation".into(), e.to_string())];
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for server to be ready
|
||||
if env.wait_for_server_ready().await.is_err() {
|
||||
error!("Server is not ready");
|
||||
return vec![TestResult::failure("server_check".into(), "Server not ready".into())];
|
||||
}
|
||||
|
||||
// Filter tests
|
||||
let tests_to_run: Vec<&TestDefinition> = self
|
||||
.tests
|
||||
.iter()
|
||||
.filter(|test| !self.config.include_critical_only || test.is_critical)
|
||||
.collect();
|
||||
|
||||
info!("Scheduled {} tests", tests_to_run.len());
|
||||
|
||||
// Run tests
|
||||
for (i, test_def) in tests_to_run.iter().enumerate() {
|
||||
info!("Running test {}/{}: {}", i + 1, tests_to_run.len(), test_def.name);
|
||||
let test_start = Instant::now();
|
||||
|
||||
let result = self.run_single_test(test_def, &env).await;
|
||||
let test_duration = test_start.elapsed();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!("Test passed: {} ({:.2}s)", test_def.name, test_duration.as_secs_f64());
|
||||
results.push(TestResult::success(test_def.name.clone()));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Test failed: {} ({:.2}s): {}", test_def.name, test_duration.as_secs_f64(), e);
|
||||
results.push(TestResult::failure(test_def.name.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between tests to avoid resource conflicts
|
||||
if i < tests_to_run.len() - 1 {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
self.print_summary(&results, start_time.elapsed());
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Run a single test
|
||||
async fn run_single_test(
|
||||
&self,
|
||||
test_def: &TestDefinition,
|
||||
env: &PolicyTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
match test_def.name.as_str() {
|
||||
"test_aws_policy_variables_single_value" => {
|
||||
super::policy_variables_test::test_aws_policy_variables_single_value_impl_with_env(env).await
|
||||
}
|
||||
"test_aws_policy_variables_multi_value" => {
|
||||
super::policy_variables_test::test_aws_policy_variables_multi_value_impl_with_env(env).await
|
||||
}
|
||||
"test_aws_policy_variables_concatenation" => {
|
||||
super::policy_variables_test::test_aws_policy_variables_concatenation_impl_with_env(env).await
|
||||
}
|
||||
"test_aws_policy_variables_nested" => {
|
||||
super::policy_variables_test::test_aws_policy_variables_nested_impl_with_env(env).await
|
||||
}
|
||||
"test_aws_policy_variables_deny" => {
|
||||
super::policy_variables_test::test_aws_policy_variables_deny_impl_with_env(env).await
|
||||
}
|
||||
"test_aws_policy_variables_sts" => {
|
||||
super::policy_variables_test::test_aws_policy_variables_sts_impl_with_env(env).await
|
||||
}
|
||||
_ => Err(format!("Test {} not implemented", test_def.name).into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print test summary
|
||||
fn print_summary(&self, results: &[TestResult], total_duration: Duration) {
|
||||
info!("=== Test Suite Summary ===");
|
||||
info!("Total duration: {:.2}s", total_duration.as_secs_f64());
|
||||
info!("Total tests: {}", results.len());
|
||||
|
||||
let passed = results.iter().filter(|r| r.success).count();
|
||||
let failed = results.len() - passed;
|
||||
let success_rate = (passed as f64 / results.len() as f64) * 100.0;
|
||||
|
||||
info!("Passed: {} | Failed: {}", passed, failed);
|
||||
info!("Success rate: {:.1}%", success_rate);
|
||||
|
||||
if failed > 0 {
|
||||
error!("Failed tests:");
|
||||
for result in results.iter().filter(|r| !r.success) {
|
||||
error!(" - {}: {}", result.test_name, result.error_message.as_ref().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test suite
|
||||
#[tokio::test]
|
||||
#[ignore = "Connects to existing rustfs server"]
|
||||
async fn test_policy_critical_suite() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let config = TestSuiteConfig {
|
||||
include_critical_only: true,
|
||||
};
|
||||
let suite = PolicyTestSuite::new().with_config(config);
|
||||
let results = suite.run_test_suite().await;
|
||||
|
||||
let failed = results.iter().filter(|r| !r.success).count();
|
||||
if failed > 0 {
|
||||
return Err(format!("Critical tests failed: {failed} failures").into());
|
||||
}
|
||||
|
||||
info!("All critical tests passed");
|
||||
Ok(())
|
||||
}
|
||||
@@ -340,7 +340,18 @@ async fn tampered_presigned_put_returns_signature_does_not_match() -> Result<(),
|
||||
assert_error_code(&body, "SignatureDoesNotMatch");
|
||||
|
||||
// The rejected write must not have created the object.
|
||||
let head = env.create_s3_client().head_object().bucket(BUCKET).key(key).send().await;
|
||||
assert!(head.is_err(), "tampered presigned PUT must not store the object");
|
||||
let error = env
|
||||
.create_s3_client()
|
||||
.head_object()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("tampered presigned PUT must not store the object");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"tampered presigned PUT absence probe must return HTTP 404, got {error:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -453,27 +453,28 @@ fn watch_session_lifecycle_events(child: &mut Child, counters: Arc<SessionCounte
|
||||
}
|
||||
|
||||
/// Count TCP connections in CLOSE_WAIT against the given local port
|
||||
/// by shelling out to ss -tn state CLOSE-WAIT. The check is
|
||||
/// best-effort: if ss is missing on the host the function returns
|
||||
/// Ok(None) and the caller skips the assertion. The contract is zero
|
||||
/// by shelling out to ss -tn state CLOSE-WAIT. The oracle fails closed
|
||||
/// if ss is missing or cannot inspect socket state. The contract is zero
|
||||
/// CLOSE_WAIT entries attributable to the test.
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn count_close_wait_on_port(port: u16) -> Result<Option<usize>> {
|
||||
let output = match Command::new("ss").args(["-tn", "state", "CLOSE-WAIT"]).output().await {
|
||||
Ok(o) => o,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
async fn count_close_wait_on_port(port: u16, test_id: &str) -> Result<usize> {
|
||||
let port_filter = format!("sport = :{port}");
|
||||
let output = Command::new("ss")
|
||||
.args(["-H", "-t", "-n", "state", "close-wait"])
|
||||
.arg(&port_filter)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| anyhow!("{test_id} failed to run ss CLOSE_WAIT oracle: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
return Err(anyhow!(
|
||||
"{test_id} ss CLOSE_WAIT oracle exited with {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let needle_local = format!(":{port} ");
|
||||
let needle_local_eol = format!(":{port}\n");
|
||||
let count = stdout
|
||||
.lines()
|
||||
.filter(|l| l.contains(&needle_local) || l.contains(needle_local_eol.trim_end()))
|
||||
.count();
|
||||
Ok(Some(count))
|
||||
let count = stdout.lines().filter(|line| !line.trim().is_empty()).count();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// CMPTST-01: medium-binary upload then download with SHA256 compare.
|
||||
@@ -1400,7 +1401,7 @@ pub(crate) mod cmptst_24 {
|
||||
// the JoinSet flushes finished tasks before the assertion runs.
|
||||
// 5. Assert the entered/finished session counters balance and that
|
||||
// no CLOSE_WAIT sockets remain on the bind port (Linux ss(8)
|
||||
// only; the assertion skips with a warn if ss is unavailable).
|
||||
// only; missing or failed socket inspection is an error).
|
||||
pub(crate) async fn run_concurrent_half_close_no_leak() -> Result<()> {
|
||||
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow!("{}", e))?;
|
||||
let host_key_dir = PathBuf::from(&env.temp_dir).join("sftp_host_keys");
|
||||
@@ -1520,15 +1521,13 @@ pub(crate) mod cmptst_24 {
|
||||
));
|
||||
}
|
||||
|
||||
match count_close_wait_on_port(HALF_CLOSE_SFTP_PORT).await? {
|
||||
Some(0) => info!("{COMPLIANCE_TEST_OUTPUT_ID}: zero CLOSE_WAIT entries against port {HALF_CLOSE_SFTP_PORT}"),
|
||||
Some(n) => {
|
||||
return Err(anyhow!(
|
||||
"{COMPLIANCE_TEST_OUTPUT_ID} {n} CLOSE_WAIT entries against port {HALF_CLOSE_SFTP_PORT}, expected 0"
|
||||
));
|
||||
}
|
||||
None => info!("{COMPLIANCE_TEST_OUTPUT_ID}: ss(8) unavailable, skipping CLOSE_WAIT assertion"),
|
||||
let close_wait = count_close_wait_on_port(HALF_CLOSE_SFTP_PORT, COMPLIANCE_TEST_OUTPUT_ID).await?;
|
||||
if close_wait != 0 {
|
||||
return Err(anyhow!(
|
||||
"{COMPLIANCE_TEST_OUTPUT_ID} {close_wait} CLOSE_WAIT entries against port {HALF_CLOSE_SFTP_PORT}, expected 0"
|
||||
));
|
||||
}
|
||||
info!("{COMPLIANCE_TEST_OUTPUT_ID}: zero CLOSE_WAIT entries against port {HALF_CLOSE_SFTP_PORT}");
|
||||
|
||||
// Drop the keepalive vector now so the test process does
|
||||
// not leave the half-closed sockets dangling past the
|
||||
@@ -1947,7 +1946,7 @@ pub(crate) mod cmptst_25 {
|
||||
// plus two 15 s ticks worst-case = 60 s) to detect CLOSE_WAIT
|
||||
// via /proc/net/tcp and cancel the parked session.
|
||||
// 5. Assert the session task counters balance and CLOSE_WAIT count
|
||||
// is zero (ss(8) only; skips with a warn when ss is missing).
|
||||
// is zero (ss(8) only; missing or failed socket inspection is an error).
|
||||
pub(crate) async fn run_wedge_kill_after_silence_in_close_wait() -> Result<()> {
|
||||
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow!("{}", e))?;
|
||||
let host_key_dir = PathBuf::from(&env.temp_dir).join("sftp_host_keys");
|
||||
@@ -2056,15 +2055,13 @@ pub(crate) mod cmptst_25 {
|
||||
));
|
||||
}
|
||||
|
||||
match count_close_wait_on_port(WEDGE_SFTP_PORT).await? {
|
||||
Some(0) => info!("{COMPLIANCE_TEST_OUTPUT_ID}: zero CLOSE_WAIT entries against port {WEDGE_SFTP_PORT}"),
|
||||
Some(n) => {
|
||||
return Err(anyhow!(
|
||||
"{COMPLIANCE_TEST_OUTPUT_ID} {n} CLOSE_WAIT entries against port {WEDGE_SFTP_PORT}, expected 0"
|
||||
));
|
||||
}
|
||||
None => info!("{COMPLIANCE_TEST_OUTPUT_ID}: ss(8) unavailable, skipping CLOSE_WAIT assertion"),
|
||||
let close_wait = count_close_wait_on_port(WEDGE_SFTP_PORT, COMPLIANCE_TEST_OUTPUT_ID).await?;
|
||||
if close_wait != 0 {
|
||||
return Err(anyhow!(
|
||||
"{COMPLIANCE_TEST_OUTPUT_ID} {close_wait} CLOSE_WAIT entries against port {WEDGE_SFTP_PORT}, expected 0"
|
||||
));
|
||||
}
|
||||
info!("{COMPLIANCE_TEST_OUTPUT_ID}: zero CLOSE_WAIT entries against port {WEDGE_SFTP_PORT}");
|
||||
|
||||
drop(keepalive);
|
||||
info!("PASS {COMPLIANCE_TEST_OUTPUT_ID}: wedged sessions killed by the watchdog");
|
||||
|
||||
@@ -682,18 +682,26 @@ mod integration_tests {
|
||||
assert!(resp.contains("quota_limit"));
|
||||
|
||||
// Normal user sets quota — should be denied
|
||||
let set_resp = awscurl_put(
|
||||
let set_error = awscurl_put(
|
||||
&get_url,
|
||||
&serde_json::json!({"quota": 2048, "quota_type": "HARD"}).to_string(),
|
||||
normal_ak,
|
||||
normal_sk,
|
||||
)
|
||||
.await;
|
||||
assert!(set_resp.is_err(), "normal user should not be able to set quota");
|
||||
.await
|
||||
.expect_err("normal user should not be able to set quota")
|
||||
.to_string();
|
||||
assert!(set_error.contains("AccessDenied"), "quota denial must return AccessDenied: {set_error}");
|
||||
|
||||
// Normal user clears quota — should be denied
|
||||
let del_resp = awscurl_delete(&get_url, normal_ak, normal_sk).await;
|
||||
assert!(del_resp.is_err(), "normal user should not be able to clear quota");
|
||||
let delete_error = awscurl_delete(&get_url, normal_ak, normal_sk)
|
||||
.await
|
||||
.expect_err("normal user should not be able to clear quota")
|
||||
.to_string();
|
||||
assert!(
|
||||
delete_error.contains("AccessDenied"),
|
||||
"quota deletion denial must return AccessDenied: {delete_error}"
|
||||
);
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
Ok(())
|
||||
|
||||
@@ -13,55 +13,37 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use aws_config::meta::region::RegionProviderChain;
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{
|
||||
CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput, JsonType, OutputSerialization,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use std::error::Error;
|
||||
use std::time::Duration;
|
||||
|
||||
const ENDPOINT: &str = "http://localhost:9000";
|
||||
const ACCESS_KEY: &str = "rustfsadmin";
|
||||
const SECRET_KEY: &str = "rustfsadmin";
|
||||
const BUCKET: &str = "test-sql-bucket";
|
||||
const CSV_OBJECT: &str = "test-data.csv";
|
||||
const JSON_OBJECT: &str = "test-data.json";
|
||||
const SELECT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
async fn create_aws_s3_client() -> Result<Client, Box<dyn Error>> {
|
||||
let region_provider = RegionProviderChain::default_provider().or_else(Region::new("us-east-1"));
|
||||
let shared_config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.region(region_provider)
|
||||
.credentials_provider(Credentials::new(ACCESS_KEY, SECRET_KEY, None, None, "static"))
|
||||
.endpoint_url(ENDPOINT)
|
||||
.load()
|
||||
.await;
|
||||
type TestResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
||||
|
||||
let client = Client::from_conf(
|
||||
aws_sdk_s3::Config::from(&shared_config)
|
||||
.to_builder()
|
||||
.force_path_style(true) // Important for S3-compatible services
|
||||
.build(),
|
||||
);
|
||||
|
||||
Ok(client)
|
||||
async fn create_test_environment() -> TestResult<(RustFSTestEnvironment, Client)> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let client = env.create_s3_client();
|
||||
Ok((env, client))
|
||||
}
|
||||
|
||||
async fn setup_test_bucket(client: &Client) -> Result<(), Box<dyn Error>> {
|
||||
match client.create_bucket().bucket(BUCKET).send().await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let error_str = e.to_string();
|
||||
if !error_str.contains("BucketAlreadyOwnedByYou") && !error_str.contains("BucketAlreadyExists") {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn setup_test_bucket(client: &Client) -> TestResult<()> {
|
||||
client.create_bucket().bucket(BUCKET).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_test_csv(client: &Client) -> Result<(), Box<dyn Error>> {
|
||||
async fn upload_test_csv(client: &Client) -> TestResult<()> {
|
||||
let csv_data = "name,age,city\nAlice,30,New York\nBob,25,Los Angeles\nCharlie,35,Chicago\nDiana,28,Boston";
|
||||
|
||||
client
|
||||
@@ -75,7 +57,7 @@ async fn upload_test_csv(client: &Client) -> Result<(), Box<dyn Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_test_json(client: &Client) -> Result<(), Box<dyn Error>> {
|
||||
async fn upload_test_json(client: &Client) -> TestResult<()> {
|
||||
let json_data = r#"{"name":"Alice","age":30,"city":"New York"}
|
||||
{"name":"Bob","age":25,"city":"Los Angeles"}
|
||||
{"name":"Charlie","age":35,"city":"Chicago"}
|
||||
@@ -93,33 +75,38 @@ async fn upload_test_json(client: &Client) -> Result<(), Box<dyn Error>> {
|
||||
|
||||
async fn process_select_response(
|
||||
mut event_stream: aws_sdk_s3::operation::select_object_content::SelectObjectContentOutput,
|
||||
) -> Result<String, Box<dyn Error>> {
|
||||
let mut total_data = Vec::new();
|
||||
) -> TestResult<String> {
|
||||
tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async move {
|
||||
let mut total_data = Vec::new();
|
||||
let mut saw_end = false;
|
||||
|
||||
while let Ok(Some(event)) = event_stream.payload.recv().await {
|
||||
match event {
|
||||
aws_sdk_s3::types::SelectObjectContentEventStream::Records(records_event) => {
|
||||
if let Some(payload) = records_event.payload {
|
||||
let data = payload.into_inner();
|
||||
total_data.extend_from_slice(&data);
|
||||
while let Some(event) = event_stream.payload.recv().await? {
|
||||
match event {
|
||||
aws_sdk_s3::types::SelectObjectContentEventStream::Records(records_event) => {
|
||||
if let Some(payload) = records_event.payload {
|
||||
total_data.extend_from_slice(payload.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
aws_sdk_s3::types::SelectObjectContentEventStream::End(_) => {
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
// Handle other event types (Stats, Progress, Cont, etc.)
|
||||
aws_sdk_s3::types::SelectObjectContentEventStream::End(_) => {
|
||||
saw_end = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(String::from_utf8(total_data)?)
|
||||
if !saw_end {
|
||||
return Err("Select response ended without an End event".into());
|
||||
}
|
||||
Ok(String::from_utf8(total_data)?)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| -> Box<dyn Error + Send + Sync> { "Select response timed out".into() })?
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_select_object_content_csv_basic() -> Result<(), Box<dyn Error>> {
|
||||
let client = create_aws_s3_client().await?;
|
||||
async fn test_select_object_content_csv_basic() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
upload_test_csv(&client).await?;
|
||||
|
||||
@@ -158,9 +145,8 @@ async fn test_select_object_content_csv_basic() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_select_object_content_csv_aggregation() -> Result<(), Box<dyn Error>> {
|
||||
let client = create_aws_s3_client().await?;
|
||||
async fn test_select_object_content_csv_aggregation() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
upload_test_csv(&client).await?;
|
||||
|
||||
@@ -203,16 +189,15 @@ async fn test_select_object_content_csv_aggregation() -> Result<(), Box<dyn Erro
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_select_object_content_json_basic() -> Result<(), Box<dyn Error>> {
|
||||
let client = create_aws_s3_client().await?;
|
||||
async fn test_select_object_content_json_basic() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
upload_test_json(&client).await?;
|
||||
|
||||
// Construct JSON query
|
||||
let sql = "SELECT s.name, s.age FROM S3Object s WHERE s.age > 28";
|
||||
|
||||
let json_input = JsonInput::builder().set_type(Some(JsonType::Document)).build();
|
||||
let json_input = JsonInput::builder().set_type(Some(JsonType::Lines)).build();
|
||||
|
||||
let input_serialization = InputSerialization::builder().json(json_input).build();
|
||||
|
||||
@@ -244,9 +229,8 @@ async fn test_select_object_content_json_basic() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_select_object_content_csv_limit() -> Result<(), Box<dyn Error>> {
|
||||
let client = create_aws_s3_client().await?;
|
||||
async fn test_select_object_content_csv_limit() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
upload_test_csv(&client).await?;
|
||||
|
||||
@@ -286,9 +270,8 @@ async fn test_select_object_content_csv_limit() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_select_object_content_csv_order_by() -> Result<(), Box<dyn Error>> {
|
||||
let client = create_aws_s3_client().await?;
|
||||
async fn test_select_object_content_csv_order_by() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
upload_test_csv(&client).await?;
|
||||
|
||||
@@ -318,9 +301,10 @@ async fn test_select_object_content_csv_order_by() -> Result<(), Box<dyn Error>>
|
||||
println!("CSV Order By result: {result_str}");
|
||||
|
||||
// Verify ordered by age descending
|
||||
assert!(
|
||||
result_str.lines().filter(|line| !line.trim().is_empty()).count() >= 2,
|
||||
"Should return at least 2 records"
|
||||
assert_eq!(
|
||||
result_str.lines().filter(|line| !line.trim().is_empty()).count(),
|
||||
2,
|
||||
"Should return exactly 2 records"
|
||||
);
|
||||
|
||||
// Check if contains highest age records
|
||||
@@ -331,9 +315,8 @@ async fn test_select_object_content_csv_order_by() -> Result<(), Box<dyn Error>>
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_select_object_content_error_handling() -> Result<(), Box<dyn Error>> {
|
||||
let client = create_aws_s3_client().await?;
|
||||
async fn test_select_object_content_error_handling() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
upload_test_csv(&client).await?;
|
||||
|
||||
@@ -348,7 +331,7 @@ async fn test_select_object_content_error_handling() -> Result<(), Box<dyn Error
|
||||
let output_serialization = OutputSerialization::builder().csv(csv_output).build();
|
||||
|
||||
// This query should fail because invalid_column doesn't exist
|
||||
let result = client
|
||||
let error = client
|
||||
.select_object_content()
|
||||
.bucket(BUCKET)
|
||||
.key(CSV_OBJECT)
|
||||
@@ -357,18 +340,20 @@ async fn test_select_object_content_error_handling() -> Result<(), Box<dyn Error
|
||||
.input_serialization(input_serialization)
|
||||
.output_serialization(output_serialization)
|
||||
.send()
|
||||
.await;
|
||||
.await
|
||||
.expect_err("a query referencing an unknown column must fail");
|
||||
|
||||
// Verify query fails (expected behavior)
|
||||
assert!(result.is_err(), "Query with invalid column should fail");
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("EvaluatorBindingDoesNotExist")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires running RustFS server at localhost:9000"]
|
||||
async fn test_select_object_content_nonexistent_object() -> Result<(), Box<dyn Error>> {
|
||||
let client = create_aws_s3_client().await?;
|
||||
async fn test_select_object_content_nonexistent_object() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
|
||||
// Test query on nonexistent object
|
||||
@@ -381,7 +366,7 @@ async fn test_select_object_content_nonexistent_object() -> Result<(), Box<dyn E
|
||||
let csv_output = CsvOutput::builder().build();
|
||||
let output_serialization = OutputSerialization::builder().csv(csv_output).build();
|
||||
|
||||
let result = client
|
||||
let error = client
|
||||
.select_object_content()
|
||||
.bucket(BUCKET)
|
||||
.key("nonexistent.csv")
|
||||
@@ -390,10 +375,10 @@ async fn test_select_object_content_nonexistent_object() -> Result<(), Box<dyn E
|
||||
.input_serialization(input_serialization)
|
||||
.output_serialization(output_serialization)
|
||||
.send()
|
||||
.await;
|
||||
.await
|
||||
.expect_err("selecting a missing object must fail");
|
||||
|
||||
// Verify query fails (expected behavior)
|
||||
assert!(result.is_err(), "Query on nonexistent object should fail");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -404,8 +404,18 @@ mod tests {
|
||||
info!("✅ DELETE object succeeded");
|
||||
|
||||
// Verify it's deleted
|
||||
let result = client.get_object().bucket(bucket).key(key).send().await;
|
||||
assert!(result.is_err(), "Object should not exist after DELETE");
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Object should not exist after DELETE");
|
||||
assert_eq!(
|
||||
error.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"GET after DELETE must return HTTP 404, got {error:?}"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
env.stop_server();
|
||||
|
||||
@@ -243,8 +243,8 @@ pub mod cache {
|
||||
|
||||
pub mod capacity {
|
||||
pub use crate::core::pools::{
|
||||
PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free, path2_bucket_object,
|
||||
path2_bucket_object_with_base_path,
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
path2_bucket_object, path2_bucket_object_with_base_path,
|
||||
};
|
||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||
}
|
||||
@@ -415,9 +415,9 @@ pub mod notification {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
|
||||
pub use crate::services::notification_sys::{
|
||||
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, acquire_cross_pool_fence_fleet_proof,
|
||||
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, new_global_notification_sys,
|
||||
start_remote_version_state_fleet_probe,
|
||||
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||
new_global_notification_sys, start_remote_version_state_fleet_probe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,9 +426,10 @@ pub mod object {
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, NamespaceLockFence, ObjectEncryptionResolver,
|
||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
||||
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest, StreamConsumer,
|
||||
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
||||
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, StreamConsumer, get_object_body_cache_plaintext_len,
|
||||
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
||||
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
};
|
||||
pub use crate::store::{
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
@@ -442,6 +443,12 @@ pub mod rebalance {
|
||||
RebalanceMeta, RebalanceStats, RebalanceStopPropagationRecord, decode_rebalance_stop_propagation_record,
|
||||
encode_rebalance_stop_propagation_record,
|
||||
};
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::services::rebalance::PausedRebalanceEntryTestFixture;
|
||||
pub use crate::services::rebalance::test_store_with_persisted_rebalance_meta;
|
||||
}
|
||||
}
|
||||
|
||||
pub mod rio {
|
||||
@@ -455,8 +462,9 @@ pub mod rpc {
|
||||
pub use crate::cluster::rpc::{
|
||||
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
|
||||
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||
ScannerBucketListing, ScannerPeerActivity, TONIC_RPC_PREFIX, TonicInterceptor, build_put_file_auth_trailer,
|
||||
check_and_record_signed_rpc_nonce, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
||||
ScannerBucketListing, ScannerPeerActivity, ScannerPublicationLease, TONIC_RPC_PREFIX, TonicInterceptor,
|
||||
build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
|
||||
encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
||||
gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
|
||||
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, sign_ns_scanner_capability,
|
||||
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
|
||||
@@ -491,7 +499,7 @@ pub mod storage {
|
||||
pub use crate::core::pools::HealLifecycleExpiryContext;
|
||||
pub use crate::store::HealWalkVersion;
|
||||
pub use crate::store::{
|
||||
ECStore, all_local_disk, all_local_disk_path, find_local_disk_by_ref, init_local_disks,
|
||||
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, all_local_disk, all_local_disk_path, find_local_disk_by_ref, init_local_disks,
|
||||
init_local_disks_with_instance_ctx, init_lock_clients, prewarm_local_disk_id_map,
|
||||
prewarm_local_disk_id_map_with_instance_ctx,
|
||||
};
|
||||
|
||||
@@ -4365,6 +4365,16 @@ pub async fn expire_transitioned_object(
|
||||
lc_event: &lifecycle::Event,
|
||||
_src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> Result<ObjectInfo, std::io::Error> {
|
||||
expire_transitioned_object_with_lock_lost_signal(api, oi, lc_event, bucket_incarnation_id, None).await
|
||||
}
|
||||
|
||||
async fn expire_transitioned_object_with_lock_lost_signal(
|
||||
api: Arc<ECStore>,
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> Result<ObjectInfo, std::io::Error> {
|
||||
let publication_guard = lifecycle_expiry_publication_guard(&api, oi, bucket_incarnation_id)
|
||||
.await
|
||||
@@ -4376,6 +4386,9 @@ pub async fn expire_transitioned_object(
|
||||
let mut opts = transitioned_object_delete_opts(oi, lc_event.action, versioned, version_suspended, bucket_incarnation_id)
|
||||
.map_err(std::io::Error::other)?;
|
||||
opts.add_namespace_lock_guard(&publication_guard);
|
||||
if let Some(signal) = lock_lost_signal {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
opts.delete_replication_config_snapshot = Some(Arc::new(snapshot));
|
||||
//let tags = LcAuditEvent::new(src, lcEvent).Tags();
|
||||
if lc_event.action.delete_restored() {
|
||||
@@ -5034,12 +5047,32 @@ pub async fn apply_expiry_on_transitioned_object(
|
||||
lc_event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
apply_expiry_on_transitioned_object_with_lock_lost_signal(api, oi, lc_event, src, bucket_incarnation_id, None).await
|
||||
}
|
||||
|
||||
async fn apply_expiry_on_transitioned_object_with_lock_lost_signal(
|
||||
api: Arc<ECStore>,
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
_src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> bool {
|
||||
if lc_event.action.delete_all() {
|
||||
return apply_expiry_on_non_transitioned_objects(api, oi, lc_event, src, bucket_incarnation_id).await;
|
||||
return apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
api,
|
||||
oi,
|
||||
lc_event,
|
||||
bucket_incarnation_id,
|
||||
lock_lost_signal,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let time_ilm = Metrics::time_ilm(lc_event.action);
|
||||
if let Err(_err) = expire_transitioned_object(api, oi, lc_event, src, bucket_incarnation_id).await {
|
||||
if let Err(_err) =
|
||||
expire_transitioned_object_with_lock_lost_signal(api, oi, lc_event, bucket_incarnation_id, lock_lost_signal).await
|
||||
{
|
||||
return false;
|
||||
}
|
||||
time_ilm(1)();
|
||||
@@ -5053,6 +5086,16 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
lc_event: &lifecycle::Event,
|
||||
_src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(api, oi, lc_event, bucket_incarnation_id, None).await
|
||||
}
|
||||
|
||||
async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
api: Arc<ECStore>,
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> bool {
|
||||
let Some(publication_guard) = lifecycle_expiry_publication_guard(&api, oi, bucket_incarnation_id).await else {
|
||||
return false;
|
||||
@@ -5083,6 +5126,9 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
..Default::default()
|
||||
};
|
||||
opts.add_namespace_lock_guard(&publication_guard);
|
||||
if let Some(signal) = lock_lost_signal {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
|
||||
if lc_event.action.delete_versioned() {
|
||||
opts.version_id = oi.version_id.map(|v| v.to_string());
|
||||
@@ -5164,6 +5210,61 @@ async fn enqueue_expiry_rule_with_incarnation(
|
||||
expiry_state.enqueue_by_days(oi, event, src, bucket_incarnation_id)
|
||||
}
|
||||
|
||||
fn lifecycle_expiry_object_matches(current: &ObjectInfo, expected: &ObjectInfo) -> bool {
|
||||
current.version_id == expected.version_id
|
||||
&& current.data_dir == expected.data_dir
|
||||
&& current.mod_time == expected.mod_time
|
||||
&& current.etag == expected.etag
|
||||
&& current.delete_marker == expected.delete_marker
|
||||
&& current.transitioned_object.name == expected.transitioned_object.name
|
||||
&& current.transitioned_object.version_id == expected.transitioned_object.version_id
|
||||
&& current.transitioned_object.tier == expected.transitioned_object.tier
|
||||
&& current.transitioned_object.status == expected.transitioned_object.status
|
||||
&& current.restore_expires == expected.restore_expires
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_expiry_rule_for_data_movement(
|
||||
api: Arc<ECStore>,
|
||||
event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
oi: &ObjectInfo,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> bool {
|
||||
let Ok(_lifecycle_guard) = api.acquire_bucket_lifecycle_read_lock(&oi.bucket).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(bucket_incarnation_id) = api.bucket_incarnation_id_from_disk(&oi.bucket).await else {
|
||||
return false;
|
||||
};
|
||||
let current = match api
|
||||
.get_object_info(
|
||||
&oi.bucket,
|
||||
&oi.name,
|
||||
&ObjectOptions {
|
||||
version_id: oi.version_id.map(|version_id| version_id.to_string()),
|
||||
versioned: oi.version_id.is_some(),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(current) => current,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !lifecycle_expiry_object_matches(¤t, oi) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if oi.transitioned_object.status.is_empty() {
|
||||
apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(api, oi, event, bucket_incarnation_id, lock_lost_signal)
|
||||
.await
|
||||
} else {
|
||||
apply_expiry_on_transitioned_object_with_lock_lost_signal(api, oi, event, src, bucket_incarnation_id, lock_lost_signal)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_expiry_rule_in(api: Arc<ECStore>, event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||
let Ok(_lifecycle_guard) = api.acquire_bucket_lifecycle_read_lock(&oi.bucket).await else {
|
||||
return false;
|
||||
@@ -5187,17 +5288,7 @@ pub(crate) async fn apply_expiry_rule_in(api: Arc<ECStore>, event: &lifecycle::E
|
||||
Ok(current) => current,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if current.version_id != oi.version_id
|
||||
|| current.data_dir != oi.data_dir
|
||||
|| current.mod_time != oi.mod_time
|
||||
|| current.etag != oi.etag
|
||||
|| current.delete_marker != oi.delete_marker
|
||||
|| current.transitioned_object.name != oi.transitioned_object.name
|
||||
|| current.transitioned_object.version_id != oi.transitioned_object.version_id
|
||||
|| current.transitioned_object.tier != oi.transitioned_object.tier
|
||||
|| current.transitioned_object.status != oi.transitioned_object.status
|
||||
|| current.restore_expires != oi.restore_expires
|
||||
{
|
||||
if !lifecycle_expiry_object_matches(¤t, oi) {
|
||||
return false;
|
||||
}
|
||||
enqueue_expiry_rule_with_incarnation(event, src, oi, bucket_incarnation_id).await
|
||||
|
||||
@@ -1076,6 +1076,13 @@ pub async fn get_versioning_config(bucket: &str) -> Result<(VersioningConfigurat
|
||||
bucket_meta_sys.get_versioning_config(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn has_authoritative_never_versioned_state(bucket: &str) -> Result<bool> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
|
||||
|
||||
bucket_meta_sys.has_authoritative_never_versioned_state(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_website_config(bucket: &str) -> Result<(WebsiteConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -1914,6 +1921,18 @@ impl BucketMetadataSys {
|
||||
}
|
||||
}
|
||||
|
||||
async fn has_authoritative_never_versioned_state(&self, bucket: &str) -> Result<bool> {
|
||||
let BucketMetadataAuthority::Authoritative(metadata) = self.get_metadata_authority(bucket).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if metadata.versioning_config.is_none() && !metadata.versioning_config_xml.is_empty() {
|
||||
return Err(Error::other("persisted bucket versioning configuration is invalid"));
|
||||
}
|
||||
|
||||
Ok(metadata.versioning_config.is_none() && metadata.versioning_config_xml.is_empty())
|
||||
}
|
||||
|
||||
pub async fn get_bucket_policy(&self, bucket: &str) -> Result<(BucketPolicy, OffsetDateTime)> {
|
||||
let bm = match self.get_metadata_authority(bucket).await? {
|
||||
BucketMetadataAuthority::Authoritative(bm) => bm,
|
||||
@@ -2469,6 +2488,10 @@ mod tests {
|
||||
sys.get_versioning_config(bucket).await.is_err(),
|
||||
"malformed versioning metadata must block destructive requests"
|
||||
);
|
||||
assert!(
|
||||
sys.has_authoritative_never_versioned_state(bucket).await.is_err(),
|
||||
"malformed versioning metadata must not enable listing shortcuts"
|
||||
);
|
||||
assert!(
|
||||
sys.get_replication_config(bucket).await.is_err(),
|
||||
"malformed replication metadata must not be reported as ConfigNotFound"
|
||||
@@ -2492,8 +2515,50 @@ mod tests {
|
||||
sys.get_object_lock_config_state("authoritative-empty").await.unwrap(),
|
||||
ObjectLockConfigState::ConfirmedAbsent
|
||||
));
|
||||
assert!(
|
||||
sys.has_authoritative_never_versioned_state("authoritative-empty")
|
||||
.await
|
||||
.unwrap(),
|
||||
"authoritative config absence should identify a never-versioned bucket"
|
||||
);
|
||||
assert!(matches!(sys.get_bucket_policy("authoritative-empty").await, Err(Error::ConfigNotFound)));
|
||||
|
||||
let mut versioned = BucketMetadata::new("authoritative-versioned");
|
||||
versioned.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
|
||||
versioned.versioning_config = Some(VersioningConfiguration {
|
||||
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
});
|
||||
sys.set("authoritative-versioned".to_string(), Arc::new(versioned)).await;
|
||||
assert!(
|
||||
!sys.has_authoritative_never_versioned_state("authoritative-versioned")
|
||||
.await
|
||||
.unwrap(),
|
||||
"versioned buckets must retain delete-marker visibility probes"
|
||||
);
|
||||
|
||||
let mut ambiguous = BucketMetadata::new("authoritative-ambiguous-versioning");
|
||||
ambiguous.versioning_config = Some(VersioningConfiguration::default());
|
||||
sys.set("authoritative-ambiguous-versioning".to_string(), Arc::new(ambiguous))
|
||||
.await;
|
||||
assert!(
|
||||
!sys.has_authoritative_never_versioned_state("authoritative-ambiguous-versioning")
|
||||
.await
|
||||
.unwrap(),
|
||||
"ambiguous versioning metadata must retain delete-marker visibility probes"
|
||||
);
|
||||
|
||||
sys.fabricated_metadata
|
||||
.write()
|
||||
.await
|
||||
.insert("fabricated-versioning".to_string());
|
||||
assert!(
|
||||
!sys.has_authoritative_never_versioned_state("fabricated-versioning")
|
||||
.await
|
||||
.unwrap(),
|
||||
"fabricated metadata must retain delete-marker visibility probes"
|
||||
);
|
||||
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join("policy-only-legacy")).unwrap();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ use super::replication_resync_boundary::{
|
||||
};
|
||||
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,
|
||||
replicate_object_with_outcome, update_resync_status_cas,
|
||||
};
|
||||
use super::replication_state::ReplicationStats;
|
||||
use super::replication_storage_boundary::{
|
||||
@@ -1898,7 +1898,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
}
|
||||
};
|
||||
|
||||
let mut bucket_status = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await?;
|
||||
let 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
|
||||
@@ -1924,26 +1924,43 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
let admitted = 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?;
|
||||
// The admission lock serializes competing admissions, but status
|
||||
// writers (mark_status, the periodic saver) do not take it — write
|
||||
// through the CAS so their concurrent updates to other targets are
|
||||
// never lost, re-checking the conflict gate on each retry.
|
||||
let (bucket_status, _) = update_resync_status_cas(&opts.bucket, self.storage.clone(), |persisted| {
|
||||
if let Some(active) = persisted.targets_map.get(&opts.arn) {
|
||||
if active.resync_id == opts.resync_id {
|
||||
return Ok(false);
|
||||
}
|
||||
if should_auto_resume_resync(active.resync_status) {
|
||||
return Err(EcstoreError::other(ResyncActiveConflictError {
|
||||
bucket: opts.bucket.clone(),
|
||||
arn: opts.arn.clone(),
|
||||
active_resync_id: active.resync_id.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
persisted.last_update = Some(now);
|
||||
persisted.targets_map.insert(opts.arn.clone(), admitted.clone());
|
||||
Ok(true)
|
||||
})
|
||||
.await?;
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
@@ -1953,6 +1970,83 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Cancel the pending/started resync intent recorded for `arn`, if any,
|
||||
/// because its remote target is being removed. Returns the canceled run.
|
||||
///
|
||||
/// Runs under the bucket admission lock and reloads `resync.bin` from disk
|
||||
/// before writing, like admission does: this node's `status_map` entry may
|
||||
/// be stale relative to intents admitted by other nodes, and persisting it
|
||||
/// would silently drop their durable restart intents.
|
||||
pub async fn cancel_bucket_resync_for_removed_target(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
arn: &str,
|
||||
) -> Result<Option<ResyncOpts>, EcstoreError> {
|
||||
let bucket = bucket.to_string();
|
||||
let arn = arn.to_string();
|
||||
tokio::spawn(async move { self.cancel_bucket_resync_for_removed_target_transaction(bucket, arn).await })
|
||||
.await
|
||||
.map_err(|error| EcstoreError::other(format!("replication resync cancellation task failed: {error}")))?
|
||||
}
|
||||
|
||||
async fn cancel_bucket_resync_for_removed_target_transaction(
|
||||
self: Arc<Self>,
|
||||
bucket: String,
|
||||
arn: String,
|
||||
) -> Result<Option<ResyncOpts>, EcstoreError> {
|
||||
let admission_lock_key = ReplicationMetadataStore::resync_admission_lock_key(&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 = admission_lock
|
||||
.get_write_lock(ReplicationLockTiming::acquire_timeout())
|
||||
.await
|
||||
.map_err(EcstoreError::from)?;
|
||||
|
||||
let mut canceled: Option<ResyncOpts> = None;
|
||||
let (final_map, _) = update_resync_status_cas(&bucket, self.storage.clone(), |persisted| {
|
||||
canceled = None;
|
||||
let Some(intent) = persisted.targets_map.get_mut(&arn) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !should_auto_resume_resync(intent.resync_status) {
|
||||
return Ok(false);
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
canceled = Some(ResyncOpts {
|
||||
bucket: bucket.clone(),
|
||||
arn: arn.clone(),
|
||||
resync_id: intent.resync_id.clone(),
|
||||
resync_before: intent.resync_before_date,
|
||||
});
|
||||
intent.resync_status = ResyncStatusType::ResyncCanceled;
|
||||
intent.last_update = Some(now);
|
||||
persisted.last_update = Some(now);
|
||||
Ok(true)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Converge only the removed target's cached entry: cached progress
|
||||
// counters for this node's other running targets stay authoritative.
|
||||
{
|
||||
let mut status_map = self.resyncer.status_map.write().await;
|
||||
let cached = status_map
|
||||
.entry(bucket.clone())
|
||||
.or_insert_with(BucketReplicationResyncStatus::new);
|
||||
if let Some(final_target) = final_map.targets_map.get(&arn) {
|
||||
cached.targets_map.insert(arn.clone(), final_target.clone());
|
||||
cached.last_update = final_map.last_update.or(cached.last_update);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(opts) = &canceled {
|
||||
self.resyncer.cancel(opts).await;
|
||||
}
|
||||
Ok(canceled)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -2710,6 +2804,11 @@ pub trait ReplicationPoolTrait: std::fmt::Debug {
|
||||
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 cancel_bucket_resync_for_removed_target(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
arn: &str,
|
||||
) -> Result<Option<ResyncOpts>, 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>;
|
||||
@@ -2763,6 +2862,14 @@ impl<S: ReplicationStorage> ReplicationPoolTrait for ReplicationPool<S> {
|
||||
self.cancel_bucket_resync(opts).await
|
||||
}
|
||||
|
||||
async fn cancel_bucket_resync_for_removed_target(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
arn: &str,
|
||||
) -> Result<Option<ResyncOpts>, EcstoreError> {
|
||||
ReplicationPool::<S>::cancel_bucket_resync_for_removed_target(self, bucket, arn).await
|
||||
}
|
||||
|
||||
async fn admit_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
|
||||
self.admit_bucket_resync(opts).await
|
||||
}
|
||||
@@ -3241,11 +3348,17 @@ mod tests {
|
||||
|
||||
async fn put_object(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: &mut Self::PutObjectReader,
|
||||
opts: &Self::ObjectOptions,
|
||||
) -> Result<Self::ObjectInfo, Self::Error> {
|
||||
let _lock_guard = if opts.no_lock {
|
||||
None
|
||||
} else {
|
||||
let lock = self.new_ns_lock(bucket, object).await?;
|
||||
Some(lock.get_write_lock(Duration::from_secs(10)).await?)
|
||||
};
|
||||
if opts.http_preconditions.is_some()
|
||||
&& let Some(replacement) = self
|
||||
.shared
|
||||
@@ -3891,6 +4004,157 @@ mod tests {
|
||||
assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1);
|
||||
}
|
||||
|
||||
/// Removing a target on node A must cancel only A's intent. Node A's cached
|
||||
/// status map predates node B's admission, so a cancel that persisted the
|
||||
/// cache would erase B's durable restart intent for `arn:second`.
|
||||
#[tokio::test]
|
||||
async fn removed_target_cancel_preserves_intents_admitted_on_other_nodes() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let node_a = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let node_b = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-b", shared.clone()))).await;
|
||||
let bucket = "removed-target-cancel";
|
||||
|
||||
assert!(
|
||||
node_a
|
||||
.clone()
|
||||
.admit_bucket_resync(test_resync_opts(bucket, "arn:first", "run-a"))
|
||||
.await
|
||||
.expect("node A admission should persist")
|
||||
);
|
||||
assert!(
|
||||
node_b
|
||||
.clone()
|
||||
.admit_bucket_resync(test_resync_opts(bucket, "arn:second", "run-b"))
|
||||
.await
|
||||
.expect("node B admission should persist")
|
||||
);
|
||||
assert!(
|
||||
!node_a.resyncer.status_map.read().await[bucket]
|
||||
.targets_map
|
||||
.contains_key("arn:second"),
|
||||
"precondition: node A's cache must be stale relative to node B's admission"
|
||||
);
|
||||
|
||||
let canceled = node_a
|
||||
.clone()
|
||||
.cancel_bucket_resync_for_removed_target(bucket, "arn:first")
|
||||
.await
|
||||
.expect("cancel should succeed");
|
||||
assert_eq!(canceled.map(|opts| opts.resync_id), Some("run-a".to_string()));
|
||||
|
||||
let persisted = decode_resync_file(&shared.data.lock().expect("test data lock should not be poisoned"))
|
||||
.expect("persisted status should decode");
|
||||
assert_eq!(persisted.targets_map["arn:first"].resync_status, ResyncStatusType::ResyncCanceled);
|
||||
assert_eq!(persisted.targets_map["arn:second"].resync_status, ResyncStatusType::ResyncPending);
|
||||
assert_eq!(persisted.targets_map["arn:second"].resync_id, "run-b");
|
||||
// Cache convergence is per-target: only the removed ARN is written
|
||||
// back (a running target's cached progress counters stay
|
||||
// authoritative), so node A's cache reflects the cancel while the
|
||||
// persisted document remains the authority for `arn:second`.
|
||||
assert_eq!(
|
||||
node_a.resyncer.status_map.read().await[bucket].targets_map["arn:first"].resync_status,
|
||||
ResyncStatusType::ResyncCanceled
|
||||
);
|
||||
|
||||
let untouched = node_a
|
||||
.clone()
|
||||
.cancel_bucket_resync_for_removed_target(bucket, "arn:first")
|
||||
.await
|
||||
.expect("cancel of a terminal intent should be a no-op");
|
||||
assert!(untouched.is_none());
|
||||
assert!(
|
||||
node_a
|
||||
.clone()
|
||||
.cancel_bucket_resync_for_removed_target(bucket, "arn:missing")
|
||||
.await
|
||||
.expect("cancel of an unknown arn should be a no-op")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// The reviewer's resurrect scenario: after node A cancels `arn:first`, a
|
||||
/// status write from node B — whose cache still holds the pre-cancel map —
|
||||
/// must not flip `arn:first` back to `Pending` on disk. `mark_status` now
|
||||
/// persists through the CAS with per-target guards instead of blind-saving
|
||||
/// its cached whole-bucket map.
|
||||
#[tokio::test]
|
||||
async fn stale_peer_status_write_cannot_resurrect_canceled_intent() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let node_a = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let node_b = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-b", shared.clone()))).await;
|
||||
let bucket = "stale-peer-write";
|
||||
|
||||
assert!(
|
||||
node_a
|
||||
.clone()
|
||||
.admit_bucket_resync(test_resync_opts(bucket, "arn:first", "run-a"))
|
||||
.await
|
||||
.expect("node A admission should persist")
|
||||
);
|
||||
assert!(
|
||||
node_b
|
||||
.clone()
|
||||
.admit_bucket_resync(test_resync_opts(bucket, "arn:second", "run-b"))
|
||||
.await
|
||||
.expect("node B admission should persist")
|
||||
);
|
||||
// Seed node B's stale cache: it saw the map before A's cancel.
|
||||
let pre_cancel = decode_resync_file(&shared.data.lock().expect("test data lock should not be poisoned"))
|
||||
.expect("pre-cancel status should decode");
|
||||
node_b
|
||||
.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(bucket.to_string(), pre_cancel);
|
||||
|
||||
node_a
|
||||
.clone()
|
||||
.cancel_bucket_resync_for_removed_target(bucket, "arn:first")
|
||||
.await
|
||||
.expect("cancel should succeed")
|
||||
.expect("cancel should report the canceled run");
|
||||
|
||||
node_b
|
||||
.resyncer
|
||||
.mark_status(
|
||||
ResyncStatusType::ResyncStarted,
|
||||
test_resync_opts(bucket, "arn:second", "run-b"),
|
||||
node_b.storage.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("peer status write should succeed");
|
||||
|
||||
let persisted = decode_resync_file(&shared.data.lock().expect("test data lock should not be poisoned"))
|
||||
.expect("persisted status should decode");
|
||||
assert_eq!(
|
||||
persisted.targets_map["arn:first"].resync_status,
|
||||
ResyncStatusType::ResyncCanceled,
|
||||
"peer's stale cache must not resurrect the canceled intent"
|
||||
);
|
||||
assert_eq!(persisted.targets_map["arn:second"].resync_status, ResyncStatusType::ResyncStarted);
|
||||
|
||||
// And the reverse guard: a stale write for the canceled target itself
|
||||
// is refused outright.
|
||||
node_b
|
||||
.resyncer
|
||||
.mark_status(
|
||||
ResyncStatusType::ResyncStarted,
|
||||
test_resync_opts(bucket, "arn:first", "run-a"),
|
||||
node_b.storage.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("guarded status write should be skipped, not fail");
|
||||
let persisted = decode_resync_file(&shared.data.lock().expect("test data lock should not be poisoned"))
|
||||
.expect("persisted status should decode");
|
||||
assert_eq!(persisted.targets_map["arn:first"].resync_status, ResyncStatusType::ResyncCanceled);
|
||||
assert_eq!(
|
||||
node_b.resyncer.status_map.read().await[bucket].targets_map["arn:first"].resync_status,
|
||||
ResyncStatusType::ResyncCanceled,
|
||||
"the refused writer's cache must converge to the persisted terminal state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admitted_resync_waits_for_target_metadata_commit_before_activation() {
|
||||
let shared = empty_resync_shared_state();
|
||||
@@ -4030,6 +4294,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_resync_status_cas_preserves_both_mutations() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let mut seeded = BucketReplicationResyncStatus::new();
|
||||
for arn in ["arn:a", "arn:b"] {
|
||||
seeded.targets_map.insert(
|
||||
arn.to_string(),
|
||||
TargetReplicationResyncStatus {
|
||||
bucket: "cas-race".to_string(),
|
||||
resync_id: format!("run-{arn}"),
|
||||
resync_status: ResyncStatusType::ResyncPending,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
*shared.data.lock().expect("test data lock should not be poisoned") =
|
||||
encode_resync_file(&seeded).expect("seeded resync status should encode");
|
||||
shared.empty_object_exists.store(true, Ordering::SeqCst);
|
||||
shared.etag_revision.store(1, Ordering::SeqCst);
|
||||
shared.block_next_write.store(true, Ordering::SeqCst);
|
||||
let node_a = Arc::new(LoadResyncNodeStore::new("cas-node-a", shared.clone()));
|
||||
let node_b = Arc::new(LoadResyncNodeStore::new("cas-node-b", shared.clone()));
|
||||
|
||||
let writer_a = tokio::spawn(async move {
|
||||
update_resync_status_cas("cas-race", node_a, |status| {
|
||||
status
|
||||
.targets_map
|
||||
.get_mut("arn:a")
|
||||
.expect("seeded target A should exist")
|
||||
.resync_status = ResyncStatusType::ResyncCanceled;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(10), shared.write_started.notified())
|
||||
.await
|
||||
.expect("writer A should pause after its precondition check");
|
||||
|
||||
let mut writer_b = tokio::spawn(async move {
|
||||
update_resync_status_cas("cas-race", node_b, |status| {
|
||||
status
|
||||
.targets_map
|
||||
.get_mut("arn:b")
|
||||
.expect("seeded target B should exist")
|
||||
.resync_status = ResyncStatusType::ResyncCompleted;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
});
|
||||
let writer_b_before_release = tokio::time::timeout(Duration::from_millis(250), &mut writer_b).await.ok();
|
||||
|
||||
shared.allow_write.notify_one();
|
||||
writer_a
|
||||
.await
|
||||
.expect("writer A task should finish")
|
||||
.expect("writer A should report a successful conditional save");
|
||||
match writer_b_before_release {
|
||||
Some(result) => result,
|
||||
None => writer_b.await,
|
||||
}
|
||||
.expect("writer B task should finish")
|
||||
.expect("writer B should retry and save its mutation");
|
||||
|
||||
let persisted = decode_resync_file(&shared.data.lock().expect("test data lock should not be poisoned"))
|
||||
.expect("persisted resync status should decode");
|
||||
assert_eq!(persisted.targets_map["arn:a"].resync_status, ResyncStatusType::ResyncCanceled);
|
||||
assert_eq!(persisted.targets_map["arn:b"].resync_status, ResyncStatusType::ResyncCompleted);
|
||||
assert!(!shared.last_put_no_lock.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_queue_admission_combines_target_results() {
|
||||
let mut admission = ReplicationQueueAdmission::Skipped;
|
||||
|
||||
@@ -40,16 +40,17 @@ use super::replication_resync_boundary::ResyncStatusType;
|
||||
#[cfg(test)]
|
||||
use super::replication_resync_boundary::should_count_head_proxy_failure;
|
||||
use super::replication_resync_boundary::{
|
||||
BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus, encode_resync_file, is_version_id_mismatch,
|
||||
resync_state_accepts_update, resync_status_duration, sanitize_resync_error_detail,
|
||||
BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus, decode_resync_file, encode_resync_file,
|
||||
is_version_id_mismatch, resync_state_accepts_update, resync_status_duration, sanitize_resync_error_detail,
|
||||
should_auto_resume_resync,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use super::replication_resync_boundary::{RESYNC_META_FORMAT, RESYNC_META_VERSION, WIRE_ZERO_TIME_UNIX, decode_resync_file};
|
||||
use super::replication_resync_boundary::{RESYNC_META_FORMAT, RESYNC_META_VERSION, WIRE_ZERO_TIME_UNIX};
|
||||
#[cfg(test)]
|
||||
use super::replication_storage_boundary::ReplicationDeletedObject;
|
||||
use super::replication_storage_boundary::{
|
||||
AdvancedGetOptions, EcstoreObjectOperations, GetObjectReader, HTTPRangeSpec, ObjectInfo, ObjectOptions, ObjectToDelete,
|
||||
ReplicationObjectIO, ReplicationStorage, StatObjectOptions, StorageObjectInfoOrErr, WalkOptions,
|
||||
AdvancedGetOptions, EcstoreObjectOperations, GetObjectReader, HTTPPreconditions, HTTPRangeSpec, ObjectInfo, ObjectOptions,
|
||||
ObjectToDelete, ReplicationObjectIO, ReplicationStorage, StatObjectOptions, StorageObjectInfoOrErr, WalkOptions,
|
||||
};
|
||||
use super::replication_target_boundary::{
|
||||
ERR_REPLICATION_SSEC_PASSTHROUGH_UNSUPPORTED, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions,
|
||||
@@ -428,7 +429,7 @@ impl ReplicationResyncer {
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
let (bucket_status, status_duration) = {
|
||||
let (updated_target, status_duration) = {
|
||||
let mut status_map = self.status_map.write().await;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
@@ -499,28 +500,62 @@ impl ReplicationResyncer {
|
||||
|
||||
bucket_status.last_update = Some(now);
|
||||
|
||||
(bucket_status.clone(), status_duration)
|
||||
(state.clone(), status_duration)
|
||||
};
|
||||
|
||||
save_resync_status(&opts.bucket, &bucket_status, obj_layer.clone()).await?;
|
||||
if status != ResyncStatusType::ResyncCanceled {
|
||||
let canceled_status = self
|
||||
.status_map
|
||||
.read()
|
||||
.await
|
||||
.get(&opts.bucket)
|
||||
.filter(|current| {
|
||||
current.targets_map.get(&opts.arn).is_some_and(|target| {
|
||||
target.resync_id == opts.resync_id && target.resync_status == ResyncStatusType::ResyncCanceled
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
if let Some(canceled_status) = canceled_status {
|
||||
save_resync_status(&opts.bucket, &canceled_status, obj_layer).await?;
|
||||
return Ok(());
|
||||
// Persist through the CAS so a stale cached map can never clobber
|
||||
// states other nodes finalized for other targets; re-run the staleness
|
||||
// and canceled-is-terminal guards against the freshest persisted entry.
|
||||
let updated_last_update = updated_target.last_update;
|
||||
let (final_map, saved) = update_resync_status_cas(&opts.bucket, obj_layer, |persisted| {
|
||||
if let Some(current) = persisted.targets_map.get(&opts.arn) {
|
||||
if !resync_state_accepts_update(current, &opts) {
|
||||
debug!(
|
||||
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
bucket = %opts.bucket,
|
||||
arn = %opts.arn,
|
||||
incoming_resync_id = %opts.resync_id,
|
||||
current_resync_id = %current.resync_id,
|
||||
reason = "stale_status_update",
|
||||
"Skipped persisting stale resync status update"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
if current.resync_status == ResyncStatusType::ResyncCanceled && status != ResyncStatusType::ResyncCanceled {
|
||||
debug!(
|
||||
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
bucket = %opts.bucket,
|
||||
arn = %opts.arn,
|
||||
incoming_status = %status,
|
||||
reason = "canceled_status_is_terminal",
|
||||
"Skipped resync status update after cancellation"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
persisted.targets_map.insert(opts.arn.clone(), updated_target.clone());
|
||||
persisted.last_update = updated_last_update;
|
||||
Ok(true)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Converge this target's cached entry with what the persisted document
|
||||
// decided (our update, or the newer/terminal state that outranked it).
|
||||
{
|
||||
let mut status_map = self.status_map.write().await;
|
||||
if let Some(cached) = status_map.get_mut(&opts.bucket)
|
||||
&& let Some(final_target) = final_map.targets_map.get(&opts.arn)
|
||||
{
|
||||
cached.targets_map.insert(opts.arn.clone(), final_target.clone());
|
||||
cached.last_update = final_map.last_update.or(cached.last_update);
|
||||
}
|
||||
}
|
||||
if let Some(stats) = runtime_sources::replication_stats() {
|
||||
|
||||
if saved && let Some(stats) = runtime_sources::replication_stats() {
|
||||
stats.record_resync_status(&opts.bucket, status, status_duration).await;
|
||||
}
|
||||
|
||||
@@ -612,10 +647,16 @@ impl ReplicationResyncer {
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
|
||||
let status_map = self.status_map.read().await;
|
||||
let snapshot: Vec<(String, BucketReplicationResyncStatus)> = self
|
||||
.status_map
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(bucket, status)| (bucket.clone(), status.clone()))
|
||||
.collect();
|
||||
|
||||
let mut update = false;
|
||||
for (bucket, status) in status_map.iter() {
|
||||
for (bucket, status) in &snapshot {
|
||||
for target in status.targets_map.values() {
|
||||
if target.last_update.is_none() {
|
||||
update = true;
|
||||
@@ -631,7 +672,14 @@ impl ReplicationResyncer {
|
||||
}
|
||||
|
||||
if update {
|
||||
if let Err(err) = save_resync_status(bucket, status, api.clone()).await {
|
||||
// CAS-merge instead of a blind whole-map save: this
|
||||
// cache may lag other nodes' admissions and
|
||||
// cancellations, which must not be overwritten.
|
||||
let result = update_resync_status_cas(bucket, api.clone(), |persisted| {
|
||||
Ok(merge_local_resync_into_persisted(persisted, status))
|
||||
})
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
error!(
|
||||
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -641,8 +689,8 @@ impl ReplicationResyncer {
|
||||
error = %err,
|
||||
"Failed to persist resync status"
|
||||
);
|
||||
} else {
|
||||
last_update_times.insert(bucket.clone(), status.last_update.expect("last_update should be set"));
|
||||
} else if let Some(last_update) = status.last_update {
|
||||
last_update_times.insert(bucket.clone(), last_update);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1451,17 +1499,109 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn save_resync_status<S: ReplicationObjectIO>(
|
||||
/// Upper bound on optimistic retries for a `resync.bin` compare-and-swap
|
||||
/// update before giving up; contention on one bucket's status is a handful of
|
||||
/// writers (status transitions, the periodic saver, admissions), not a crowd.
|
||||
const RESYNC_STATUS_CAS_MAX_ATTEMPTS: usize = 32;
|
||||
|
||||
/// Read-merge-write `resync.bin` under an ETag compare-and-swap.
|
||||
///
|
||||
/// Every writer used to persist its node's cached whole-bucket map, so one
|
||||
/// node's stale cache could silently resurrect a state another node had
|
||||
/// already finalized (e.g. flip a just-canceled intent back to `Pending`).
|
||||
/// `apply` receives the freshest persisted map and mutates it in place,
|
||||
/// returning `Ok(false)` to skip the write. On a concurrent write the load +
|
||||
/// apply + save cycle is retried against the new document. Returns the final
|
||||
/// map and whether this call wrote it.
|
||||
pub(crate) async fn update_resync_status_cas<S, F>(
|
||||
bucket: &str,
|
||||
status: &BucketReplicationResyncStatus,
|
||||
api: Arc<S>,
|
||||
) -> Result<()> {
|
||||
let data = encode_resync_file(status)?;
|
||||
|
||||
mut apply: F,
|
||||
) -> Result<(BucketReplicationResyncStatus, bool)>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
F: FnMut(&mut BucketReplicationResyncStatus) -> Result<bool>,
|
||||
{
|
||||
let config_file = ReplicationMetadataStore::bucket_resync_file_path(bucket);
|
||||
ReplicationConfigStore::save(api, &config_file, data).await?;
|
||||
for _ in 0..RESYNC_STATUS_CAS_MAX_ATTEMPTS {
|
||||
let (mut status, preconditions) =
|
||||
match ReplicationConfigStore::read_no_lock_with_metadata(api.clone(), &config_file).await {
|
||||
Ok((data, object_info)) => {
|
||||
let etag = object_info
|
||||
.etag
|
||||
.filter(|etag| !etag.trim().is_empty())
|
||||
.ok_or_else(|| Error::other("replication resync status has no ETag for conditional update"))?;
|
||||
let status = if data.is_empty() {
|
||||
BucketReplicationResyncStatus::new()
|
||||
} else {
|
||||
decode_resync_file(&data)?
|
||||
};
|
||||
(
|
||||
status,
|
||||
HTTPPreconditions {
|
||||
if_match: Some(etag),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(Error::ConfigNotFound) => (
|
||||
BucketReplicationResyncStatus::new(),
|
||||
HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if !apply(&mut status)? {
|
||||
return Ok((status, false));
|
||||
}
|
||||
match ReplicationConfigStore::save_conditional(api.clone(), &config_file, encode_resync_file(&status)?, preconditions)
|
||||
.await
|
||||
{
|
||||
Ok(()) => return Ok((status, true)),
|
||||
Err(Error::PreconditionFailed) => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(Error::other("replication resync status conditional update did not converge"))
|
||||
}
|
||||
|
||||
Ok(())
|
||||
/// Merge this node's cached bucket resync map into the persisted map for the
|
||||
/// periodic saver. Per target: same run id overlays the fresher local state
|
||||
/// unless the persisted state is already terminal and the local one is not
|
||||
/// (a cancel/completion recorded by another node must stick); a different
|
||||
/// persisted run id means a newer admission elsewhere and is kept; targets
|
||||
/// unknown to disk are added. Returns whether `persisted` changed.
|
||||
pub(crate) fn merge_local_resync_into_persisted(
|
||||
persisted: &mut BucketReplicationResyncStatus,
|
||||
local: &BucketReplicationResyncStatus,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
for (arn, local_state) in &local.targets_map {
|
||||
match persisted.targets_map.get(arn) {
|
||||
Some(current) if current.resync_id == local_state.resync_id => {
|
||||
let persisted_terminal = !should_auto_resume_resync(current.resync_status);
|
||||
let local_terminal = !should_auto_resume_resync(local_state.resync_status);
|
||||
if persisted_terminal && !local_terminal {
|
||||
continue;
|
||||
}
|
||||
if current != local_state {
|
||||
persisted.targets_map.insert(arn.clone(), local_state.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
persisted.targets_map.insert(arn.clone(), local_state.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed && local.last_update.is_some() {
|
||||
persisted.last_update = local.last_update;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicationInfo, storage: Arc<S>) {
|
||||
@@ -3913,6 +4053,87 @@ async fn replicate_object_with_multipart<S: ReplicationObjectIO>(ctx: MultipartR
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::replication_filemeta_boundary::ReplicateTargetDecision;
|
||||
fn resync_target_state(resync_id: &str, status: ResyncStatusType, replicated_count: i64) -> TargetReplicationResyncStatus {
|
||||
TargetReplicationResyncStatus {
|
||||
resync_id: resync_id.to_string(),
|
||||
resync_status: status,
|
||||
replicated_count,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic-saver merge: fresher local progress overlays the same run,
|
||||
/// but a terminal state persisted by another node must stick, a newer
|
||||
/// admission elsewhere is kept, and locally-known targets are added.
|
||||
#[test]
|
||||
fn merge_local_resync_keeps_peer_terminal_and_newer_states() {
|
||||
let mut persisted = BucketReplicationResyncStatus::new();
|
||||
persisted.targets_map.insert(
|
||||
"arn:same-run".to_string(),
|
||||
resync_target_state("run-1", ResyncStatusType::ResyncStarted, 1),
|
||||
);
|
||||
persisted.targets_map.insert(
|
||||
"arn:canceled".to_string(),
|
||||
resync_target_state("run-1", ResyncStatusType::ResyncCanceled, 0),
|
||||
);
|
||||
persisted.targets_map.insert(
|
||||
"arn:new-run".to_string(),
|
||||
resync_target_state("run-2", ResyncStatusType::ResyncPending, 0),
|
||||
);
|
||||
|
||||
let mut local = BucketReplicationResyncStatus::new();
|
||||
local.targets_map.insert(
|
||||
"arn:same-run".to_string(),
|
||||
resync_target_state("run-1", ResyncStatusType::ResyncStarted, 9),
|
||||
);
|
||||
local.targets_map.insert(
|
||||
"arn:canceled".to_string(),
|
||||
resync_target_state("run-1", ResyncStatusType::ResyncPending, 0),
|
||||
);
|
||||
local.targets_map.insert(
|
||||
"arn:new-run".to_string(),
|
||||
resync_target_state("run-1", ResyncStatusType::ResyncStarted, 3),
|
||||
);
|
||||
local.targets_map.insert(
|
||||
"arn:local-only".to_string(),
|
||||
resync_target_state("run-1", ResyncStatusType::ResyncPending, 0),
|
||||
);
|
||||
local.last_update = Some(OffsetDateTime::now_utc());
|
||||
|
||||
assert!(merge_local_resync_into_persisted(&mut persisted, &local));
|
||||
assert_eq!(persisted.targets_map["arn:same-run"].replicated_count, 9, "fresher local progress wins");
|
||||
assert_eq!(
|
||||
persisted.targets_map["arn:canceled"].resync_status,
|
||||
ResyncStatusType::ResyncCanceled,
|
||||
"peer terminal state must stick"
|
||||
);
|
||||
assert_eq!(
|
||||
persisted.targets_map["arn:new-run"].resync_id, "run-2",
|
||||
"newer admission elsewhere is kept"
|
||||
);
|
||||
assert!(persisted.targets_map.contains_key("arn:local-only"));
|
||||
assert_eq!(persisted.last_update, local.last_update);
|
||||
}
|
||||
|
||||
/// A terminal local state for the same run (completion/failure recorded by
|
||||
/// this node) still overlays a non-terminal persisted state.
|
||||
#[test]
|
||||
fn merge_local_resync_reports_no_change_when_maps_agree() {
|
||||
let mut persisted = BucketReplicationResyncStatus::new();
|
||||
persisted
|
||||
.targets_map
|
||||
.insert("arn:same".to_string(), resync_target_state("run-1", ResyncStatusType::ResyncStarted, 5));
|
||||
let local = persisted.clone();
|
||||
assert!(!merge_local_resync_into_persisted(&mut persisted, &local));
|
||||
|
||||
let mut local = local.clone();
|
||||
local
|
||||
.targets_map
|
||||
.insert("arn:same".to_string(), resync_target_state("run-1", ResyncStatusType::ResyncCompleted, 5));
|
||||
assert!(merge_local_resync_into_persisted(&mut persisted, &local));
|
||||
assert_eq!(persisted.targets_map["arn:same"].resync_status, ResyncStatusType::ResyncCompleted);
|
||||
}
|
||||
|
||||
use super::super::replication_target_boundary::{BucketTarget, BucketTargets};
|
||||
use super::*;
|
||||
use s3s::dto::{
|
||||
|
||||
@@ -218,6 +218,7 @@ pub struct ListPathRawOptions {
|
||||
pub path: String,
|
||||
pub recursive: bool,
|
||||
pub incl_deleted: bool,
|
||||
pub skip_hidden_prefix_check: bool,
|
||||
pub filter_prefix: Option<String>,
|
||||
pub forward_to: Option<String>,
|
||||
pub min_disks: usize,
|
||||
@@ -249,6 +250,7 @@ impl Clone for ListPathRawOptions {
|
||||
path: self.path.clone(),
|
||||
recursive: self.recursive,
|
||||
incl_deleted: self.incl_deleted,
|
||||
skip_hidden_prefix_check: self.skip_hidden_prefix_check,
|
||||
filter_prefix: self.filter_prefix.clone(),
|
||||
forward_to: self.forward_to.clone(),
|
||||
min_disks: self.min_disks,
|
||||
@@ -274,6 +276,7 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
|
||||
base_dir: opts.path.clone(),
|
||||
recursive: opts.recursive,
|
||||
incl_deleted: opts.incl_deleted,
|
||||
skip_hidden_prefix_check: opts.skip_hidden_prefix_check,
|
||||
report_notfound: opts.report_not_found,
|
||||
filter_prefix: opts.filter_prefix.clone(),
|
||||
forward_to: opts.forward_to.clone(),
|
||||
@@ -1098,11 +1101,13 @@ mod tests {
|
||||
#[test]
|
||||
fn walk_dir_options_preserve_zero_total_and_inherited_stall_timeouts() {
|
||||
let options = walk_dir_options(&ListPathRawOptions {
|
||||
skip_hidden_prefix_check: true,
|
||||
walkdir_timeout: Some(Duration::ZERO),
|
||||
walkdir_stall_timeout: None,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(options.skip_hidden_prefix_check);
|
||||
assert_eq!(options.timeout_ms, Some(0));
|
||||
assert_eq!(options.stall_timeout_ms, None);
|
||||
assert!(!options.skip_total_timeout);
|
||||
|
||||
@@ -2994,6 +2994,7 @@ mod tests {
|
||||
dst_volume: "bucket".to_string(),
|
||||
dst_path: "object".to_string(),
|
||||
file_info_bin: vec![0x81, 0xA1, 0x76, 0x01].into(),
|
||||
scanner_publication_lease_token: Vec::new().into(),
|
||||
};
|
||||
let body = rustfs_protos::canonical_rename_data_request_body(&message).expect("small request should encode");
|
||||
let mut request = tonic::Request::new(());
|
||||
|
||||
@@ -48,9 +48,12 @@ pub use internode_data_transport::build_internode_data_transport_from_env;
|
||||
pub(crate) use peer_rest_client::TierConfigReloadOutcome;
|
||||
pub use peer_rest_client::{
|
||||
KMS_SIGNAL_SUBSYSTEM, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG,
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity,
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity, ScannerPublicationLease,
|
||||
};
|
||||
pub(crate) use peer_s3_client::heal_bucket_local_on_disks;
|
||||
pub use peer_s3_client::{LocalPeerS3Client, PeerS3Client, S3PeerSys, ScannerBucketListing, ScannerSetBucketListing};
|
||||
pub use peer_s3_client::{
|
||||
LocalPeerS3Client, PeerS3Client, S3PeerSys, ScannerBucketListing, ScannerSetBucketListing, decode_heal_bucket_rpc_options,
|
||||
encode_heal_bucket_rpc_options,
|
||||
};
|
||||
pub use remote_disk::RemoteDisk;
|
||||
pub use remote_locker::RemoteClient;
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_bo
|
||||
use crate::error::{Error, Result};
|
||||
use crate::storage_api_contracts::internode::{
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION,
|
||||
};
|
||||
use crate::{
|
||||
bucket::replication::BucketStats,
|
||||
@@ -45,8 +46,9 @@ use rustfs_protos::proto_gen::node_service::{
|
||||
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
|
||||
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
|
||||
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
|
||||
ScannerActivityRequest, ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse,
|
||||
StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
|
||||
ScannerActivityRequest, ScannerActivityResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest,
|
||||
ScannerPublicationLeaseResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
|
||||
StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
|
||||
TierMutationControlResponse, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
|
||||
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
||||
};
|
||||
@@ -84,6 +86,11 @@ const HEAL_CONTROL_PAYLOAD_MAX_SIZE: usize = 64 * 1024;
|
||||
const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
|
||||
const PEER_REST_RECOVERY_MAX_BACKOFF: Duration = Duration::from_secs(30);
|
||||
const SCANNER_ACTIVITY_MAX_MESSAGE_SIZE: usize = 1024;
|
||||
/// Reserve time for the acquire response's network/clock uncertainty. The
|
||||
/// server owns the real expiry; this local deadline is intentionally earlier
|
||||
/// so a coordinator never starts a bounded persistence operation at the edge
|
||||
/// of a remote lease.
|
||||
const SCANNER_PUBLICATION_LEASE_SAFETY_MARGIN: Duration = Duration::from_secs(5);
|
||||
const REPLICATION_STATS_MAX_MESSAGE_SIZE: usize = 8 * 1024 * 1024;
|
||||
const BUCKET_METADATA_RELOAD_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
@@ -150,6 +157,8 @@ pub struct ScannerPeerActivity {
|
||||
pub data_movement_active: Option<bool>,
|
||||
pub dirty_usage_generation: Option<u64>,
|
||||
pub dirty_usage_pending: Option<bool>,
|
||||
pub movement_generation: Option<u64>,
|
||||
pub publication_blocked: Option<bool>,
|
||||
}
|
||||
|
||||
fn decode_scanner_activity_with_verifier(
|
||||
@@ -166,7 +175,14 @@ fn decode_scanner_activity_with_verifier(
|
||||
{
|
||||
return Err(Error::other("peer returned an invalid scanner activity instance ID"));
|
||||
}
|
||||
let (topology_digest, data_movement_active, dirty_usage_generation, dirty_usage_pending) = match response.protocol_version {
|
||||
let (
|
||||
topology_digest,
|
||||
data_movement_active,
|
||||
dirty_usage_generation,
|
||||
dirty_usage_pending,
|
||||
movement_generation,
|
||||
publication_blocked,
|
||||
) = match response.protocol_version {
|
||||
// RUSTFS_COMPAT_TODO(ns-scanner-rpc-v3): legacy response fields are unauthenticated. Remove after protocol v0 peers are unsupported.
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION
|
||||
if response.topology_digest.is_empty()
|
||||
@@ -175,7 +191,7 @@ fn decode_scanner_activity_with_verifier(
|
||||
&& response.dirty_usage_generation == 0
|
||||
&& !response.dirty_usage_pending =>
|
||||
{
|
||||
(None, None, None, None)
|
||||
(None, None, None, None, None, None)
|
||||
}
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION => {
|
||||
return Err(Error::other("legacy scanner activity peer returned unexpected extended fields"));
|
||||
@@ -198,9 +214,11 @@ fn decode_scanner_activity_with_verifier(
|
||||
Some(response.data_movement_active),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION => {
|
||||
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION => {
|
||||
if response.dirty_usage_pending && response.dirty_usage_generation == 0 {
|
||||
return Err(Error::other("scanner activity peer returned pending dirty usage without a generation"));
|
||||
}
|
||||
@@ -218,11 +236,42 @@ fn decode_scanner_activity_with_verifier(
|
||||
Some(response.data_movement_active),
|
||||
Some(response.dirty_usage_generation),
|
||||
Some(response.dirty_usage_pending),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
version => {
|
||||
return Err(Error::other(format!("peer returned unsupported scanner activity protocol {version}")));
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION => {
|
||||
if response.dirty_usage_pending && response.dirty_usage_generation == 0 {
|
||||
return Err(Error::other("scanner activity peer returned pending dirty usage without a generation"));
|
||||
}
|
||||
let movement_generation = response
|
||||
.movement_generation
|
||||
.ok_or_else(|| Error::other("scanner activity peer omitted its movement generation"))?;
|
||||
let publication_blocked = response
|
||||
.publication_blocked
|
||||
.ok_or_else(|| Error::other("scanner activity peer omitted its publication blocked state"))?;
|
||||
if movement_generation == u64::MAX {
|
||||
return Err(Error::other("scanner activity peer exhausted its movement generation"));
|
||||
}
|
||||
let canonical = rustfs_protos::canonical_scanner_activity_v7_response_body(challenge, &response)
|
||||
.map_err(|_| Error::other("scanner activity peer response is too large to authenticate"))?;
|
||||
verify_proof(&canonical, &response.response_proof)?;
|
||||
(
|
||||
Some(
|
||||
response
|
||||
.topology_digest
|
||||
.as_ref()
|
||||
.try_into()
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner topology digest"))?,
|
||||
),
|
||||
Some(response.data_movement_active),
|
||||
Some(response.dirty_usage_generation),
|
||||
Some(response.dirty_usage_pending),
|
||||
Some(movement_generation),
|
||||
Some(publication_blocked),
|
||||
)
|
||||
}
|
||||
version => return Err(Error::other(format!("peer returned unsupported scanner activity protocol {version}"))),
|
||||
};
|
||||
Ok(ScannerPeerActivity {
|
||||
instance_id: response.instance_id,
|
||||
@@ -233,6 +282,8 @@ fn decode_scanner_activity_with_verifier(
|
||||
data_movement_active,
|
||||
dirty_usage_generation,
|
||||
dirty_usage_pending,
|
||||
movement_generation,
|
||||
publication_blocked,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,6 +294,17 @@ fn decode_scanner_activity(response: ScannerActivityResponse, challenge: &[u8; 1
|
||||
})
|
||||
}
|
||||
|
||||
fn scanner_activity_protocol_unsupported(err: &Error) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
Error::Io(io_err)
|
||||
if embedded_tonic_status(io_err).is_some_and(|status| {
|
||||
status.code() == tonic::Code::FailedPrecondition
|
||||
&& status.message().starts_with("unsupported scanner activity request protocol")
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_heal_control_capability_proof(canonical_ack: &[u8], proof: &[u8]) -> Result<()> {
|
||||
verify_tonic_rpc_response_proof(canonical_ack, proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid heal control capability proof"))
|
||||
@@ -285,6 +347,76 @@ pub struct PeerLiveEventsBatch {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScannerPublicationLease {
|
||||
pub token: Uuid,
|
||||
pub movement_generation: u64,
|
||||
/// Stable storage owner identity. This is distinct from the activity
|
||||
/// session and is bound into both acquire and release proofs.
|
||||
pub owner_id: String,
|
||||
/// Process/session nonce observed by the final activity probe.
|
||||
pub session_id: String,
|
||||
pub expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl ScannerPublicationLease {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
std::time::Instant::now() < self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_scanner_publication_lease_response_fields(
|
||||
response: &ScannerPublicationLeaseResponse,
|
||||
expected_session_id: &str,
|
||||
expected_generation: u64,
|
||||
) -> Result<(Uuid, String)> {
|
||||
if !response.success {
|
||||
return Err(Error::other(
|
||||
response
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|error| error.error_info.clone())
|
||||
.unwrap_or_else(|| "peer rejected scanner publication lease".to_string()),
|
||||
));
|
||||
}
|
||||
if response.movement_generation != expected_generation {
|
||||
return Err(Error::other("peer returned a different scanner publication lease generation"));
|
||||
}
|
||||
if response.session_id != expected_session_id {
|
||||
return Err(Error::other("peer returned a different scanner publication lease session"));
|
||||
}
|
||||
let owner_id = Uuid::parse_str(&response.owner_id)
|
||||
.ok()
|
||||
.filter(|owner_id| !owner_id.is_nil())
|
||||
.map(|owner_id| owner_id.to_string())
|
||||
.ok_or_else(|| Error::other("peer returned an invalid scanner publication lease owner"))?;
|
||||
if response.lease_ttl_ms != crate::store::SCANNER_PUBLICATION_LEASE_TTL_MS {
|
||||
return Err(Error::other("peer returned an unsupported scanner publication lease TTL"));
|
||||
}
|
||||
let token = Uuid::from_slice(response.token.as_ref())
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner publication lease token"))?;
|
||||
Ok((token, owner_id))
|
||||
}
|
||||
|
||||
fn scanner_publication_lease_deadline(
|
||||
request_started: std::time::Instant,
|
||||
response_received: std::time::Instant,
|
||||
lease_ttl_ms: u64,
|
||||
) -> Result<std::time::Instant> {
|
||||
let lease_window = Duration::from_millis(lease_ttl_ms)
|
||||
.checked_sub(SCANNER_PUBLICATION_LEASE_SAFETY_MARGIN)
|
||||
.ok_or_else(|| Error::other("scanner publication lease TTL is shorter than its safety margin"))?;
|
||||
let elapsed = response_received
|
||||
.checked_duration_since(request_started)
|
||||
.ok_or_else(|| Error::other("scanner publication lease response clock moved backwards"))?;
|
||||
if elapsed >= lease_window {
|
||||
return Err(Error::other("scanner publication lease response arrived after its safety window"));
|
||||
}
|
||||
request_started
|
||||
.checked_add(lease_window)
|
||||
.ok_or_else(|| Error::other("scanner publication lease deadline overflowed"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PeerRestClient {
|
||||
pub host: XHost,
|
||||
@@ -1640,10 +1772,11 @@ impl PeerRestClient {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn scanner_activity_request(
|
||||
async fn scanner_activity_request_with_protocol(
|
||||
&self,
|
||||
acknowledge_instance_id: String,
|
||||
acknowledge_dirty_usage_generation: u64,
|
||||
protocol_version: u32,
|
||||
) -> Result<ScannerPeerActivity> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
@@ -1655,7 +1788,7 @@ impl PeerRestClient {
|
||||
.max_encoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE);
|
||||
let mut request = Request::new(ScannerActivityRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
protocol_version,
|
||||
acknowledge_instance_id,
|
||||
acknowledge_dirty_usage_generation,
|
||||
});
|
||||
@@ -1671,11 +1804,168 @@ impl PeerRestClient {
|
||||
}
|
||||
|
||||
pub async fn scanner_activity(&self) -> Result<ScannerPeerActivity> {
|
||||
self.scanner_activity_request(String::new(), 0).await
|
||||
let result = self
|
||||
.scanner_activity_request_with_protocol(String::new(), 0, SCANNER_ACTIVITY_PROTOCOL_VERSION)
|
||||
.await;
|
||||
if result.as_ref().err().is_some_and(scanner_activity_protocol_unsupported) {
|
||||
// A v6 peer cannot parse the v7 marker. Its authenticated
|
||||
// response is still decoded as untrusted terminal state, so the
|
||||
// scanner will defer publication until every peer is v7.
|
||||
self.scanner_activity_request_with_protocol(String::new(), 0, SCANNER_ACTIVITY_V6_PROTOCOL_VERSION)
|
||||
.await
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn acknowledge_scanner_dirty_usage(&self, instance_id: String, generation: u64) -> Result<ScannerPeerActivity> {
|
||||
self.scanner_activity_request(instance_id, generation).await
|
||||
let result = self
|
||||
.scanner_activity_request_with_protocol(instance_id.clone(), generation, SCANNER_ACTIVITY_PROTOCOL_VERSION)
|
||||
.await;
|
||||
if result.as_ref().err().is_some_and(scanner_activity_protocol_unsupported) {
|
||||
self.scanner_activity_request_with_protocol(instance_id, generation, SCANNER_ACTIVITY_V6_PROTOCOL_VERSION)
|
||||
.await
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire a bounded, storage-owned read admission on the peer that
|
||||
/// produced the final activity generation. Older peers do not implement
|
||||
/// the lease form and are rejected rather than downgraded.
|
||||
pub async fn acquire_scanner_publication_lease(
|
||||
&self,
|
||||
expected_session_id: &str,
|
||||
expected_generation: u64,
|
||||
) -> Result<ScannerPublicationLease> {
|
||||
let request_started = std::time::Instant::now();
|
||||
self.finalize_result(
|
||||
async {
|
||||
let challenge = Uuid::new_v4();
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await?
|
||||
.max_decoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE)
|
||||
.max_encoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE);
|
||||
let mut request = Request::new(ScannerPublicationLeaseRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
expected_movement_generation: expected_generation,
|
||||
ttl_ms: crate::store::SCANNER_PUBLICATION_LEASE_TTL_MS,
|
||||
expected_session_id: expected_session_id.to_string(),
|
||||
token: Bytes::new(),
|
||||
});
|
||||
let canonical = rustfs_protos::canonical_scanner_publication_lease_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("scanner publication lease request is too large to authenticate"))?;
|
||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
||||
let response = client.acquire_scanner_publication_lease(request).await?.into_inner();
|
||||
let response_body =
|
||||
rustfs_protos::canonical_scanner_publication_lease_response_body(challenge.as_bytes(), &response)
|
||||
.map_err(|_| Error::other("scanner publication lease response is too large to authenticate"))?;
|
||||
verify_tonic_rpc_response_proof(&response_body, &response.response_proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner publication lease proof"))?;
|
||||
let (token, owner_id) =
|
||||
validate_scanner_publication_lease_response_fields(&response, expected_session_id, expected_generation)?;
|
||||
Ok(ScannerPublicationLease {
|
||||
token,
|
||||
movement_generation: response.movement_generation,
|
||||
owner_id,
|
||||
session_id: response.session_id,
|
||||
expires_at: scanner_publication_lease_deadline(
|
||||
request_started,
|
||||
std::time::Instant::now(),
|
||||
response.lease_ttl_ms,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Revalidate the exact token immediately before the coordinator commits
|
||||
/// its final publication. The peer keeps the original movement read
|
||||
/// guard in its token table; a restart drops that table and changes the
|
||||
/// activity session, so this proof fails closed instead of accepting an
|
||||
/// ABA generation value.
|
||||
pub async fn validate_scanner_publication_lease(&self, lease: &ScannerPublicationLease) -> Result<()> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
let challenge = Uuid::new_v4();
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await?
|
||||
.max_decoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE)
|
||||
.max_encoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE);
|
||||
let mut request = Request::new(ScannerPublicationLeaseRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
expected_movement_generation: lease.movement_generation,
|
||||
ttl_ms: crate::store::SCANNER_PUBLICATION_LEASE_TTL_MS,
|
||||
expected_session_id: lease.session_id.clone(),
|
||||
token: lease.token.as_bytes().to_vec().into(),
|
||||
});
|
||||
let canonical = rustfs_protos::canonical_scanner_publication_lease_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("scanner publication lease validation request is too large to authenticate"))?;
|
||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
||||
let response = client.acquire_scanner_publication_lease(request).await?.into_inner();
|
||||
let response_body =
|
||||
rustfs_protos::canonical_scanner_publication_lease_response_body(challenge.as_bytes(), &response).map_err(
|
||||
|_| Error::other("scanner publication lease validation response is too large to authenticate"),
|
||||
)?;
|
||||
verify_tonic_rpc_response_proof(&response_body, &response.response_proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner publication lease validation proof"))?;
|
||||
let (token, owner_id) =
|
||||
validate_scanner_publication_lease_response_fields(&response, &lease.session_id, lease.movement_generation)?;
|
||||
if token != lease.token {
|
||||
return Err(Error::other("peer returned a different scanner publication lease token"));
|
||||
}
|
||||
if owner_id != lease.owner_id {
|
||||
return Err(Error::other("peer returned a different scanner publication lease owner"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn release_scanner_publication_lease(&self, lease: &ScannerPublicationLease) -> Result<()> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
let challenge = Uuid::new_v4();
|
||||
let mut client = self.get_client().await?;
|
||||
let mut request = Request::new(ScannerPublicationLeaseReleaseRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
token: lease.token.as_bytes().to_vec().into(),
|
||||
owner_id: lease.owner_id.clone(),
|
||||
session_id: lease.session_id.clone(),
|
||||
});
|
||||
let canonical = rustfs_protos::canonical_scanner_publication_lease_release_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("scanner publication lease release request is too large to authenticate"))?;
|
||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
||||
let request_body = request.get_ref().clone();
|
||||
let response = client.release_scanner_publication_lease(request).await?.into_inner();
|
||||
let response_body = rustfs_protos::canonical_scanner_publication_lease_release_response_body(
|
||||
challenge.as_bytes(),
|
||||
&request_body,
|
||||
&response,
|
||||
)
|
||||
.map_err(|_| Error::other("scanner publication lease release response is too large to authenticate"))?;
|
||||
verify_tonic_rpc_response_proof(&response_body, &response.response_proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner publication lease release proof"))?;
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::other(
|
||||
response
|
||||
.error
|
||||
.map(|error| error.error_info)
|
||||
.unwrap_or_else(|| "peer rejected scanner publication lease release".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_metacache_listing(&self) -> Result<()> {
|
||||
@@ -1991,6 +2281,52 @@ mod tests {
|
||||
use temp_env::async_with_vars;
|
||||
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
|
||||
|
||||
#[test]
|
||||
fn scanner_publication_lease_response_rejects_stale_generation_and_session() {
|
||||
let token = Uuid::new_v4();
|
||||
let response = ScannerPublicationLeaseResponse {
|
||||
success: true,
|
||||
token: token.as_bytes().to_vec().into(),
|
||||
movement_generation: 7,
|
||||
lease_ttl_ms: crate::store::SCANNER_PUBLICATION_LEASE_TTL_MS,
|
||||
error: None,
|
||||
response_proof: Bytes::new(),
|
||||
owner_id: Uuid::new_v4().to_string(),
|
||||
session_id: "session-a".to_string(),
|
||||
};
|
||||
|
||||
assert!(validate_scanner_publication_lease_response_fields(&response, "session-a", 7).is_ok());
|
||||
|
||||
let stale_generation = ScannerPublicationLeaseResponse {
|
||||
movement_generation: 6,
|
||||
..response.clone()
|
||||
};
|
||||
let error = validate_scanner_publication_lease_response_fields(&stale_generation, "session-a", 7)
|
||||
.expect_err("a response from an older movement generation must be rejected");
|
||||
assert!(error.to_string().contains("different scanner publication lease generation"));
|
||||
|
||||
let stale_session = ScannerPublicationLeaseResponse {
|
||||
session_id: "session-b".to_string(),
|
||||
..response
|
||||
};
|
||||
let error = validate_scanner_publication_lease_response_fields(&stale_session, "session-a", 7)
|
||||
.expect_err("a response from an older scanner session must be rejected");
|
||||
assert!(error.to_string().contains("different scanner publication lease session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_publication_lease_deadline_accounts_for_delayed_rpc_response() {
|
||||
let started = std::time::Instant::now();
|
||||
let expected_deadline = started + Duration::from_secs(55);
|
||||
let deadline = scanner_publication_lease_deadline(started, started + Duration::from_secs(10), 60_000)
|
||||
.expect("a response inside the safety window should retain the original deadline");
|
||||
assert_eq!(deadline, expected_deadline);
|
||||
|
||||
let error = scanner_publication_lease_deadline(started, started + Duration::from_secs(55), 60_000)
|
||||
.expect_err("a response arriving at the safety boundary must fail closed");
|
||||
assert!(error.to_string().contains("after its safety window"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_stats_response_decodes_valid_empty_provider() {
|
||||
let mut stats = BucketStats::default();
|
||||
@@ -2229,6 +2565,8 @@ mod tests {
|
||||
response_proof: Vec::new().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
})
|
||||
.expect("legacy peers should retain their activity generations during a rolling upgrade");
|
||||
assert_eq!(
|
||||
@@ -2242,6 +2580,8 @@ mod tests {
|
||||
data_movement_active: None,
|
||||
dirty_usage_generation: None,
|
||||
dirty_usage_pending: None,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2255,6 +2595,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
})
|
||||
.expect("protocol v4 peers should remain observable during a rolling upgrade");
|
||||
assert_eq!(
|
||||
@@ -2268,9 +2610,29 @@ mod tests {
|
||||
data_movement_active: Some(true),
|
||||
dirty_usage_generation: None,
|
||||
dirty_usage_pending: None,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
}
|
||||
);
|
||||
|
||||
let v6 = decode_test_scanner_activity(ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_V6_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: true,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
})
|
||||
.expect("v6 peers should remain readable without a v7 publication proof");
|
||||
assert_eq!(v6.movement_generation, None);
|
||||
assert_eq!(v6.publication_blocked, None);
|
||||
assert_eq!(v6.dirty_usage_generation, Some(11));
|
||||
|
||||
let malformed_topology = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
@@ -2281,6 +2643,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(malformed_topology)
|
||||
@@ -2299,6 +2663,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(missing_instance)
|
||||
@@ -2317,6 +2683,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(malformed_instance)
|
||||
@@ -2335,6 +2703,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
})
|
||||
.expect("complete activity responses should be accepted");
|
||||
assert_eq!(
|
||||
@@ -2348,9 +2718,31 @@ mod tests {
|
||||
data_movement_active: Some(true),
|
||||
dirty_usage_generation: Some(11),
|
||||
dirty_usage_pending: Some(true),
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
}
|
||||
);
|
||||
|
||||
let missing_movement_generation = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(missing_movement_generation)
|
||||
.expect_err("v7 activity must carry movement generation")
|
||||
.to_string()
|
||||
.contains("movement generation")
|
||||
);
|
||||
|
||||
let pending_without_generation = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
@@ -2361,6 +2753,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(pending_without_generation)
|
||||
@@ -2379,6 +2773,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(previous_with_dirty_usage)
|
||||
@@ -2397,6 +2793,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(legacy_with_topology)
|
||||
@@ -2415,6 +2813,8 @@ mod tests {
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(unsupported_protocol)
|
||||
@@ -2433,6 +2833,8 @@ mod tests {
|
||||
response_proof: Vec::new().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(missing_proof)
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::cluster::rpc::client::{
|
||||
node_service_time_out_client,
|
||||
};
|
||||
use crate::cluster::rpc::set_tonic_mutation_body_digest;
|
||||
use crate::core::pools::{PoolMeta, PoolMetaWriteState};
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::disk::error::{Error, Result};
|
||||
use crate::disk::error_reduce::{BUCKET_OP_IGNORED_ERRS, is_all_buckets_not_found, reduce_write_quorum_errs};
|
||||
@@ -46,7 +47,12 @@ use std::sync::{
|
||||
Mutex as StdMutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
fmt::Debug,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use tokio::sync::Notify;
|
||||
use tokio::{net::TcpStream, sync::RwLock, time};
|
||||
@@ -99,6 +105,9 @@ impl DeleteBucketEmptyScanBarrier {
|
||||
#[cfg(test)]
|
||||
static DELETE_BUCKET_EMPTY_SCAN_BARRIER: StdMutex<Option<Arc<DeleteBucketEmptyScanBarrier>>> = StdMutex::new(None);
|
||||
|
||||
#[cfg(test)]
|
||||
static HEAL_BUCKET_PRE_MUTATION_BARRIER: StdMutex<Option<Arc<DeleteBucketEmptyScanBarrier>>> = StdMutex::new(None);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum HealBucketOperation {
|
||||
Make,
|
||||
@@ -171,6 +180,15 @@ pub(crate) fn install_delete_bucket_empty_scan_barrier() -> Arc<DeleteBucketEmpt
|
||||
barrier
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn install_heal_bucket_pre_mutation_barrier() -> Arc<DeleteBucketEmptyScanBarrier> {
|
||||
let barrier = Arc::new(DeleteBucketEmptyScanBarrier::default());
|
||||
*HEAL_BUCKET_PRE_MUTATION_BARRIER
|
||||
.lock()
|
||||
.expect("heal bucket mutation barrier lock should not be poisoned") = Some(barrier.clone());
|
||||
barrier
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_after_delete_bucket_empty_scan() {
|
||||
let barrier = DELETE_BUCKET_EMPTY_SCAN_BARRIER
|
||||
@@ -182,6 +200,20 @@ async fn pause_after_delete_bucket_empty_scan() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_before_heal_bucket_volume_mutation() {
|
||||
let barrier = HEAL_BUCKET_PRE_MUTATION_BARRIER
|
||||
.lock()
|
||||
.expect("heal bucket mutation barrier lock should not be poisoned")
|
||||
.take();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.pause().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
async fn pause_before_heal_bucket_volume_mutation() {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScannerBucketListing {
|
||||
pub buckets: Vec<BucketInfo>,
|
||||
@@ -253,9 +285,53 @@ fn resolve_heal_bucket_mode(opts: &mut HealOpts, pool_errs: &[Option<Error>]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct HealBucketRpcEnvelope {
|
||||
options: HealOpts,
|
||||
#[serde(rename = "fencedPools", default)]
|
||||
fenced_pools: Vec<usize>,
|
||||
}
|
||||
|
||||
pub fn encode_heal_bucket_rpc_options(opts: HealOpts, fenced_pools: &[usize]) -> Result<String> {
|
||||
if fenced_pools.is_empty() {
|
||||
return serde_json::to_string(&opts).map_err(Into::into);
|
||||
}
|
||||
|
||||
serde_json::to_string(&HealBucketRpcEnvelope {
|
||||
options: opts,
|
||||
fenced_pools: fenced_pools.to_vec(),
|
||||
})
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn decode_heal_bucket_rpc_options(payload: &str) -> Result<(HealOpts, Vec<usize>)> {
|
||||
match serde_json::from_str::<HealBucketRpcEnvelope>(payload) {
|
||||
Ok(envelope) => Ok((envelope.options, envelope.fenced_pools)),
|
||||
Err(envelope_err) => serde_json::from_str::<HealOpts>(payload)
|
||||
.map(|options| (options, Vec::new()))
|
||||
.map_err(|legacy_err| {
|
||||
Error::other(format!(
|
||||
"decode heal bucket RPC options failed: envelope={envelope_err}; legacy={legacy_err}"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PeerS3Client: Debug + Sync + Send + 'static {
|
||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem>;
|
||||
async fn heal_bucket_with_fence(&self, bucket: &str, opts: &HealOpts, _fenced_pools: &[usize]) -> Result<HealResultItem> {
|
||||
self.heal_bucket(bucket, opts).await
|
||||
}
|
||||
async fn heal_bucket_with_fence_from_movement_guarded_coordinator(
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &HealOpts,
|
||||
fenced_pools: &[usize],
|
||||
) -> Result<HealResultItem> {
|
||||
self.heal_bucket_with_fence(bucket, opts, fenced_pools).await
|
||||
}
|
||||
async fn make_bucket(&self, bucket: &str, opts: &MakeBucketOptions) -> Result<()>;
|
||||
async fn list_bucket(&self, opts: &BucketOptions) -> Result<Vec<BucketInfo>>;
|
||||
async fn delete_bucket(&self, bucket: &str, opts: &DeleteBucketOptions) -> Result<()>;
|
||||
@@ -309,6 +385,29 @@ impl S3PeerSys {
|
||||
|
||||
impl S3PeerSys {
|
||||
pub async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
self.heal_bucket_with_fence(bucket, opts, &[]).await
|
||||
}
|
||||
|
||||
pub async fn heal_bucket_with_fence(&self, bucket: &str, opts: &HealOpts, fenced_pools: &[usize]) -> Result<HealResultItem> {
|
||||
self.heal_bucket_with_fence_inner(bucket, opts, fenced_pools, false).await
|
||||
}
|
||||
|
||||
pub async fn heal_bucket_with_fence_from_movement_guarded_coordinator(
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &HealOpts,
|
||||
fenced_pools: &[usize],
|
||||
) -> Result<HealResultItem> {
|
||||
self.heal_bucket_with_fence_inner(bucket, opts, fenced_pools, true).await
|
||||
}
|
||||
|
||||
async fn heal_bucket_with_fence_inner(
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &HealOpts,
|
||||
fenced_pools: &[usize],
|
||||
movement_guard_held: bool,
|
||||
) -> Result<HealResultItem> {
|
||||
let mut opts = *opts;
|
||||
let mut futures = Vec::with_capacity(self.clients.len());
|
||||
for client in self.clients.iter() {
|
||||
@@ -331,7 +430,14 @@ impl S3PeerSys {
|
||||
let opts_clone = opts;
|
||||
let heal_bucket_results_clone = heal_bucket_results.clone();
|
||||
futures.push(async move {
|
||||
match client.heal_bucket(bucket, &opts_clone).await {
|
||||
let result = if movement_guard_held {
|
||||
client
|
||||
.heal_bucket_with_fence_from_movement_guarded_coordinator(bucket, &opts_clone, fenced_pools)
|
||||
.await
|
||||
} else {
|
||||
client.heal_bucket_with_fence(bucket, &opts_clone, fenced_pools).await
|
||||
};
|
||||
match result {
|
||||
Ok(res) => {
|
||||
heal_bucket_results_clone.write().await[idx] = res;
|
||||
None
|
||||
@@ -626,6 +732,63 @@ impl LocalPeerS3Client {
|
||||
.filter(|disk| usize::try_from(disk.endpoint().pool_idx).is_ok_and(|pool_idx| pools.contains(&pool_idx)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn heal_bucket_with_fence_inner(
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &HealOpts,
|
||||
fenced_pools: &[usize],
|
||||
movement_guard_held: bool,
|
||||
) -> Result<HealResultItem> {
|
||||
let disks = self.local_disks_for_pools().await.into_iter().map(Some).collect();
|
||||
let store = runtime_sources::object_store_handle().filter(|store| Arc::ptr_eq(&store.ctx, &self.instance_ctx));
|
||||
#[cfg(not(test))]
|
||||
if store.is_none() {
|
||||
return Err(Error::other("bucket heal refused: pool metadata is unavailable for this instance"));
|
||||
}
|
||||
let movement_gate = store.as_ref().map(|store| store.ctx.data_movement_operation_gate());
|
||||
let movement_guard = try_acquire_bucket_heal_movement_guard(movement_gate.as_ref(), movement_guard_held)?;
|
||||
let save_guard = acquire_bucket_heal_write_guard(store.as_ref().map(|store| &store.pool_meta_save_gate)).await?;
|
||||
let result = heal_bucket_local_on_disks_with_pool_meta(
|
||||
bucket,
|
||||
opts,
|
||||
disks,
|
||||
store.as_ref().map(|store| &store.pool_meta),
|
||||
fenced_pools,
|
||||
)
|
||||
.await;
|
||||
drop(save_guard);
|
||||
drop(movement_guard);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn try_acquire_bucket_heal_movement_guard<'a>(
|
||||
gate: Option<&'a Arc<tokio::sync::RwLock<()>>>,
|
||||
movement_guard_held: bool,
|
||||
) -> Result<Option<tokio::sync::RwLockReadGuard<'a, ()>>> {
|
||||
if movement_guard_held {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(gate) = gate else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Do not queue a receiver behind a movement writer while its coordinator
|
||||
// holds another node's read guard; failing fast breaks that cross-node cycle.
|
||||
gate.try_read()
|
||||
.map(Some)
|
||||
.map_err(|_| crate::error::StorageError::SlowDown.into())
|
||||
}
|
||||
|
||||
async fn acquire_bucket_heal_write_guard<'a>(
|
||||
gate: Option<&'a tokio::sync::Mutex<PoolMetaWriteState>>,
|
||||
) -> Result<Option<tokio::sync::MutexGuard<'a, PoolMetaWriteState>>> {
|
||||
let Some(gate) = gate else {
|
||||
return Ok(None);
|
||||
};
|
||||
let guard = gate.lock().await;
|
||||
guard.ensure_write_safe("bucket heal cannot run while pool metadata requires recovery")?;
|
||||
Ok(Some(guard))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -635,8 +798,20 @@ impl PeerS3Client for LocalPeerS3Client {
|
||||
}
|
||||
|
||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
let disks = self.local_disks_for_pools().await.into_iter().map(Some).collect();
|
||||
heal_bucket_local_on_disks(bucket, opts, disks).await
|
||||
self.heal_bucket_with_fence(bucket, opts, &[]).await
|
||||
}
|
||||
|
||||
async fn heal_bucket_with_fence(&self, bucket: &str, opts: &HealOpts, fenced_pools: &[usize]) -> Result<HealResultItem> {
|
||||
self.heal_bucket_with_fence_inner(bucket, opts, fenced_pools, false).await
|
||||
}
|
||||
|
||||
async fn heal_bucket_with_fence_from_movement_guarded_coordinator(
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &HealOpts,
|
||||
fenced_pools: &[usize],
|
||||
) -> Result<HealResultItem> {
|
||||
self.heal_bucket_with_fence_inner(bucket, opts, fenced_pools, true).await
|
||||
}
|
||||
|
||||
async fn list_bucket(&self, _opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
|
||||
@@ -1079,9 +1254,13 @@ impl PeerS3Client for RemotePeerS3Client {
|
||||
}
|
||||
|
||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
self.heal_bucket_with_fence(bucket, opts, &[]).await
|
||||
}
|
||||
|
||||
async fn heal_bucket_with_fence(&self, bucket: &str, opts: &HealOpts, fenced_pools: &[usize]) -> Result<HealResultItem> {
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let options: String = serde_json::to_string(opts)?;
|
||||
let options = encode_heal_bucket_rpc_options(*opts, fenced_pools)?;
|
||||
let mut client = self.get_client().await?;
|
||||
let mut request = Request::new(HealBucketRequest {
|
||||
bucket: bucket.to_string(),
|
||||
@@ -1229,6 +1408,117 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
opts: &HealOpts,
|
||||
disks: Vec<Option<DiskStore>>,
|
||||
) -> Result<HealResultItem> {
|
||||
if let Some(store) = runtime_sources::object_store_handle() {
|
||||
return heal_bucket_local_on_disks_with_pool_meta(bucket, opts, disks, Some(&store.pool_meta), &[]).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
return heal_bucket_local_on_disks_with_pool_meta(bucket, opts, disks, None, &[]).await;
|
||||
|
||||
#[cfg(not(test))]
|
||||
Err(Error::other("bucket heal refused: pool metadata is unavailable"))
|
||||
}
|
||||
|
||||
fn disk_pool_index(disk: &DiskStore) -> Result<usize> {
|
||||
usize::try_from(disk.endpoint().pool_idx)
|
||||
.map_err(|_| Error::other(format!("invalid bucket-heal pool index {}", disk.endpoint().pool_idx)))
|
||||
}
|
||||
|
||||
fn fenced_decommission_drive_state() -> DriveState {
|
||||
DriveState::Unknown("skipped-decommission-suspended".to_string())
|
||||
}
|
||||
|
||||
fn heal_bucket_fence_detail(fenced_pools: &BTreeSet<usize>) -> Option<String> {
|
||||
if fenced_pools.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pools = fenced_pools.iter().map(usize::to_string).collect::<Vec<_>>().join(", ");
|
||||
Some(format!("skipped: bucket-volume heal fenced on decommission-suspended pool(s): {pools}"))
|
||||
}
|
||||
|
||||
async fn snapshot_heal_bucket_fence(
|
||||
disks: &[Option<DiskStore>],
|
||||
pool_meta: Option<&RwLock<PoolMeta>>,
|
||||
dispatch_fenced_pools: &[usize],
|
||||
) -> Result<(Vec<bool>, BTreeSet<usize>)> {
|
||||
let mut fenced_disks = vec![false; disks.len()];
|
||||
let mut fenced_pools = dispatch_fenced_pools.iter().copied().collect::<BTreeSet<_>>();
|
||||
let pool_meta = match pool_meta {
|
||||
Some(pool_meta) => Some(pool_meta.read().await),
|
||||
None => None,
|
||||
};
|
||||
if let Some(pool_meta) = pool_meta.as_ref()
|
||||
&& let Some(pool_idx) = fenced_pools.iter().find(|pool_idx| **pool_idx >= pool_meta.pools.len())
|
||||
{
|
||||
return Err(Error::other(format!(
|
||||
"bucket-heal dispatch fence pool index {pool_idx} is absent from {} pool metadata entries",
|
||||
pool_meta.pools.len()
|
||||
)));
|
||||
}
|
||||
|
||||
for (disk_index, disk) in disks.iter().enumerate() {
|
||||
let Some(disk) = disk else {
|
||||
continue;
|
||||
};
|
||||
let pool_idx = disk_pool_index(disk)?;
|
||||
if let Some(pool_meta) = pool_meta.as_ref() {
|
||||
if pool_idx >= pool_meta.pools.len() {
|
||||
return Err(Error::other(format!(
|
||||
"bucket-heal pool index {pool_idx} is absent from {} pool metadata entries",
|
||||
pool_meta.pools.len()
|
||||
)));
|
||||
}
|
||||
if pool_meta.is_suspended(pool_idx) {
|
||||
fenced_pools.insert(pool_idx);
|
||||
}
|
||||
}
|
||||
if fenced_pools.contains(&pool_idx) {
|
||||
fenced_disks[disk_index] = true;
|
||||
}
|
||||
}
|
||||
Ok((fenced_disks, fenced_pools))
|
||||
}
|
||||
|
||||
async fn run_heal_bucket_volume_mutation<F, Fut>(
|
||||
disk: &DiskStore,
|
||||
pool_meta: Option<&RwLock<PoolMeta>>,
|
||||
operation: F,
|
||||
) -> Result<Option<usize>>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let Some(pool_meta) = pool_meta else {
|
||||
operation().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let pool_idx = disk_pool_index(disk)?;
|
||||
let pool_meta = pool_meta.read().await;
|
||||
if pool_idx >= pool_meta.pools.len() {
|
||||
return Err(Error::other(format!(
|
||||
"bucket-heal pool index {pool_idx} is absent from {} pool metadata entries",
|
||||
pool_meta.pools.len()
|
||||
)));
|
||||
}
|
||||
if pool_meta.is_suspended(pool_idx) {
|
||||
return Ok(Some(pool_idx));
|
||||
}
|
||||
|
||||
// Keep the metadata read guard through the disk mutation so a decommission
|
||||
// transition cannot pass between this state check and the destructive action.
|
||||
operation().await?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn heal_bucket_local_on_disks_with_pool_meta(
|
||||
bucket: &str,
|
||||
opts: &HealOpts,
|
||||
disks: Vec<Option<DiskStore>>,
|
||||
pool_meta: Option<&RwLock<PoolMeta>>,
|
||||
dispatch_fenced_pools: &[usize],
|
||||
) -> Result<HealResultItem> {
|
||||
let (fenced_disks, mut fenced_pool_idxs) = snapshot_heal_bucket_fence(&disks, pool_meta, dispatch_fenced_pools).await?;
|
||||
let fenced_disks = Arc::new(fenced_disks);
|
||||
let before_state = Arc::new(RwLock::new(vec![String::new(); disks.len()]));
|
||||
let after_state = Arc::new(RwLock::new(vec![String::new(); disks.len()]));
|
||||
|
||||
@@ -1238,7 +1528,14 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
let bucket = bucket.to_string();
|
||||
let bs_clone = before_state.clone();
|
||||
let as_clone = after_state.clone();
|
||||
let fenced_disks = fenced_disks.clone();
|
||||
futures.push(async move {
|
||||
if fenced_disks[index] {
|
||||
let skipped = fenced_decommission_drive_state().to_string();
|
||||
bs_clone.write().await[index] = skipped.clone();
|
||||
as_clone.write().await[index] = skipped;
|
||||
return None;
|
||||
}
|
||||
let disk = match disk {
|
||||
Some(disk) => disk,
|
||||
None => {
|
||||
@@ -1301,9 +1598,14 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
state: state.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(detail) = heal_bucket_fence_detail(&fenced_pool_idxs) {
|
||||
res.detail = detail;
|
||||
}
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
pause_before_heal_bucket_volume_mutation().await;
|
||||
|
||||
let mut operation_error = errs
|
||||
.iter()
|
||||
.filter_map(|err| match err {
|
||||
@@ -1315,26 +1617,35 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
if opts.remove && !bucket.starts_with(disk::RUSTFS_META_BUCKET) && !is_all_buckets_not_found(&errs) {
|
||||
let mut futures = Vec::new();
|
||||
for (index, disk) in disks.iter().enumerate() {
|
||||
if matches!(errs[index].as_ref(), Some(Error::DiskNotFound | Error::VolumeNotFound)) {
|
||||
if fenced_disks[index] || matches!(errs[index].as_ref(), Some(Error::DiskNotFound | Error::VolumeNotFound)) {
|
||||
continue;
|
||||
}
|
||||
let Some(disk) = disk.clone() else {
|
||||
continue;
|
||||
};
|
||||
let bucket = bucket.to_string();
|
||||
let mutation_disk = disk.clone();
|
||||
futures.push(async move {
|
||||
if let Some(err) = injected_heal_bucket_operation_error(&bucket, index, HealBucketOperation::Delete) {
|
||||
return (index, Err(err));
|
||||
}
|
||||
(index, disk.delete_volume(&bucket, false).await)
|
||||
let result = run_heal_bucket_volume_mutation(&disk, pool_meta, || async move {
|
||||
if let Some(err) = injected_heal_bucket_operation_error(&bucket, index, HealBucketOperation::Delete) {
|
||||
return Err(err);
|
||||
}
|
||||
mutation_disk.delete_volume(&bucket, false).await
|
||||
})
|
||||
.await;
|
||||
(index, result)
|
||||
});
|
||||
}
|
||||
|
||||
for (index, result) in join_all(futures).await {
|
||||
match result {
|
||||
Ok(()) | Err(Error::VolumeNotFound) => {
|
||||
Ok(None) | Err(Error::VolumeNotFound) => {
|
||||
after_state.write().await[index] = DriveState::Missing.to_string();
|
||||
}
|
||||
Ok(Some(pool_idx)) => {
|
||||
fenced_pool_idxs.insert(pool_idx);
|
||||
after_state.write().await[index] = fenced_decommission_drive_state().to_string();
|
||||
}
|
||||
Err(Error::VolumeNotEmpty) => {
|
||||
warn!(
|
||||
bucket,
|
||||
@@ -1365,30 +1676,38 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
let bs_clone = before_state.clone();
|
||||
futures.push(async move {
|
||||
if bs_clone.read().await[idx] == DriveState::Missing.to_string() {
|
||||
let Some(disk) = disk.as_ref() else {
|
||||
return (idx, Some(Error::DiskNotFound));
|
||||
let Some(disk) = disk else {
|
||||
return (idx, Err(Error::DiskNotFound));
|
||||
};
|
||||
|
||||
if let Some(err) = injected_heal_bucket_operation_error(&bucket, idx, HealBucketOperation::Make) {
|
||||
return (idx, Some(err));
|
||||
}
|
||||
match disk.make_volume(&bucket).await {
|
||||
Ok(()) | Err(Error::VolumeExists) => return (idx, None),
|
||||
Err(err) => return (idx, Some(err)),
|
||||
}
|
||||
let mutation_disk = disk.clone();
|
||||
let result = run_heal_bucket_volume_mutation(&disk, pool_meta, || async move {
|
||||
if let Some(err) = injected_heal_bucket_operation_error(&bucket, idx, HealBucketOperation::Make) {
|
||||
return Err(err);
|
||||
}
|
||||
match mutation_disk.make_volume(&bucket).await {
|
||||
Ok(()) | Err(Error::VolumeExists) => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
return (idx, result);
|
||||
}
|
||||
(idx, None)
|
||||
(idx, Ok(None))
|
||||
});
|
||||
}
|
||||
|
||||
for (index, result) in join_all(futures).await {
|
||||
match result {
|
||||
None => {
|
||||
Ok(None) => {
|
||||
if before_state.read().await[index] == DriveState::Missing.to_string() {
|
||||
after_state.write().await[index] = DriveState::Ok.to_string();
|
||||
}
|
||||
}
|
||||
Some(err) => {
|
||||
Ok(Some(pool_idx)) => {
|
||||
fenced_pool_idxs.insert(pool_idx);
|
||||
after_state.write().await[index] = fenced_decommission_drive_state().to_string();
|
||||
}
|
||||
Err(err) => {
|
||||
after_state.write().await[index] = match &err {
|
||||
Error::DiskNotFound => DriveState::Offline.to_string(),
|
||||
_ => DriveState::Corrupt.to_string(),
|
||||
@@ -1409,6 +1728,10 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(detail) = heal_bucket_fence_detail(&fenced_pool_idxs) {
|
||||
res.detail = detail;
|
||||
}
|
||||
|
||||
match operation_error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(res),
|
||||
@@ -1426,6 +1749,7 @@ async fn clone_drives() -> Vec<Option<DiskStore>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::pools::{PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus};
|
||||
use crate::disk::WalkDirOptions;
|
||||
use crate::disk::disk_store::LocalDiskWrapper;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
@@ -1599,6 +1923,23 @@ mod tests {
|
||||
disks
|
||||
}
|
||||
|
||||
fn heal_bucket_pool_meta(suspended_pool: Option<usize>) -> PoolMeta {
|
||||
PoolMeta {
|
||||
pools: (0..2)
|
||||
.map(|pool_idx| PoolStatus {
|
||||
id: pool_idx,
|
||||
cmd_line: format!("pool-{pool_idx}"),
|
||||
last_update: ::time::OffsetDateTime::UNIX_EPOCH,
|
||||
decommission: (suspended_pool == Some(pool_idx)).then(|| PoolDecommissionInfo {
|
||||
start_time: Some(::time::OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
}),
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_remote_peer(addr: &str) -> RemotePeerS3Client {
|
||||
RemotePeerS3Client {
|
||||
pools: Some(vec![0]),
|
||||
@@ -1910,6 +2251,173 @@ mod tests {
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_rechecks_decommission_before_recreating_volume() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for bucket-heal fence regression");
|
||||
let disks = init_test_local_disks_for_pools(&temp_dir, &[(0, 1), (1, 1)], "heal-bucket-mutation-fence").await;
|
||||
let bucket = "fenced-recreate-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("active pool should start with the bucket volume");
|
||||
|
||||
let pool_meta = Arc::new(RwLock::new(heal_bucket_pool_meta(None)));
|
||||
let barrier = install_heal_bucket_pre_mutation_barrier();
|
||||
let heal = tokio::spawn({
|
||||
let disks = disks.clone();
|
||||
let pool_meta = pool_meta.clone();
|
||||
async move {
|
||||
heal_bucket_local_on_disks_with_pool_meta(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.into_iter().map(Some).collect(),
|
||||
Some(pool_meta.as_ref()),
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
barrier.wait_until_paused().await;
|
||||
pool_meta.write().await.pools[1].decommission = Some(PoolDecommissionInfo {
|
||||
start_time: Some(::time::OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
});
|
||||
barrier.release();
|
||||
|
||||
let result = heal
|
||||
.await
|
||||
.expect("bucket-heal task should join")
|
||||
.expect("suspended pool should be reported as skipped");
|
||||
assert!(result.detail.contains("skipped") && result.detail.contains('1'));
|
||||
assert!(matches!(disks[1].stat_volume(bucket).await, Err(Error::VolumeNotFound)));
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_dispatch_fence_blocks_stale_active_peer_state() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for stale bucket-heal peer regression");
|
||||
let disks = init_test_local_disks_for_pools(&temp_dir, &[(0, 1), (1, 1)], "heal-bucket-dispatch-fence").await;
|
||||
let bucket = "dispatch-fenced-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("active pool should start with the bucket volume");
|
||||
let stale_pool_meta = RwLock::new(heal_bucket_pool_meta(None));
|
||||
|
||||
let result = heal_bucket_local_on_disks_with_pool_meta(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.iter().cloned().map(Some).collect(),
|
||||
Some(&stale_pool_meta),
|
||||
&[1],
|
||||
)
|
||||
.await
|
||||
.expect("dispatch fence should override stale active peer metadata");
|
||||
|
||||
assert!(result.detail.contains("skipped") && result.detail.contains('1'));
|
||||
assert!(matches!(disks[1].stat_volume(bucket).await, Err(Error::VolumeNotFound)));
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_bucket_heal_refuses_receiver_with_unsafe_pool_metadata() {
|
||||
let gate = tokio::sync::Mutex::new(PoolMetaWriteState::default());
|
||||
gate.lock().await.observe_replicas(PoolMetaReplicaState {
|
||||
needs_repair: true,
|
||||
repair_write_safe: false,
|
||||
});
|
||||
|
||||
let err = acquire_bucket_heal_write_guard(Some(&gate))
|
||||
.await
|
||||
.expect_err("receiver-side bucket heal must honor the local pool metadata gate");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("bucket heal cannot run while pool metadata requires recovery")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn receiver_bucket_heal_fails_fast_behind_queued_movement_writer() {
|
||||
let gate = Arc::new(tokio::sync::RwLock::new(()));
|
||||
let coordinator_guard = gate.read().await;
|
||||
let writer_gate = gate.clone();
|
||||
let mut writer = tokio::spawn(async move {
|
||||
let _writer_guard = writer_gate.write().await;
|
||||
});
|
||||
while gate.try_read().is_ok() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
let err = try_acquire_bucket_heal_movement_guard(Some(&gate), false)
|
||||
.expect_err("receiver must not wait behind a queued movement writer");
|
||||
assert_eq!(err, crate::error::StorageError::SlowDown.into());
|
||||
assert!(
|
||||
try_acquire_bucket_heal_movement_guard(Some(&gate), true)
|
||||
.expect("coordinator-owned movement guard should be reused")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
drop(coordinator_guard);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), &mut writer)
|
||||
.await
|
||||
.expect("queued movement writer should proceed after the coordinator guard is released")
|
||||
.expect("movement writer task should not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_keeps_suspended_pool_volume_on_remove() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for bucket-heal delete fence regression");
|
||||
let disks = init_test_local_disks_for_pools(&temp_dir, &[(0, 1), (1, 1)], "heal-bucket-delete-fence").await;
|
||||
let bucket = "fenced-remove-bucket";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket volume should exist before heal");
|
||||
}
|
||||
let pool_meta = RwLock::new(heal_bucket_pool_meta(Some(1)));
|
||||
|
||||
let result = heal_bucket_local_on_disks_with_pool_meta(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
remove: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.iter().cloned().map(Some).collect(),
|
||||
Some(&pool_meta),
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect("suspended pool should be skipped during bucket-volume removal");
|
||||
|
||||
assert!(result.detail.contains("skipped") && result.detail.contains('1'));
|
||||
assert!(matches!(disks[0].stat_volume(bucket).await, Err(Error::VolumeNotFound)));
|
||||
disks[1]
|
||||
.stat_volume(bucket)
|
||||
.await
|
||||
.expect("suspended pool bucket volume must not be deleted");
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_dry_run_reports_discovered_drive_states() {
|
||||
@@ -2123,6 +2631,36 @@ mod tests {
|
||||
assert!(partial.recreate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_bucket_rpc_envelope_preserves_legacy_compatibility_fail_closed() {
|
||||
let opts = HealOpts {
|
||||
recreate: true,
|
||||
pool: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = encode_heal_bucket_rpc_options(opts, &[1, 2]).expect("encode bucket-heal RPC envelope");
|
||||
|
||||
assert!(
|
||||
serde_json::from_str::<HealOpts>(&encoded).is_err(),
|
||||
"an old peer must reject the nested request instead of ignoring its dispatch fence"
|
||||
);
|
||||
let (decoded, fenced_pools) =
|
||||
decode_heal_bucket_rpc_options(&encoded).expect("new peer should decode bucket-heal RPC envelope");
|
||||
assert!(decoded.recreate);
|
||||
assert_eq!(decoded.pool, Some(2));
|
||||
assert_eq!(fenced_pools, vec![1, 2]);
|
||||
|
||||
let legacy = encode_heal_bucket_rpc_options(opts, &[]).expect("encode legacy HealOpts for an unfenced heal");
|
||||
let old_peer_opts = serde_json::from_str::<HealOpts>(&legacy).expect("old peer should decode an unfenced heal request");
|
||||
assert!(old_peer_opts.recreate);
|
||||
assert_eq!(old_peer_opts.pool, Some(2));
|
||||
|
||||
let (decoded, fenced_pools) = decode_heal_bucket_rpc_options(&legacy).expect("new peer should accept a legacy request");
|
||||
assert!(decoded.recreate);
|
||||
assert_eq!(decoded.pool, Some(2));
|
||||
assert!(fenced_pools.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_make_bucket_reduces_quorum_by_pool_participants() {
|
||||
let peer_sys = S3PeerSys {
|
||||
|
||||
@@ -1981,6 +1981,20 @@ impl RemoteDisk {
|
||||
fi: &FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
self.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn rename_data_borrowed_with_fence(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
fi: &FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
scanner_publication_lease_token: Option<Uuid>,
|
||||
) -> Result<RenameDataResp> {
|
||||
trace!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
@@ -2013,9 +2027,18 @@ impl RemoteDisk {
|
||||
dst_volume: dst_volume.to_string(),
|
||||
dst_path: dst_path.to_string(),
|
||||
file_info_bin: file_info_bin.into(),
|
||||
scanner_publication_lease_token: scanner_publication_lease_token
|
||||
.map(|token| token.as_bytes().to_vec().into())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
|
||||
if scanner_publication_lease_token.is_some() {
|
||||
let canonical_body =
|
||||
canonical_body.map_err(|_| Error::other("rename_data request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
} else {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "rename_data")?;
|
||||
}
|
||||
|
||||
let response = client.rename_data(request).await?.into_inner();
|
||||
|
||||
@@ -2035,6 +2058,70 @@ impl RemoteDisk {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a path while binding the target-side operation to a scanner
|
||||
/// publication lease. The ordinary `DiskAPI::delete` path keeps the
|
||||
/// legacy digest/compatibility behavior by passing no token.
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn delete_with_scanner_publication_lease(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
opt: DeleteOptions,
|
||||
scanner_publication_lease_token: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
trace!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
volume,
|
||||
path,
|
||||
recursive = opt.recursive,
|
||||
immediate = opt.immediate,
|
||||
fenced = scanner_publication_lease_token.is_some(),
|
||||
op = "delete",
|
||||
state = "started",
|
||||
"Remote disk RPC started"
|
||||
);
|
||||
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let options = serde_json::to_string(&opt)?;
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
|
||||
let mut request = Request::new(DeleteRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
options,
|
||||
scanner_publication_lease_token: scanner_publication_lease_token
|
||||
.map(|token| token.as_bytes().to_vec().into())
|
||||
.unwrap_or_default(),
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_delete_request_body(request.get_ref());
|
||||
if scanner_publication_lease_token.is_some() {
|
||||
let canonical_body =
|
||||
canonical_body.map_err(|_| Error::other("delete request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
} else {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "delete")?;
|
||||
}
|
||||
|
||||
let response = client.delete(request).await?.into_inner();
|
||||
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -3446,47 +3533,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn delete(&self, volume: &str, path: &str, opt: DeleteOptions) -> Result<()> {
|
||||
trace!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
volume,
|
||||
path,
|
||||
recursive = opt.recursive,
|
||||
immediate = opt.immediate,
|
||||
op = "delete",
|
||||
state = "started",
|
||||
"Remote disk RPC started"
|
||||
);
|
||||
|
||||
self.execute_with_timeout(
|
||||
|| async {
|
||||
let options = serde_json::to_string(&opt)?;
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
|
||||
let mut request = Request::new(DeleteRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
options,
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_delete_request_body(request.get_ref());
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "delete")?;
|
||||
|
||||
let response = client.delete(request).await?.into_inner();
|
||||
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
self.delete_with_scanner_publication_lease(volume, path, opt, None).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -6864,7 +6911,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remote_disk_walk_dir_preserves_skip_total_timeout_option() {
|
||||
async fn test_remote_disk_walk_dir_preserves_control_options() {
|
||||
let transport = RecordingInternodeDataTransport::default();
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
|
||||
let opts = WalkDirOptions {
|
||||
@@ -6872,6 +6919,7 @@ mod tests {
|
||||
base_dir: "prefix".to_string(),
|
||||
recursive: true,
|
||||
skip_total_timeout: true,
|
||||
skip_hidden_prefix_check: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut writer = Vec::new();
|
||||
@@ -6888,6 +6936,7 @@ mod tests {
|
||||
let sent_opts: WalkDirOptions =
|
||||
serde_json::from_slice(&request.body).expect("walk_dir request body should deserialize");
|
||||
assert!(sent_opts.skip_total_timeout);
|
||||
assert!(sent_opts.skip_hidden_prefix_check);
|
||||
assert_eq!(request.stall_timeout, Some(get_drive_walkdir_stall_timeout()));
|
||||
}
|
||||
other => panic!("expected walk-dir transport call, got {other:?}"),
|
||||
|
||||
+4082
-529
File diff suppressed because it is too large
Load Diff
@@ -1241,8 +1241,16 @@ pub(crate) async fn make_local_two_set_sets() -> (Vec<tempfile::TempDir>, Arc<Se
|
||||
make_local_two_set_sets_with_ctx(bootstrap_ctx()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub(crate) async fn make_local_two_set_sets_with_ctx(ctx: Arc<InstanceContext>) -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||
make_local_two_set_sets_for_pool_with_ctx(ctx, 0).await
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub(crate) async fn make_local_two_set_sets_for_pool_with_ctx(
|
||||
ctx: Arc<InstanceContext>,
|
||||
pool_idx: usize,
|
||||
) -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use rustfs_lock::client::local::LocalClient;
|
||||
|
||||
@@ -1258,7 +1266,7 @@ pub(crate) async fn make_local_two_set_sets_with_ctx(ctx: Arc<InstanceContext>)
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("tempdir path should be utf8"))
|
||||
.expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_pool_index(pool_idx);
|
||||
endpoint.set_set_index(set_index);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
let disk = new_disk(
|
||||
@@ -1294,7 +1302,7 @@ pub(crate) async fn make_local_two_set_sets_with_ctx(ctx: Arc<InstanceContext>)
|
||||
2,
|
||||
1,
|
||||
set_index,
|
||||
0,
|
||||
pool_idx,
|
||||
endpoints,
|
||||
format.clone(),
|
||||
lockers,
|
||||
@@ -1307,7 +1315,7 @@ pub(crate) async fn make_local_two_set_sets_with_ctx(ctx: Arc<InstanceContext>)
|
||||
let sets = Arc::new(Sets {
|
||||
id: format.id,
|
||||
disk_set: disk_sets,
|
||||
pool_idx: 0,
|
||||
pool_idx,
|
||||
endpoints: PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 2,
|
||||
|
||||
@@ -1023,10 +1023,11 @@ pub(crate) enum SourceCleanupError {
|
||||
Storage(#[from] Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct SourceCleanupBucketFence<'a> {
|
||||
pub(crate) expected_incarnation_id: Option<uuid::Uuid>,
|
||||
pub(crate) lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
|
||||
pub(crate) namespace_lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
pub(crate) object_mutation_fence: Option<&'a SourceCleanupMutationFence>,
|
||||
}
|
||||
|
||||
@@ -1061,7 +1062,7 @@ pub(crate) async fn ensure_source_cleanup_versions_unchanged(
|
||||
ensure_source_cleanup_versions_match(expected, ¤t, allowed_missing)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
struct SourceCleanupDeleteBarrierState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
@@ -1071,7 +1072,7 @@ struct SourceCleanupDeleteBarrierState {
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)"
|
||||
@@ -1080,11 +1081,11 @@ pub(crate) struct SourceCleanupDeleteBarrier {
|
||||
state: Arc<SourceCleanupDeleteBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
static SOURCE_CLEANUP_DELETE_BARRIERS: std::sync::OnceLock<std::sync::Mutex<Vec<Arc<SourceCleanupDeleteBarrierState>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)"
|
||||
@@ -1148,7 +1149,7 @@ pub(crate) fn notify_source_cleanup_mutation_fence_pending(bucket: &str, object:
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl Drop for SourceCleanupDeleteBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
@@ -1160,7 +1161,7 @@ impl Drop for SourceCleanupDeleteBarrier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
async fn pause_source_cleanup_before_delete(bucket: &str, object: &str) {
|
||||
let barrier = SOURCE_CLEANUP_DELETE_BARRIERS
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
@@ -1220,7 +1221,7 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
|
||||
ensure_source_cleanup_versions_unchanged(set.clone(), bucket, object, expected, allowed_missing, op_label).await?;
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_source_cleanup_before_delete(bucket, object).await;
|
||||
|
||||
let mut opts = ObjectOptions {
|
||||
@@ -1240,6 +1241,9 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
if let Some(bucket_lifecycle_guard) = bucket_fence.lifecycle_guard {
|
||||
opts.add_bucket_lifecycle_lock_guard(bucket_lifecycle_guard);
|
||||
}
|
||||
if let Some(signal) = bucket_fence.namespace_lock_lost_signal {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
let result = set.delete_object(bucket, cleanup_key.as_str(), opts).await;
|
||||
if result.is_ok() {
|
||||
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
|
||||
@@ -1410,11 +1414,13 @@ pub(crate) async fn migrate_decommission_object(
|
||||
rd,
|
||||
source_bucket_incarnation_id,
|
||||
op_label,
|
||||
None,
|
||||
Some(&_mutation_fence),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn migrate_object(
|
||||
store: Arc<ECStore>,
|
||||
pool_idx: usize,
|
||||
@@ -1423,9 +1429,33 @@ pub(crate) async fn migrate_object(
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
) -> Result<()> {
|
||||
migrate_object_inner(store, pool_idx, bucket, rd, source_bucket_incarnation_id, op_label, None).await
|
||||
migrate_object_with_lock_lost_signal(store, pool_idx, bucket, rd, source_bucket_incarnation_id, op_label, None).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn migrate_object_with_lock_lost_signal(
|
||||
store: Arc<ECStore>,
|
||||
pool_idx: usize,
|
||||
bucket: String,
|
||||
rd: GetObjectReader,
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> Result<()> {
|
||||
migrate_object_inner(
|
||||
store,
|
||||
pool_idx,
|
||||
bucket,
|
||||
rd,
|
||||
source_bucket_incarnation_id,
|
||||
op_label,
|
||||
lock_lost_signal,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn migrate_object_inner(
|
||||
store: Arc<ECStore>,
|
||||
pool_idx: usize,
|
||||
@@ -1433,6 +1463,7 @@ async fn migrate_object_inner(
|
||||
rd: GetObjectReader,
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
||||
) -> Result<()> {
|
||||
let object_info = rd.object_info.clone();
|
||||
@@ -1446,6 +1477,9 @@ async fn migrate_object_inner(
|
||||
if should_use_multipart_data_movement(&object_info, has_part_checksums) {
|
||||
let mut new_multipart_opts = data_movement_new_multipart_opts(&object_info, pool_idx);
|
||||
new_multipart_opts.expected_bucket_incarnation_id = source_bucket_incarnation_id;
|
||||
if let Some(signal) = lock_lost_signal.as_ref() {
|
||||
new_multipart_opts.add_namespace_lock_lost_signal(Arc::clone(signal));
|
||||
}
|
||||
let (res, target_pool_idx, expected_bucket_incarnation_id) = match store
|
||||
.handle_new_multipart_upload_with_pool_idx(&bucket, &object_info.name, &new_multipart_opts, mutation_fence)
|
||||
.await
|
||||
@@ -1490,7 +1524,7 @@ async fn migrate_object_inner(
|
||||
err,
|
||||
)
|
||||
})?;
|
||||
let part_opts = ObjectOptions {
|
||||
let mut part_opts = ObjectOptions {
|
||||
part_number: Some(part.number),
|
||||
preserve_etag: Some(part.etag.clone()),
|
||||
data_movement: true,
|
||||
@@ -1498,6 +1532,9 @@ async fn migrate_object_inner(
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(signal) = lock_lost_signal.as_ref() {
|
||||
part_opts.add_namespace_lock_lost_signal(Arc::clone(signal));
|
||||
}
|
||||
let pi = match store
|
||||
.put_object_part_for_data_movement(
|
||||
target_pool_idx,
|
||||
@@ -1542,6 +1579,9 @@ async fn migrate_object_inner(
|
||||
)
|
||||
})?;
|
||||
complete_multipart_opts.expected_bucket_incarnation_id = expected_bucket_incarnation_id;
|
||||
if let Some(signal) = lock_lost_signal.as_ref() {
|
||||
complete_multipart_opts.add_namespace_lock_lost_signal(Arc::clone(signal));
|
||||
}
|
||||
if let Err(err) = store
|
||||
.clone()
|
||||
.complete_multipart_upload_for_data_movement(
|
||||
@@ -1590,18 +1630,18 @@ async fn migrate_object_inner(
|
||||
|
||||
if multipart_result.is_ok() && should_abort_multipart_upload(&abort_multipart_flag) {
|
||||
let abort_result = store
|
||||
.abort_multipart_upload_for_data_movement(
|
||||
target_pool_idx,
|
||||
&bucket,
|
||||
&object_info.name,
|
||||
&res.upload_id,
|
||||
&ObjectOptions {
|
||||
.abort_multipart_upload_for_data_movement(target_pool_idx, &bucket, &object_info.name, &res.upload_id, &{
|
||||
let mut opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
src_pool_idx: pool_idx,
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
};
|
||||
if let Some(signal) = lock_lost_signal.as_ref() {
|
||||
opts.add_namespace_lock_lost_signal(Arc::clone(signal));
|
||||
}
|
||||
opts
|
||||
})
|
||||
.await;
|
||||
match abort_result {
|
||||
Ok(()) => return Ok(()),
|
||||
@@ -1659,18 +1699,18 @@ async fn migrate_object_inner(
|
||||
if let Err(primary_err) = multipart_result {
|
||||
if should_abort_multipart_upload(&abort_multipart_flag) {
|
||||
return match store
|
||||
.abort_multipart_upload_for_data_movement(
|
||||
target_pool_idx,
|
||||
&bucket,
|
||||
&object_info.name,
|
||||
&res.upload_id,
|
||||
&ObjectOptions {
|
||||
.abort_multipart_upload_for_data_movement(target_pool_idx, &bucket, &object_info.name, &res.upload_id, &{
|
||||
let mut opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
src_pool_idx: pool_idx,
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
};
|
||||
if let Some(signal) = lock_lost_signal.as_ref() {
|
||||
opts.add_namespace_lock_lost_signal(Arc::clone(signal));
|
||||
}
|
||||
opts
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => Err(primary_err),
|
||||
@@ -1705,6 +1745,9 @@ async fn migrate_object_inner(
|
||||
|
||||
let mut put_opts = data_movement_put_object_opts(&object_info, pool_idx);
|
||||
put_opts.expected_bucket_incarnation_id = source_bucket_incarnation_id;
|
||||
if let Some(signal) = lock_lost_signal {
|
||||
put_opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
let (target_pool_idx, put_result) = store
|
||||
.put_object_for_data_movement(&bucket, &object_info.name, &mut data, &put_opts, mutation_fence)
|
||||
.await
|
||||
@@ -1950,6 +1993,19 @@ mod tests {
|
||||
assert!(source_cleanup_versions_match_with_allowed_missing(&expected, ¤t, &allowed_missing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decommission_cleanup_preflight_accepts_migrated_free_version_consumed_from_source() {
|
||||
let migrated = cleanup_test_file_info("object.txt", Uuid::from_u128(1), "migrated");
|
||||
let mut free_version = cleanup_test_file_info("object.txt", Uuid::from_u128(2), "tier-cleanup");
|
||||
free_version.deleted = true;
|
||||
free_version.set_tier_free_version();
|
||||
let expected = cleanup_test_versions(vec![migrated.clone(), free_version.clone()]);
|
||||
let current = cleanup_test_versions(vec![migrated]);
|
||||
let allowed_missing = vec![source_cleanup_version_identity(&free_version)];
|
||||
|
||||
assert!(source_cleanup_versions_match_with_allowed_missing(&expected, ¤t, &allowed_missing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decommission_cleanup_preflight_rejects_unexpected_missing_version() {
|
||||
let migrated = cleanup_test_file_info("object.txt", Uuid::from_u128(1), "migrated");
|
||||
|
||||
@@ -743,7 +743,12 @@ where
|
||||
let backup_seed = load_data_usage_for_bucket_removal(store, DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await?
|
||||
.map(|(data_usage_info, _)| data_usage_info)
|
||||
.or_else(|| primary_seed.clone());
|
||||
.filter(|data_usage_info| !data_usage_info.usage_snapshot_bootstrap_pending)
|
||||
.or_else(|| {
|
||||
primary_seed
|
||||
.clone()
|
||||
.filter(|data_usage_info| !data_usage_info.usage_snapshot_bootstrap_pending)
|
||||
});
|
||||
remove_bucket_usage_from_object_with_retries_and_publication(
|
||||
store,
|
||||
DATA_USAGE_OBJ_BACKUP_PATH.as_str(),
|
||||
@@ -2776,7 +2781,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: TokioMutex::new(()),
|
||||
pool_meta_save_gate: TokioMutex::new(()),
|
||||
pool_meta_save_gate: TokioMutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: Arc::default(),
|
||||
})
|
||||
@@ -4646,6 +4651,32 @@ mod tests {
|
||||
assert!(state.backup_object.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_bucket_usage_does_not_seed_backup_from_pristine_bootstrap_marker() {
|
||||
let marker = DataUsageInfo {
|
||||
last_update: Some(SystemTime::now()),
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_bootstrap_pending: true,
|
||||
..Default::default()
|
||||
};
|
||||
let store = Arc::new(UsageCasStore {
|
||||
state: Mutex::new(UsageCasState {
|
||||
object: Some((serde_json::to_vec(&marker).expect("bootstrap marker should encode"), 1)),
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
|
||||
remove_bucket_usage_from_backend_with_store(store.as_ref(), "bucket-a")
|
||||
.await
|
||||
.expect("bucket removal should preserve the pending primary without creating a backup");
|
||||
|
||||
let state = store.state.lock().await;
|
||||
assert!(state.backup_object.is_none());
|
||||
let saved = serde_json::from_slice::<DataUsageInfo>(&state.object.as_ref().expect("pending primary should remain").0)
|
||||
.expect("pending primary should decode");
|
||||
assert!(saved.usage_snapshot_bootstrap_pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_bucket_usage_migrates_legacy_snapshot_without_hiding_other_buckets() {
|
||||
let mut legacy = data_usage_info_for_test("bucket-a", 2, 84, SystemTime::now());
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
// limitations under the License.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::Metadata,
|
||||
path::Path,
|
||||
sync::{Arc, OnceLock},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{
|
||||
fs::{self, File},
|
||||
@@ -225,6 +227,78 @@ pub async fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
|
||||
fs::read(path.as_ref()).await
|
||||
}
|
||||
|
||||
// Bucket existence cache - reduces statx syscalls for repeated bucket checks
|
||||
|
||||
/// Cache for bucket directory existence checks.
|
||||
struct BucketExistenceCache {
|
||||
cache: Mutex<HashMap<PathBuf, (Instant, bool)>>,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl BucketExistenceCache {
|
||||
fn new(ttl: Duration) -> Self {
|
||||
Self {
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
ttl,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_exists(&self, path: &PathBuf) -> Option<bool> {
|
||||
let mut cache = self.cache.lock().ok()?;
|
||||
if let Some((timestamp, exists)) = cache.get(path) {
|
||||
if timestamp.elapsed() < self.ttl {
|
||||
return Some(*exists);
|
||||
}
|
||||
cache.remove(path);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn record(&self, path: PathBuf, exists: bool) {
|
||||
if let Ok(mut cache) = self.cache.lock() {
|
||||
cache.insert(path, (Instant::now(), exists));
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate(&self, path: &PathBuf) {
|
||||
if let Ok(mut cache) = self.cache.lock() {
|
||||
cache.remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static BUCKET_EXISTENCE_CACHE: std::sync::LazyLock<BucketExistenceCache> =
|
||||
std::sync::LazyLock::new(|| BucketExistenceCache::new(Duration::from_secs(60)));
|
||||
|
||||
/// Cached access check - reduces statx syscalls
|
||||
pub async fn cached_access(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
let path_buf = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(exists) = BUCKET_EXISTENCE_CACHE.check_exists(&path_buf) {
|
||||
if exists {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "bucket not found (cached)"));
|
||||
}
|
||||
|
||||
let result = fs::metadata(&path_buf).await;
|
||||
|
||||
match &result {
|
||||
Ok(_) => BUCKET_EXISTENCE_CACHE.record(path_buf, true),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
BUCKET_EXISTENCE_CACHE.record(path_buf, false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn invalidate_bucket_cache(path: impl AsRef<Path>) {
|
||||
BUCKET_EXISTENCE_CACHE.invalidate(&path.as_ref().to_path_buf());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -35,7 +35,10 @@ use crate::disk::{
|
||||
error::{DiskError, Error, FileAccessDeniedWithContext, Result},
|
||||
error_conv::{to_access_error, to_file_error, to_unformatted_disk_error, to_volume_error},
|
||||
format::FormatV3,
|
||||
fs::{O_APPEND, O_CREATE, O_RDONLY, O_TRUNC, O_WRONLY, access, lstat, lstat_std, remove, remove_all_std, remove_std, rename},
|
||||
fs::{
|
||||
O_APPEND, O_CREATE, O_RDONLY, O_TRUNC, O_WRONLY, access, cached_access, invalidate_bucket_cache, lstat, lstat_std,
|
||||
remove, remove_all_std, remove_std, rename,
|
||||
},
|
||||
is_quota_mutation_fence_path, os,
|
||||
os::{check_path_length, is_dir_not_empty_error, is_empty_dir, is_root_disk, rename_all, rename_all_ignore_missing_source},
|
||||
quota_mutation_fence_path,
|
||||
@@ -80,6 +83,11 @@ use uuid::Uuid;
|
||||
|
||||
const DELETED_OBJECTS_CLEANUP_INTERVAL: Duration = Duration::from_secs(60 * 5);
|
||||
const STALE_TMP_OBJECT_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
static DIRECTORY_LISTING_ENTRY_PROBE_COUNT: Arc<AtomicUsize>;
|
||||
}
|
||||
const RUSTFS_META_TMP_OLD_BUCKET: &str = ".rustfs.sys/tmp-old";
|
||||
const INLINE_METADATA_ROLLBACK_DIR_XOR: u128 = 0x7275737466735f696e6c696e655f7262;
|
||||
const DELETE_MARKER_ROLLBACK_FILE: &str = "xl.meta.delete-marker.rollback";
|
||||
@@ -3553,7 +3561,7 @@ impl LocalIoBackend for StdBackend {
|
||||
let access_check_start = metrics_enabled.then(std::time::Instant::now);
|
||||
let volume_dir = local_disk_bucket_path(self.io_root(), volume)?;
|
||||
if !skip_access_checks(volume) {
|
||||
access(&volume_dir)
|
||||
cached_access(&volume_dir)
|
||||
.await
|
||||
.map_err(|e| to_access_error(e, DiskError::VolumeAccessDenied))?;
|
||||
}
|
||||
@@ -3618,7 +3626,7 @@ impl LocalIoBackend for StdBackend {
|
||||
async fn open_read_stream(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<FileReader> {
|
||||
let volume_dir = local_disk_bucket_path(self.io_root(), volume)?;
|
||||
if !skip_access_checks(volume) {
|
||||
access(&volume_dir)
|
||||
cached_access(&volume_dir)
|
||||
.await
|
||||
.map_err(|e| to_access_error(e, DiskError::VolumeAccessDenied))?;
|
||||
}
|
||||
@@ -3658,7 +3666,7 @@ impl LocalIoBackend for StdBackend {
|
||||
async fn open_full_read(&self, volume: &str, path: &str) -> Result<FileReader> {
|
||||
let volume_dir = local_disk_bucket_path(self.io_root(), volume)?;
|
||||
if !skip_access_checks(volume) {
|
||||
access(&volume_dir)
|
||||
cached_access(&volume_dir)
|
||||
.await
|
||||
.map_err(|e| to_access_error(e, DiskError::VolumeAccessDenied))?;
|
||||
}
|
||||
@@ -3712,7 +3720,7 @@ impl LocalIoBackend for StdBackend {
|
||||
WriteMode::Append => {
|
||||
let volume_dir = local_disk_bucket_path(self.io_root(), volume)?;
|
||||
if !skip_access_checks(volume) {
|
||||
access(&volume_dir)
|
||||
cached_access(&volume_dir)
|
||||
.await
|
||||
.map_err(|e| to_access_error(e, DiskError::VolumeAccessDenied))?;
|
||||
}
|
||||
@@ -5817,7 +5825,7 @@ impl LocalDisk {
|
||||
async fn delete_unleased(&self, volume: &str, path: &str, opt: &DeleteOptions) -> Result<()> {
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
if !skip_access_checks(volume)
|
||||
&& let Err(e) = access(&volume_dir).await
|
||||
&& let Err(e) = cached_access(&volume_dir).await
|
||||
{
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
@@ -6739,7 +6747,7 @@ impl LocalDisk {
|
||||
let read_dir_result = match read_dir_entries_with_walk_stall(&dir_path_abs, -1, stall).await {
|
||||
Err(err) if err == Error::FileNotFound && !skip_access_checks(&opts.bucket) => {
|
||||
let volume_dir = self.io_get_bucket_path(&opts.bucket)?;
|
||||
if let Err(access_err) = access(&volume_dir).await {
|
||||
if let Err(access_err) = cached_access(&volume_dir).await {
|
||||
Err(to_access_error(access_err, DiskError::VolumeAccessDenied).into())
|
||||
} else {
|
||||
Err(err)
|
||||
@@ -7057,6 +7065,7 @@ impl LocalDisk {
|
||||
meta.name.push_str(SLASH_SEPARATOR);
|
||||
if opts.recursive
|
||||
|| opts.incl_deleted
|
||||
|| opts.skip_hidden_prefix_check
|
||||
|| self
|
||||
.directory_has_listing_entry(&opts.bucket, &meta.name, opts.incl_deleted, stall)
|
||||
.await?
|
||||
@@ -7202,6 +7211,10 @@ impl LocalDisk {
|
||||
continue;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let _previous_probe_count =
|
||||
DIRECTORY_LISTING_ENTRY_PROBE_COUNT.try_with(|probe_count| probe_count.fetch_add(1, Ordering::Relaxed));
|
||||
|
||||
let entries = match with_walk_stall_timeout(stall, self.list_dir("", bucket, ¤t, -1)).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
@@ -8124,7 +8137,7 @@ impl DiskAPI for LocalDisk {
|
||||
async fn verify_file(&self, volume: &str, path: &str, fi: &FileInfo) -> Result<CheckPartsResp> {
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
if !skip_access_checks(volume)
|
||||
&& let Err(e) = access(&volume_dir).await
|
||||
&& let Err(e) = cached_access(&volume_dir).await
|
||||
{
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
@@ -8332,7 +8345,7 @@ impl DiskAPI for LocalDisk {
|
||||
|
||||
if e == DiskError::FileNotFound {
|
||||
if !skip_access_checks(volume)
|
||||
&& let Err(err) = access(&volume_dir).await
|
||||
&& let Err(err) = cached_access(&volume_dir).await
|
||||
&& err.kind() == ErrorKind::NotFound
|
||||
{
|
||||
resp.results[i] = CHECK_PART_VOLUME_NOT_FOUND;
|
||||
@@ -8858,7 +8871,7 @@ impl DiskAPI for LocalDisk {
|
||||
Err(e) => {
|
||||
if e.kind() == ErrorKind::NotFound
|
||||
&& !skip_access_checks(volume)
|
||||
&& let Err(e) = access(&volume_dir).await
|
||||
&& let Err(e) = cached_access(&volume_dir).await
|
||||
{
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
@@ -8887,7 +8900,7 @@ impl DiskAPI for LocalDisk {
|
||||
let volume_dir = self.io_get_bucket_path(&opts.bucket)?;
|
||||
|
||||
if !skip_access_checks(&opts.bucket)
|
||||
&& let Err(e) = with_walk_stall_deadline(stall, access(&volume_dir)).await?
|
||||
&& let Err(e) = with_walk_stall_deadline(stall, cached_access(&volume_dir)).await?
|
||||
{
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
@@ -9968,9 +9981,12 @@ impl DiskAPI for LocalDisk {
|
||||
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
|
||||
// Volume creation is a mutation boundary, so it must observe the live
|
||||
// filesystem rather than a potentially stale existence-cache entry.
|
||||
if let Err(e) = access(&volume_dir).await {
|
||||
if e.kind() == ErrorKind::NotFound {
|
||||
os::make_dir_all(&volume_dir, self.io_root()).await?;
|
||||
invalidate_bucket_cache(&volume_dir);
|
||||
return Ok(());
|
||||
}
|
||||
error!(
|
||||
@@ -10028,7 +10044,7 @@ impl DiskAPI for LocalDisk {
|
||||
async fn delete_paths(&self, volume: &str, paths: &[String]) -> Result<()> {
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
if !skip_access_checks(volume) {
|
||||
access(&volume_dir)
|
||||
cached_access(&volume_dir)
|
||||
.await
|
||||
.map_err(|e| to_access_error(e, DiskError::VolumeAccessDenied))?;
|
||||
}
|
||||
@@ -10861,6 +10877,7 @@ impl DiskAPI for LocalDisk {
|
||||
// hit path skips the volume-access check, so nothing else would notice)
|
||||
// (rustfs/backlog#1177).
|
||||
self.io_backend.invalidate_cached_fds_for_volume(volume);
|
||||
invalidate_bucket_cache(&p);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -17511,6 +17528,86 @@ mod test {
|
||||
assert_eq!(objs_returned, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scan_dir_nonrecursive_visible_prefix_probe_cost() {
|
||||
use rustfs_filemeta::MetacacheReader;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const PREFIX_COUNT: usize = 64;
|
||||
|
||||
let dir = tempdir().expect("tempdir should be created");
|
||||
let bucket = "test-bucket";
|
||||
let bucket_dir = dir.path().join(bucket);
|
||||
let mut expected_names = Vec::with_capacity(PREFIX_COUNT);
|
||||
|
||||
for index in 0..PREFIX_COUNT {
|
||||
let prefix = format!("prefix-{index:04}");
|
||||
let object_name = format!("{prefix}/nested/object");
|
||||
let object_dir = bucket_dir.join(&object_name);
|
||||
fs::create_dir_all(&object_dir)
|
||||
.await
|
||||
.expect("visible object directory should be created");
|
||||
|
||||
let mut metadata = FileMeta::default();
|
||||
let mut file_info = FileInfo::new(&object_name, 1, 1);
|
||||
file_info.mod_time = Some(OffsetDateTime::now_utc());
|
||||
metadata.add_version(file_info).expect("visible metadata should be valid");
|
||||
fs::write(
|
||||
object_dir.join(STORAGE_FORMAT_FILE),
|
||||
metadata.marshal_msg().expect("visible metadata should encode"),
|
||||
)
|
||||
.await
|
||||
.expect("visible object metadata should be written");
|
||||
expected_names.push(format!("{prefix}/"));
|
||||
}
|
||||
|
||||
async fn scan_prefixes(disk: &LocalDisk, bucket: &str, skip_hidden_prefix_check: bool) -> (Vec<String>, usize) {
|
||||
let probe_count = Arc::new(AtomicUsize::new(0));
|
||||
let (reader, mut writer) = tokio::io::duplex(64 * 1024);
|
||||
let mut output = MetacacheWriter::new(&mut writer);
|
||||
let opts = WalkDirOptions {
|
||||
bucket: bucket.to_string(),
|
||||
skip_hidden_prefix_check,
|
||||
..Default::default()
|
||||
};
|
||||
let mut objects_returned = 0;
|
||||
|
||||
DIRECTORY_LISTING_ENTRY_PROBE_COUNT
|
||||
.scope(
|
||||
Arc::clone(&probe_count),
|
||||
disk.scan_dir("".to_string(), "".to_string(), &opts, &mut output, &mut objects_returned, false, None),
|
||||
)
|
||||
.await
|
||||
.expect("scan_dir should succeed");
|
||||
output.close().await.expect("metacache writer should close");
|
||||
drop(output);
|
||||
drop(writer);
|
||||
|
||||
let mut reader = MetacacheReader::new(reader);
|
||||
let names = reader
|
||||
.read_all()
|
||||
.await
|
||||
.expect("scan output should decode")
|
||||
.into_iter()
|
||||
.map(|entry| entry.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(names, probe_count.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
let endpoint =
|
||||
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be UTF-8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should initialize");
|
||||
|
||||
let (conservative_names, conservative_probes) = scan_prefixes(&disk, bucket, false).await;
|
||||
let (fast_path_names, fast_path_probes) = scan_prefixes(&disk, bucket, true).await;
|
||||
|
||||
assert_eq!(conservative_names, expected_names);
|
||||
assert_eq!(fast_path_names, expected_names);
|
||||
assert_eq!(conservative_probes, PREFIX_COUNT * 3);
|
||||
assert_eq!(fast_path_probes, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_scan_dir_nonrecursive_skips_dirs_with_only_hidden_delete_markers() {
|
||||
use rustfs_filemeta::MetacacheReader;
|
||||
@@ -18333,6 +18430,29 @@ mod test {
|
||||
let _ = fs::remove_dir_all(&p).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn make_volume_rechecks_stale_positive_existence_cache() {
|
||||
let root_dir = tempfile::tempdir().expect("temporary disk root should be created");
|
||||
let endpoint = Endpoint::try_from(root_dir.path().to_string_lossy().as_ref()).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should initialize");
|
||||
let volume_dir = disk.io_get_bucket_path("bucket").expect("bucket path should resolve");
|
||||
|
||||
fs::create_dir_all(&volume_dir)
|
||||
.await
|
||||
.expect("bucket directory should be created");
|
||||
cached_access(&volume_dir)
|
||||
.await
|
||||
.expect("existing bucket should populate the cache");
|
||||
fs::remove_dir(&volume_dir)
|
||||
.await
|
||||
.expect("bucket directory should be removed outside the cache");
|
||||
|
||||
disk.make_volume("bucket")
|
||||
.await
|
||||
.expect("volume creation should recheck the live filesystem");
|
||||
assert!(fs::metadata(volume_dir).await.is_ok(), "volume directory should be recreated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_volume() {
|
||||
let p = "./testv1";
|
||||
|
||||
@@ -677,6 +677,23 @@ impl DiskAPI for Disk {
|
||||
}
|
||||
|
||||
impl Disk {
|
||||
pub(crate) async fn delete_with_scanner_publication_lease(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
opts: DeleteOptions,
|
||||
scanner_publication_lease_token: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
Disk::Local(local_disk) => local_disk.delete(volume, path, opts).await,
|
||||
Disk::Remote(remote_disk) => {
|
||||
remote_disk
|
||||
.delete_with_scanner_publication_lease(volume, path, opts, scanner_publication_lease_token)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn rename_data_borrowed(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
@@ -684,6 +701,19 @@ impl Disk {
|
||||
fi: &FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
self.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn rename_data_borrowed_with_fence(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
fi: &FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
scanner_publication_lease_token: Option<Uuid>,
|
||||
) -> Result<RenameDataResp> {
|
||||
match self {
|
||||
Disk::Local(local_disk) => {
|
||||
@@ -693,7 +723,14 @@ impl Disk {
|
||||
}
|
||||
Disk::Remote(remote_disk) => {
|
||||
remote_disk
|
||||
.rename_data_borrowed(src_volume, src_path, fi, dst_volume, dst_path)
|
||||
.rename_data_borrowed_with_fence(
|
||||
src_volume,
|
||||
src_path,
|
||||
fi,
|
||||
dst_volume,
|
||||
dst_path,
|
||||
scanner_publication_lease_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1194,6 +1231,11 @@ pub struct WalkDirOptions {
|
||||
#[serde(default)]
|
||||
pub incl_deleted: bool,
|
||||
|
||||
// Skip recursive prefix visibility probes only when authoritative bucket
|
||||
// metadata proves versioning was never enabled.
|
||||
#[serde(default)]
|
||||
pub skip_hidden_prefix_check: bool,
|
||||
|
||||
// ReportNotFound will return errFileNotFound if all disks reports the BaseDir cannot be found.
|
||||
pub report_notfound: bool,
|
||||
|
||||
@@ -1532,6 +1574,7 @@ mod tests {
|
||||
base_dir: "/path/to/dir".to_string(),
|
||||
recursive: true,
|
||||
incl_deleted: false,
|
||||
skip_hidden_prefix_check: false,
|
||||
report_notfound: false,
|
||||
filter_prefix: Some("prefix_".to_string()),
|
||||
forward_to: Some("object/path".to_string()),
|
||||
@@ -1546,6 +1589,7 @@ mod tests {
|
||||
assert_eq!(opts.base_dir, "/path/to/dir");
|
||||
assert!(opts.recursive);
|
||||
assert!(!opts.incl_deleted);
|
||||
assert!(!opts.skip_hidden_prefix_check);
|
||||
assert!(!opts.report_notfound);
|
||||
assert_eq!(opts.filter_prefix, Some("prefix_".to_string()));
|
||||
assert_eq!(opts.forward_to, Some("object/path".to_string()));
|
||||
@@ -1556,6 +1600,23 @@ mod tests {
|
||||
assert_eq!(opts.stall_timeout_duration(), Some(std::time::Duration::from_secs(20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_walk_dir_options_default_hidden_prefix_check_for_old_peers() {
|
||||
let mut encoded = serde_json::to_value(WalkDirOptions {
|
||||
skip_hidden_prefix_check: true,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("walk options should serialize");
|
||||
encoded
|
||||
.as_object_mut()
|
||||
.expect("walk options should serialize as an object")
|
||||
.remove("skip_hidden_prefix_check");
|
||||
|
||||
let decoded: WalkDirOptions = serde_json::from_value(encoded).expect("old peer options should deserialize");
|
||||
|
||||
assert!(!decoded.skip_hidden_prefix_check);
|
||||
}
|
||||
|
||||
/// Test DeleteOptions structure
|
||||
#[test]
|
||||
fn test_delete_options() {
|
||||
|
||||
@@ -84,6 +84,61 @@ impl NamespaceLockFence {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static NAMESPACE_LOCK_SIGNAL_TEST_FENCES: std::sync::OnceLock<std::sync::Mutex<Vec<(usize, NamespaceLockFence)>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct NamespaceLockSignalTestFence {
|
||||
signal_key: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl NamespaceLockSignalTestFence {
|
||||
pub(crate) fn install_with_loss_handle(
|
||||
signal: &Arc<rustfs_lock::distributed_lock::LockLostSignal>,
|
||||
loss_handle: Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> Self {
|
||||
let fence = NamespaceLockFence {
|
||||
signals: Arc::default(),
|
||||
forced_lost: Arc::new(vec![loss_handle]),
|
||||
};
|
||||
let signal_key = Arc::as_ptr(signal) as usize;
|
||||
let mut fences = NAMESPACE_LOCK_SIGNAL_TEST_FENCES
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("namespace lock signal test fence should not be poisoned");
|
||||
assert!(
|
||||
!fences.iter().any(|(key, _)| *key == signal_key),
|
||||
"namespace lock signal test fence must be unique"
|
||||
);
|
||||
fences.push((signal_key, fence));
|
||||
Self { signal_key }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for NamespaceLockSignalTestFence {
|
||||
fn drop(&mut self) {
|
||||
let mut fences = NAMESPACE_LOCK_SIGNAL_TEST_FENCES
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("namespace lock signal test fence should not be poisoned");
|
||||
fences.retain(|(key, _)| *key != self.signal_key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn namespace_lock_signal_test_fence_is_lost(signal: &Arc<rustfs_lock::distributed_lock::LockLostSignal>) -> bool {
|
||||
NAMESPACE_LOCK_SIGNAL_TEST_FENCES
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("namespace lock signal test fence should not be poisoned")
|
||||
.iter()
|
||||
.find(|(key, _)| *key == Arc::as_ptr(signal) as usize)
|
||||
.is_some_and(|(_, fence)| fence.is_lock_lost())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ObjectLockConfigSnapshot {
|
||||
store_id: Option<Uuid>,
|
||||
@@ -391,6 +446,11 @@ pub struct ObjectOptions {
|
||||
pub tier_delete_journal_api: Option<Arc<crate::store::ECStore>>,
|
||||
}
|
||||
|
||||
/// Transient scanner-only carrier for target-side publication lease tokens.
|
||||
/// SetDisks consumes and removes this key before constructing durable
|
||||
/// FileInfo metadata; it must never appear in an S3-visible object.
|
||||
pub const SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY: &str = "x-rustfs-internal-scanner-publication-lease-fence-v1";
|
||||
|
||||
impl ObjectOptions {
|
||||
pub fn set_quota_admission(&mut self, current_usage: u64, quota_limit: u64) -> bool {
|
||||
self.quota_admission = (current_usage <= quota_limit).then_some(QuotaAdmission {
|
||||
@@ -405,9 +465,23 @@ impl ObjectOptions {
|
||||
}
|
||||
|
||||
pub(crate) fn add_namespace_lock_lost_signal(&mut self, signal: Arc<rustfs_lock::distributed_lock::LockLostSignal>) {
|
||||
#[cfg(test)]
|
||||
let test_fence = NAMESPACE_LOCK_SIGNAL_TEST_FENCES
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("namespace lock signal test fence should not be poisoned")
|
||||
.iter()
|
||||
.find(|(key, _)| *key == Arc::as_ptr(&signal) as usize)
|
||||
.map(|(_, fence)| fence.clone());
|
||||
self.namespace_lock_fence
|
||||
.get_or_insert_with(NamespaceLockFence::new)
|
||||
.add_signal(signal);
|
||||
#[cfg(test)]
|
||||
if let Some(test_fence) = test_fence {
|
||||
self.namespace_lock_fence
|
||||
.get_or_insert_with(NamespaceLockFence::new)
|
||||
.extend(&test_fence);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_namespace_lock_fence(&mut self) {
|
||||
|
||||
@@ -56,7 +56,8 @@ use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
|
||||
};
|
||||
use tokio::sync::{OnceCell, RwLock};
|
||||
use tokio::sync::{Mutex, Notify, OnceCell, OwnedRwLockReadGuard, RwLock};
|
||||
use tokio::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -64,6 +65,19 @@ const SCANNER_PUBLICATION_STATE_UNKNOWN: u8 = 0;
|
||||
const SCANNER_PUBLICATION_STATE_ALLOWED: u8 = 1;
|
||||
const SCANNER_PUBLICATION_STATE_BLOCKED: u8 = 2;
|
||||
|
||||
pub(crate) const SCANNER_PUBLICATION_LEASE_MAX_ENTRIES: usize = 256;
|
||||
|
||||
/// A lease is deliberately short-lived. The coordinator treats expiry as a
|
||||
/// failed publication rather than silently continuing with a peer that may
|
||||
/// have started movement after the lease was abandoned.
|
||||
pub(crate) const SCANNER_PUBLICATION_LEASE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
pub(crate) struct ScannerPublicationLeaseEntry {
|
||||
pub(crate) expires_at: Instant,
|
||||
pub(crate) movement_generation: u64,
|
||||
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
||||
}
|
||||
|
||||
/// Runtime state owned by a single `ECStore` instance.
|
||||
///
|
||||
/// This is intentionally minimal in the first migration slice; subsequent
|
||||
@@ -171,6 +185,11 @@ pub struct InstanceContext {
|
||||
/// Readers are held across one publication commit; movement transitions
|
||||
/// take the writer at their durable state commit boundary.
|
||||
data_movement_operation_gate: Arc<RwLock<()>>,
|
||||
/// Remote scanner publication leases own a read guard until explicit
|
||||
/// release or bounded expiry. Keeping the guard in storage-owned state
|
||||
/// makes a remote movement transition wait on the same fence as a local
|
||||
/// scanner commit.
|
||||
scanner_publication_leases: Arc<Mutex<HashMap<Uuid, ScannerPublicationLeaseEntry>>>,
|
||||
/// Monotonic admission epoch paired with the operation gate. A
|
||||
/// publication admitted before a movement transition must never be
|
||||
/// mistaken for one admitted after the transition.
|
||||
@@ -180,6 +199,13 @@ pub struct InstanceContext {
|
||||
/// saturating counter prevents an unchanged `u64::MAX` value from being
|
||||
/// mistaken for a fresh epoch after overflow.
|
||||
data_movement_operation_epoch_exhausted: AtomicBool,
|
||||
/// Storage-owned generation for movement state changes. This is separate
|
||||
/// from the publication admission epoch so scanners can wait for a
|
||||
/// terminal/clear transition without treating the wake as a publication
|
||||
/// permit.
|
||||
data_movement_generation: AtomicU64,
|
||||
data_movement_generation_exhausted: AtomicBool,
|
||||
data_movement_generation_notify: Arc<Notify>,
|
||||
/// Last storage-owned movement snapshot observed under the operation
|
||||
/// gate. SetDisks cache writers fail closed until ECStore refreshes it.
|
||||
scanner_publication_state: AtomicU8,
|
||||
@@ -224,8 +250,12 @@ impl InstanceContext {
|
||||
bucket_metadata_sys: std::sync::Mutex::new(None),
|
||||
background_cancel_token: OnceLock::new(),
|
||||
data_movement_operation_gate: Arc::new(RwLock::new(())),
|
||||
scanner_publication_leases: Arc::new(Mutex::new(HashMap::new())),
|
||||
data_movement_operation_epoch: AtomicU64::new(0),
|
||||
data_movement_operation_epoch_exhausted: AtomicBool::new(false),
|
||||
data_movement_generation: AtomicU64::new(0),
|
||||
data_movement_generation_exhausted: AtomicBool::new(false),
|
||||
data_movement_generation_notify: Arc::new(Notify::new()),
|
||||
scanner_publication_state: AtomicU8::new(SCANNER_PUBLICATION_STATE_UNKNOWN),
|
||||
object_encryption_resolver: OnceLock::new(),
|
||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||
@@ -249,6 +279,75 @@ impl InstanceContext {
|
||||
Arc::clone(&self.data_movement_operation_gate)
|
||||
}
|
||||
|
||||
pub(crate) async fn install_scanner_publication_lease(
|
||||
&self,
|
||||
token: Uuid,
|
||||
expires_at: Instant,
|
||||
movement_generation: u64,
|
||||
operation_guard: OwnedRwLockReadGuard<()>,
|
||||
) -> bool {
|
||||
let mut leases = self.scanner_publication_leases.lock().await;
|
||||
if leases.len() >= SCANNER_PUBLICATION_LEASE_MAX_ENTRIES {
|
||||
return false;
|
||||
}
|
||||
leases.insert(
|
||||
token,
|
||||
ScannerPublicationLeaseEntry {
|
||||
expires_at,
|
||||
movement_generation,
|
||||
_operation_guard: operation_guard,
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_scanner_publication_lease(&self, token: Uuid) -> bool {
|
||||
self.scanner_publication_leases.lock().await.remove(&token).is_some()
|
||||
}
|
||||
|
||||
/// Check a lease token while the caller holds the movement read guard.
|
||||
///
|
||||
/// The token table is deliberately process-owned and non-persistent: a
|
||||
/// restarted instance has no entries from the previous process, so an old
|
||||
/// coordinator proof cannot become valid again merely because the
|
||||
/// movement generation counter restarted at zero.
|
||||
pub(crate) async fn scanner_publication_lease_is_active(&self, token: Uuid) -> bool {
|
||||
let mut leases = self.scanner_publication_leases.lock().await;
|
||||
let now = Instant::now();
|
||||
let Some(expires_at) = leases.get(&token).map(|entry| entry.expires_at) else {
|
||||
return false;
|
||||
};
|
||||
if expires_at <= now {
|
||||
leases.remove(&token);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Return the generation bound to a live lease. The lease entry owns the
|
||||
/// movement read guard, so a successful lookup remains valid for the
|
||||
/// caller's guard-protected operation; expiry is still fail-closed.
|
||||
pub(crate) async fn scanner_publication_lease_generation(&self, token: Uuid) -> Option<u64> {
|
||||
let mut leases = self.scanner_publication_leases.lock().await;
|
||||
let now = Instant::now();
|
||||
let (expires_at, movement_generation) = leases
|
||||
.get(&token)
|
||||
.map(|entry| (entry.expires_at, entry.movement_generation))?;
|
||||
if expires_at <= now {
|
||||
leases.remove(&token);
|
||||
return None;
|
||||
}
|
||||
Some(movement_generation)
|
||||
}
|
||||
|
||||
pub(crate) async fn expire_scanner_publication_lease(&self, token: Uuid, expires_at: Instant) {
|
||||
let mut leases = self.scanner_publication_leases.lock().await;
|
||||
let should_remove = leases.get(&token).is_some_and(|entry| entry.expires_at <= expires_at);
|
||||
if should_remove {
|
||||
leases.remove(&token);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn data_movement_operation_epoch(&self) -> u64 {
|
||||
self.data_movement_operation_epoch.load(Ordering::Acquire)
|
||||
}
|
||||
@@ -257,8 +356,21 @@ impl InstanceContext {
|
||||
self.data_movement_operation_epoch_exhausted.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn data_movement_generation(&self) -> u64 {
|
||||
self.data_movement_generation.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn data_movement_generation_exhausted(&self) -> bool {
|
||||
self.data_movement_generation_exhausted.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn data_movement_generation_notify(&self) -> Arc<Notify> {
|
||||
Arc::clone(&self.data_movement_generation_notify)
|
||||
}
|
||||
|
||||
pub(crate) fn scanner_publication_state_allowed(&self) -> bool {
|
||||
!self.data_movement_operation_epoch_exhausted()
|
||||
&& !self.data_movement_generation_exhausted()
|
||||
&& self.scanner_publication_state.load(Ordering::Acquire) == SCANNER_PUBLICATION_STATE_ALLOWED
|
||||
}
|
||||
|
||||
@@ -276,6 +388,7 @@ impl InstanceContext {
|
||||
pub(crate) fn advance_data_movement_operation_epoch(&self) -> u64 {
|
||||
self.scanner_publication_state
|
||||
.store(SCANNER_PUBLICATION_STATE_UNKNOWN, Ordering::Release);
|
||||
let previous = self.data_movement_operation_epoch.load(Ordering::Acquire);
|
||||
let _ = self
|
||||
.data_movement_operation_epoch
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |epoch| Some(epoch.saturating_add(1)));
|
||||
@@ -283,9 +396,42 @@ impl InstanceContext {
|
||||
if result == u64::MAX {
|
||||
self.data_movement_operation_epoch_exhausted.store(true, Ordering::Release);
|
||||
}
|
||||
if result != previous {
|
||||
let _ = self.advance_data_movement_generation();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Advance the movement generation after a durable movement transition.
|
||||
/// The generation is deliberately bounded: once it reaches `u64::MAX`,
|
||||
/// publication and generation-based waits fail closed rather than reusing
|
||||
/// an indistinguishable saturated value.
|
||||
pub(crate) fn advance_data_movement_generation(&self) -> Option<u64> {
|
||||
if self.data_movement_generation_exhausted.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
let updated = self
|
||||
.data_movement_generation
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |generation| generation.checked_add(1));
|
||||
match updated {
|
||||
Ok(previous) => {
|
||||
let Some(generation) = previous.checked_add(1) else {
|
||||
self.data_movement_generation_exhausted.store(true, Ordering::Release);
|
||||
return None;
|
||||
};
|
||||
if generation == u64::MAX {
|
||||
self.data_movement_generation_exhausted.store(true, Ordering::Release);
|
||||
}
|
||||
self.data_movement_generation_notify.notify_waiters();
|
||||
Some(generation)
|
||||
}
|
||||
Err(_) => {
|
||||
self.data_movement_generation_exhausted.store(true, Ordering::Release);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_data_movement_operation_epoch_for_test(&self, epoch: u64) {
|
||||
self.data_movement_operation_epoch.store(epoch, Ordering::Release);
|
||||
@@ -295,6 +441,13 @@ impl InstanceContext {
|
||||
.store(SCANNER_PUBLICATION_STATE_UNKNOWN, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_data_movement_generation_for_test(&self, generation: u64) {
|
||||
self.data_movement_generation.store(generation, Ordering::Release);
|
||||
self.data_movement_generation_exhausted
|
||||
.store(generation == u64::MAX, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Install the application-owned object-encryption resolver once.
|
||||
pub fn set_object_encryption_resolver(
|
||||
&self,
|
||||
|
||||
@@ -119,8 +119,8 @@ pub(crate) fn endpoint_erasure_set_count() -> Option<usize> {
|
||||
endpoint_pools().map(|endpoints| endpoints.es_count())
|
||||
}
|
||||
|
||||
pub(crate) fn endpoint_pool_is_local(pool_index: usize) -> bool {
|
||||
get_global_endpoints()
|
||||
pub(crate) fn endpoint_pool_is_local(endpoints: &EndpointServerPools, pool_index: usize) -> bool {
|
||||
endpoints
|
||||
.as_ref()
|
||||
.get(pool_index)
|
||||
.is_some_and(|pool| pool.endpoints.as_ref().first().is_some_and(|endpoint| endpoint.is_local))
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::cluster::rpc::{PeerRestClient, ScannerPeerActivity, TierConfigReloadOutcome};
|
||||
use crate::cluster::rpc::{PeerRestClient, ScannerPeerActivity, ScannerPublicationLease, TierConfigReloadOutcome};
|
||||
use crate::diagnostics::admin_server_info::get_commit_id;
|
||||
use crate::disk::DiskAPI;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -51,7 +51,13 @@ const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
|
||||
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 1;
|
||||
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScannerPublicationLeaseGrant {
|
||||
pub host: String,
|
||||
pub lease: ScannerPublicationLease,
|
||||
}
|
||||
|
||||
/// Cached result from the last successful admin call to a peer.
|
||||
struct PeerAdminCache {
|
||||
@@ -210,6 +216,24 @@ pub fn cross_pool_fence_fleet_proof_matches(proof: &CrossPoolFenceFleetProofToke
|
||||
fleet_capability_proof_matches(cross_pool_fence_fleet_proof_slot(), &proof.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_cross_pool_fence_fleet_proof_for_test() {
|
||||
let topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "pool-activation-test-topology".to_string());
|
||||
let _ = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology.clone());
|
||||
let mut state = cross_pool_fence_fleet_proof_slot()
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.topology_conflict = false;
|
||||
state.proof = Some(FleetCapabilityProof {
|
||||
topology_fingerprint: topology,
|
||||
peer_epochs: Arc::new(BTreeMap::new()),
|
||||
expires_at: Instant::now() + Duration::from_secs(60 * 60),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn rotate_cross_pool_fence_fleet_proof_for_test() -> bool {
|
||||
let mut state = cross_pool_fence_fleet_proof_slot()
|
||||
@@ -352,7 +376,7 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
capability = "cross_pool_fence_v1",
|
||||
capability = "cross_pool_fence_v2",
|
||||
state = "failed_closed",
|
||||
error = %err,
|
||||
"notification capability probe"
|
||||
@@ -1121,9 +1145,21 @@ impl NotificationSys {
|
||||
}
|
||||
}
|
||||
|
||||
match store.stop_rebalance_for_id(expected_rebalance_id).await {
|
||||
let local_rebalance_id = match expected_rebalance_id {
|
||||
Some(expected_id) => Some(expected_id.to_owned()),
|
||||
None => store.current_rebalance_id().await,
|
||||
};
|
||||
match store.stop_rebalance_for_id(local_rebalance_id.as_deref()).await {
|
||||
Ok(_) => {
|
||||
if let Err(err) = store.save_rebalance_stats(usize::MAX, RebalSaveOpt::StoppedAt).await {
|
||||
let save_result = match local_rebalance_id.as_deref() {
|
||||
Some(expected_id) => {
|
||||
store
|
||||
.save_rebalance_stats_for_id(usize::MAX, RebalSaveOpt::StoppedAt, expected_id)
|
||||
.await
|
||||
}
|
||||
None => Ok(()),
|
||||
};
|
||||
if let Err(err) = save_result {
|
||||
error!(
|
||||
event = EVENT_NOTIFICATION_PEER_PROPAGATION,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -1461,6 +1497,102 @@ impl NotificationSys {
|
||||
aggregate_scanner_dirty_usage_acknowledgement_results(join_all(futures).await, failures)
|
||||
}
|
||||
|
||||
/// Acquire remote publication leases in a deterministic host order. A
|
||||
/// missing/legacy peer is a hard publication deferral; already acquired
|
||||
/// leases are released before returning so a partial acquisition cannot
|
||||
/// pin movement on one peer.
|
||||
pub async fn acquire_scanner_publication_leases(
|
||||
&self,
|
||||
mut targets: Vec<(String, String, u64)>,
|
||||
) -> Result<Vec<ScannerPublicationLeaseGrant>> {
|
||||
targets.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
for pair in targets.windows(2) {
|
||||
if pair[0].0 == pair[1].0 {
|
||||
return Err(Error::other(format!("duplicate scanner publication lease target: {}", pair[0].0)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut grants = Vec::with_capacity(targets.len());
|
||||
for (host, session_id, generation) in targets {
|
||||
let Some(client) = self
|
||||
.peer_clients
|
||||
.iter()
|
||||
.flatten()
|
||||
.find(|client| client.grid_host == host)
|
||||
.cloned()
|
||||
else {
|
||||
let _ = self.release_scanner_publication_leases(grants).await;
|
||||
return Err(Error::other(format!("scanner publication lease peer {host} is unavailable")));
|
||||
};
|
||||
match client.acquire_scanner_publication_lease(&session_id, generation).await {
|
||||
Ok(lease) => grants.push(ScannerPublicationLeaseGrant { host, lease }),
|
||||
Err(err) => {
|
||||
let _ = self.release_scanner_publication_leases(grants).await;
|
||||
return Err(Error::other(format!("scanner publication lease acquisition failed: {err}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(grants)
|
||||
}
|
||||
|
||||
pub async fn release_scanner_publication_leases(&self, mut grants: Vec<ScannerPublicationLeaseGrant>) -> Result<()> {
|
||||
grants.sort_by(|left, right| right.host.cmp(&left.host));
|
||||
let mut failures = Vec::new();
|
||||
for grant in grants {
|
||||
let Some(client) = self
|
||||
.peer_clients
|
||||
.iter()
|
||||
.flatten()
|
||||
.find(|client| client.grid_host == grant.host)
|
||||
else {
|
||||
failures.push(format!("peer {} is unavailable", grant.host));
|
||||
continue;
|
||||
};
|
||||
if let Err(err) = client.release_scanner_publication_lease(&grant.lease).await {
|
||||
failures.push(format!("peer {} release failed: {err}", grant.host));
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::other(format!(
|
||||
"scanner publication lease release failures: {}",
|
||||
failures.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Revalidate every remote lease in deterministic host order immediately
|
||||
/// before a final scanner metadata write. A peer restart removes its
|
||||
/// process-owned token table and changes its activity session, so an old
|
||||
/// generation cannot pass this proof even when the numeric generation is
|
||||
/// reused.
|
||||
pub async fn validate_scanner_publication_leases(&self, grants: &[ScannerPublicationLeaseGrant]) -> Result<()> {
|
||||
let mut grants = grants.to_vec();
|
||||
grants.sort_by(|left, right| left.host.cmp(&right.host));
|
||||
for pair in grants.windows(2) {
|
||||
if pair[0].host == pair[1].host {
|
||||
return Err(Error::other(format!("duplicate scanner publication lease target: {}", pair[0].host)));
|
||||
}
|
||||
}
|
||||
for grant in grants {
|
||||
let Some(client) = self
|
||||
.peer_clients
|
||||
.iter()
|
||||
.flatten()
|
||||
.find(|client| client.grid_host == grant.host)
|
||||
.cloned()
|
||||
else {
|
||||
return Err(Error::other(format!("scanner publication lease peer {} is unavailable", grant.host)));
|
||||
};
|
||||
client
|
||||
.validate_scanner_publication_lease(&grant.lease)
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("scanner publication lease validation failed for {}: {err}", grant.host)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload_site_replication_config(&self) -> Vec<NotificationPeerErr> {
|
||||
let mut futures = Vec::with_capacity(self.peer_clients.len());
|
||||
for client in self.peer_clients.iter() {
|
||||
@@ -2603,6 +2735,38 @@ mod tests {
|
||||
assert!(err.to_string().contains("no remote peers"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_publication_lease_release_reports_all_unavailable_peers() {
|
||||
let sys = NotificationSys {
|
||||
peer_clients: Vec::new(),
|
||||
all_peer_clients: Vec::new(),
|
||||
peer_topology_hosts: Vec::new(),
|
||||
peer_admin_caches: Vec::new(),
|
||||
tier_config_reload_workers: Default::default(),
|
||||
};
|
||||
let grants = ["peer-a", "peer-b"]
|
||||
.into_iter()
|
||||
.map(|host| ScannerPublicationLeaseGrant {
|
||||
host: host.to_string(),
|
||||
lease: ScannerPublicationLease {
|
||||
token: Uuid::new_v4(),
|
||||
movement_generation: 3,
|
||||
owner_id: Uuid::new_v4().to_string(),
|
||||
session_id: "session-a".to_string(),
|
||||
expires_at: Instant::now() + Duration::from_secs(30),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let error = sys
|
||||
.release_scanner_publication_leases(grants)
|
||||
.await
|
||||
.expect_err("an unavailable peer must not silently release a remote lease");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("peer-a"));
|
||||
assert!(message.contains("peer-b"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_activity_probe_rejects_an_incomplete_peer_topology() {
|
||||
let client = PeerRestClient::new(
|
||||
@@ -2725,6 +2889,8 @@ mod tests {
|
||||
data_movement_active: Some(false),
|
||||
dirty_usage_generation: Some(2),
|
||||
dirty_usage_pending,
|
||||
movement_generation: Some(1),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
|
||||
let pending = aggregate_scanner_dirty_usage_acknowledgement_results(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -155,7 +155,7 @@ impl RebalanceMeta {
|
||||
self.save_with_opts(store, ObjectOptions::default()).await
|
||||
}
|
||||
|
||||
pub async fn save_with_opts<S>(&self, store: Arc<S>, opts: ObjectOptions) -> Result<()>
|
||||
pub async fn save_with_opts<S>(&self, store: Arc<S>, mut opts: ObjectOptions) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
@@ -188,6 +188,14 @@ impl RebalanceMeta {
|
||||
let msg = rmp_serde::to_vec(self)?;
|
||||
data.extend(msg);
|
||||
|
||||
if self.stopped_at.is_none() && is_rebalance_conflicting_with_decommission(self) {
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut opts.user_defined,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_REBALANCE_RUN_ID,
|
||||
self.id.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
save_config_with_opts(store, REBAL_META_NAME, data, &opts).await?;
|
||||
|
||||
Ok(())
|
||||
@@ -864,10 +872,6 @@ pub(super) fn merge_rebalance_meta(remote: &mut RebalanceMeta, local: &Rebalance
|
||||
RebalanceMetaMergeOutcome::Merged
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "stop-transition helper retained beside stop_rebalance_meta_snapshot; no caller yet (backlog#1823)"
|
||||
)]
|
||||
pub(super) fn mark_started_rebalance_pools_stopped(meta: &mut RebalanceMeta, stop_time: OffsetDateTime) {
|
||||
for pool_stat in meta.pool_stats.iter_mut() {
|
||||
if pool_stat.info.status == RebalStatus::Started {
|
||||
@@ -935,6 +939,9 @@ pub(super) fn stop_rebalance_meta_snapshot_for_id(
|
||||
}
|
||||
|
||||
stop_rebalance_state(meta, now);
|
||||
// The caller holds the activation writer after admission was cancelled,
|
||||
// so all entry readers have drained and no later entry can be admitted.
|
||||
mark_started_rebalance_pools_stopped(meta, now);
|
||||
meta.last_refreshed_at = Some(now);
|
||||
Ok(Some(meta.clone()))
|
||||
}
|
||||
|
||||
@@ -102,11 +102,20 @@ pub(crate) trait MigrationBackend: Send + Sync {
|
||||
pub(crate) struct RebalanceMigrationBackend<'a> {
|
||||
source: &'a SetDisks,
|
||||
store: &'a ECStore,
|
||||
lock_lost_signal: Option<std::sync::Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
}
|
||||
|
||||
impl<'a> RebalanceMigrationBackend<'a> {
|
||||
pub(crate) fn new(source: &'a SetDisks, store: &'a ECStore) -> Self {
|
||||
Self { source, store }
|
||||
pub(crate) fn new(
|
||||
source: &'a SetDisks,
|
||||
store: &'a ECStore,
|
||||
lock_lost_signal: Option<std::sync::Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
store,
|
||||
lock_lost_signal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +139,11 @@ impl MigrationBackend for RebalanceMigrationBackend<'_> {
|
||||
fi: &FileInfo,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
self.store.decommission_tiered_object(bucket, object, fi, opts).await
|
||||
let mut opts = opts.clone();
|
||||
if let Some(signal) = self.lock_lost_signal.as_ref() {
|
||||
opts.add_namespace_lock_lost_signal(std::sync::Arc::clone(signal));
|
||||
}
|
||||
self.store.decommission_tiered_object(bucket, object, fi, &opts).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::disk::DiskAPI;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
use tokio::time::Duration;
|
||||
@@ -45,6 +47,8 @@ mod runtime;
|
||||
mod types;
|
||||
mod worker;
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use entry::test_util::PausedRebalanceEntryTestFixture;
|
||||
pub(crate) use meta::is_rebalance_conflicting_with_decommission;
|
||||
pub use meta::{decode_rebalance_stop_propagation_record, encode_rebalance_stop_propagation_record};
|
||||
pub use types::{
|
||||
@@ -53,5 +57,130 @@ pub use types::{
|
||||
};
|
||||
use types::{RebalanceBucketConfigs, RebalanceBucketOutcome, RebalanceEntryOutcome};
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub async fn test_store_with_persisted_rebalance_meta(
|
||||
meta: RebalanceMeta,
|
||||
) -> (Vec<tempfile::TempDir>, std::sync::Arc<crate::store::ECStore>) {
|
||||
let ctx = std::sync::Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (temp_dirs, pool) = crate::core::sets::make_local_two_set_sets_with_ctx(ctx.clone()).await;
|
||||
meta.save(pool.clone())
|
||||
.await
|
||||
.expect("rebalance test metadata should be persisted");
|
||||
let endpoint_pools: crate::layout::endpoints::EndpointServerPools = vec![pool.endpoints.clone()].into();
|
||||
let store = std::sync::Arc::new(crate::store::ECStore {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
disk_map: std::collections::HashMap::new(),
|
||||
pools: vec![pool],
|
||||
peer_sys: crate::cluster::rpc::S3PeerSys::new_with_instance_ctx(&endpoint_pools, ctx.clone()),
|
||||
pool_meta: tokio::sync::RwLock::new(crate::core::pools::PoolMeta::default()),
|
||||
rebalance_meta: tokio::sync::RwLock::new(Some(meta)),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(vec![None]),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
});
|
||||
(temp_dirs, store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn test_two_pool_stores(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
) -> (
|
||||
Vec<tempfile::TempDir>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
) {
|
||||
test_two_pool_stores_with_contexts(rebalance_meta, false).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn test_two_pool_stores_with_isolated_node_contexts(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
) -> (
|
||||
Vec<tempfile::TempDir>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
) {
|
||||
test_two_pool_stores_with_contexts(rebalance_meta, true).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn test_two_pool_stores_with_contexts(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
isolate_node_contexts: bool,
|
||||
) -> (
|
||||
Vec<tempfile::TempDir>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
) {
|
||||
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
||||
use crate::core::pools::PoolMeta;
|
||||
use crate::layout::endpoints::{EndpointServerPools, SetupType};
|
||||
|
||||
let ctx = std::sync::Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
let (mut temp_dirs, first_pool) =
|
||||
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(std::sync::Arc::clone(&ctx), 0).await;
|
||||
let (second_temp_dirs, second_pool) =
|
||||
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(std::sync::Arc::clone(&ctx), 1).await;
|
||||
temp_dirs.extend(second_temp_dirs);
|
||||
let pools = vec![first_pool, second_pool];
|
||||
{
|
||||
let local_disk_map = ctx.local_disk_map();
|
||||
let mut local_disk_map = local_disk_map.write().await;
|
||||
for pool in &pools {
|
||||
for set in &pool.disk_set {
|
||||
for disk in set.disks.read().await.iter().flatten() {
|
||||
local_disk_map.insert(disk.endpoint().to_string(), Some(disk.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
||||
pool_meta
|
||||
.save(pools.clone())
|
||||
.await
|
||||
.expect("baseline pool metadata should be persisted");
|
||||
if let Some(meta) = rebalance_meta.as_ref() {
|
||||
meta.save(pools[0].clone())
|
||||
.await
|
||||
.expect("active rebalance metadata should be persisted");
|
||||
}
|
||||
let endpoint_pools: EndpointServerPools = pools.iter().map(|pool| pool.endpoints.clone()).collect::<Vec<_>>().into();
|
||||
ctx.set_endpoints(endpoint_pools.clone());
|
||||
let other_ctx = if isolate_node_contexts {
|
||||
let other_ctx = std::sync::Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
other_ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
*other_ctx.local_disk_map().write().await = ctx.local_disk_map().read().await.clone();
|
||||
other_ctx.set_endpoints(endpoint_pools.clone());
|
||||
other_ctx
|
||||
} else {
|
||||
std::sync::Arc::clone(&ctx)
|
||||
};
|
||||
let make_store = |store_ctx: std::sync::Arc<crate::runtime::instance::InstanceContext>| {
|
||||
std::sync::Arc::new(crate::store::ECStore {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
disk_map: std::collections::HashMap::new(),
|
||||
pools: pools.clone(),
|
||||
peer_sys: crate::cluster::rpc::S3PeerSys::new_with_instance_ctx(&endpoint_pools, std::sync::Arc::clone(&store_ctx)),
|
||||
pool_meta: tokio::sync::RwLock::new(pool_meta.clone()),
|
||||
rebalance_meta: tokio::sync::RwLock::new(rebalance_meta.clone()),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(vec![None, None]),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::default(),
|
||||
ctx: store_ctx,
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
})
|
||||
};
|
||||
let store = make_store(ctx);
|
||||
let other_store = make_store(other_ctx);
|
||||
if isolate_node_contexts {
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(std::sync::Arc::clone(&store), Vec::new()).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(std::sync::Arc::clone(&other_store), Vec::new()).await;
|
||||
}
|
||||
(temp_dirs, store, other_store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rebalance_unit_tests;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::control::validate_rebalance_disk_stats_coverage;
|
||||
use super::control::{fail_next_rebalance_activation_save_for_test, validate_rebalance_disk_stats_coverage};
|
||||
use super::meta::{
|
||||
RebalanceMetaMergeOutcome, RebalanceTerminalEvent, apply_rebalance_save_option, apply_rebalance_terminal_event,
|
||||
apply_stopped_at, classify_rebalance_terminal_event, clone_arc_by_index, clone_first_arc, clone_rebalance_pool_stats,
|
||||
@@ -32,7 +32,11 @@ use super::migration::{
|
||||
MigrationBackend, MigrationVersionResult, migrate_entry_version, migrate_entry_version_with_retry_wait,
|
||||
rebalance_delete_marker_opts,
|
||||
};
|
||||
use super::runtime::{should_fail_repeated_rebalance_bucket_defer, source_cleanup_defer_attempt};
|
||||
use super::runtime::{
|
||||
RebalanceLocalActivationOutcome, commit_local_rebalance_worker_activation,
|
||||
commit_local_rebalance_worker_activation_candidate, should_fail_repeated_rebalance_bucket_defer,
|
||||
source_cleanup_defer_attempt, stage_local_rebalance_worker_activation,
|
||||
};
|
||||
use super::worker::{
|
||||
RebalanceEntryCleanupResult, ensure_rebalance_listing_disks_available, is_transient_rebalance_error,
|
||||
parse_rebalance_max_attempts, rebalance_listing_retry_delay, rebalance_migration_retry_delay,
|
||||
@@ -48,6 +52,7 @@ use super::worker::{
|
||||
use super::{
|
||||
DiskStat, GetObjectReader, ObjectInfo, ObjectOptions, RebalSaveOpt, RebalStatus, RebalanceBucketConfigs,
|
||||
RebalanceBucketOutcome, RebalanceCleanupWarnings, RebalanceEntryOutcome, RebalanceInfo, RebalanceMeta, RebalanceStats,
|
||||
RebalanceStopPropagationRecord,
|
||||
};
|
||||
use super::{REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX, REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX};
|
||||
use crate::bucket::replication::{ReplicationState, ReplicationStatusType, replication_state_to_filemeta};
|
||||
@@ -2680,7 +2685,10 @@ async fn test_start_rebalance_for_id_rejects_changed_metadata() {
|
||||
.await
|
||||
.expect_err("staged start must not start changed metadata");
|
||||
|
||||
assert!(err.to_string().contains("rebalance metadata changed before start"));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("stale rebalance worker rejected during start rebalance")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2708,6 +2716,281 @@ async fn test_start_rebalance_for_id_rejects_stopped_metadata() {
|
||||
assert!(err.to_string().contains("was stopped before start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopped_activation_state_prevents_worker_token_commit() {
|
||||
let mut meta = RebalanceMeta {
|
||||
id: "rebalance-a".to_string(),
|
||||
stopped_at: Some(OffsetDateTime::now_utc()),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let outcome = commit_local_rebalance_worker_activation(&mut meta, "rebalance-a", tokio_util::sync::CancellationToken::new())
|
||||
.expect("stopped metadata should produce a non-start outcome");
|
||||
assert_eq!(outcome, RebalanceLocalActivationOutcome::NotStartedTerminal);
|
||||
assert!(meta.cancel.is_none(), "stopped rebalance must not receive a worker token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_activation_candidate_does_not_clobber_replacement_token() {
|
||||
let mut local = RebalanceMeta {
|
||||
id: "rebalance-a".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
buckets: vec!["bucket-a".to_string()],
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let (candidate, outcome, must_persist) =
|
||||
stage_local_rebalance_worker_activation(&local, "rebalance-a", CancellationToken::new(), OffsetDateTime::UNIX_EPOCH)
|
||||
.expect("active activation candidate should be staged");
|
||||
assert_eq!(outcome, RebalanceLocalActivationOutcome::Started);
|
||||
assert!(!must_persist);
|
||||
|
||||
let replacement = CancellationToken::new();
|
||||
local.cancel = Some(replacement.clone());
|
||||
let err = commit_local_rebalance_worker_activation_candidate(&mut local, "rebalance-a", None, candidate)
|
||||
.expect_err("a replacement token must reject the stale activation candidate");
|
||||
assert!(err.to_string().contains("worker token changed"));
|
||||
assert_eq!(local.cancel.as_ref(), Some(&replacement));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_rebalance_start_save_failure_retries_persisted_completed_state() {
|
||||
let active = RebalanceMeta {
|
||||
id: "rebalance-real-save-completed".to_string(),
|
||||
percent_free_goal: 0.5,
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
init_free_space: 400,
|
||||
init_capacity: 1_000,
|
||||
bytes: 100,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp_dirs, store) = super::test_store_with_persisted_rebalance_meta(active).await;
|
||||
|
||||
fail_next_rebalance_activation_save_for_test("rebalance-real-save-completed");
|
||||
let err = store
|
||||
.start_rebalance_under_gate()
|
||||
.await
|
||||
.expect_err("the injected first activation save must fail through the real start path");
|
||||
assert!(err.to_string().contains("injected rebalance activation save failure"));
|
||||
{
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("local rebalance metadata should remain present");
|
||||
assert_eq!(local.pool_stats[0].info.status, RebalStatus::Started);
|
||||
assert!(local.cancel.is_none(), "failed persistence must not publish a worker token");
|
||||
}
|
||||
let mut after_failure = RebalanceMeta::new();
|
||||
after_failure
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("active metadata should remain readable after the failed save");
|
||||
assert_eq!(after_failure.pool_stats[0].info.status, RebalStatus::Started);
|
||||
|
||||
store
|
||||
.start_rebalance_under_gate()
|
||||
.await
|
||||
.expect("the real start path must retry and persist the terminal candidate");
|
||||
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
persisted
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("retry-persisted completed metadata should be readable");
|
||||
assert_eq!(persisted.pool_stats[0].info.status, RebalStatus::Completed);
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("local rebalance metadata should remain present");
|
||||
assert_eq!(local.pool_stats[0].info.status, RebalStatus::Completed);
|
||||
assert!(local.cancel.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_rebalance_start_save_failure_retries_persisted_stopped_state() {
|
||||
let active = RebalanceMeta {
|
||||
id: "rebalance-real-save-stopped".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp_dirs, store) = super::test_store_with_persisted_rebalance_meta(active).await;
|
||||
let stopped_at = OffsetDateTime::from_unix_timestamp(1_000).expect("test timestamp should be valid");
|
||||
{
|
||||
let mut local = store.rebalance_meta.write().await;
|
||||
let local = local.as_mut().expect("local rebalance metadata should remain present");
|
||||
local.stopped_at = Some(stopped_at);
|
||||
local.pool_stats[0].info.status = RebalStatus::Stopped;
|
||||
local.pool_stats[0].info.end_time = Some(stopped_at);
|
||||
}
|
||||
|
||||
fail_next_rebalance_activation_save_for_test("rebalance-real-save-stopped");
|
||||
let err = store
|
||||
.start_rebalance_under_gate()
|
||||
.await
|
||||
.expect_err("the injected first stopped-state save must fail through the real start path");
|
||||
assert!(err.to_string().contains("injected rebalance activation save failure"));
|
||||
let mut after_failure = RebalanceMeta::new();
|
||||
after_failure
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("active metadata should remain readable after the failed save");
|
||||
assert_eq!(after_failure.pool_stats[0].info.status, RebalStatus::Started);
|
||||
{
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("local rebalance metadata should remain present");
|
||||
assert_eq!(local.pool_stats[0].info.status, RebalStatus::Stopped);
|
||||
assert!(local.cancel.is_none(), "failed persistence must not publish a worker token");
|
||||
}
|
||||
|
||||
store
|
||||
.start_rebalance_under_gate()
|
||||
.await
|
||||
.expect("the real start path must retry and persist the stopped candidate");
|
||||
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
persisted
|
||||
.load(store.pools[0].clone())
|
||||
.await
|
||||
.expect("retry-persisted stopped metadata should be readable");
|
||||
assert_eq!(persisted.stopped_at, Some(stopped_at));
|
||||
assert_eq!(persisted.pool_stats[0].info.status, RebalStatus::Stopped);
|
||||
let local = store.rebalance_meta.read().await;
|
||||
let local = local.as_ref().expect("local rebalance metadata should remain present");
|
||||
assert_eq!(local.pool_stats[0].info.status, RebalStatus::Stopped);
|
||||
assert!(local.cancel.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_old_worker_cannot_mutate_replacement_rebalance_state() {
|
||||
let meta = RebalanceMeta {
|
||||
id: "rebalance-b".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
buckets: vec!["bucket-a".to_string()],
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let store = test_store_with_rebalance_meta(meta);
|
||||
let fi = FileInfo {
|
||||
size: 128,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for err in [
|
||||
store
|
||||
.next_rebal_bucket(0, "rebalance-a")
|
||||
.await
|
||||
.expect_err("old worker must not read replacement work"),
|
||||
store
|
||||
.bucket_rebalance_done(0, "bucket-a".to_string(), "rebalance-a")
|
||||
.await
|
||||
.expect_err("old worker must not complete replacement bucket"),
|
||||
store
|
||||
.update_pool_stats_batch_for_rebalance(0, "bucket-a".to_string(), &[&fi], "rebalance-a")
|
||||
.await
|
||||
.expect_err("old worker must not update replacement stats"),
|
||||
store
|
||||
.check_if_rebalance_done(0, "rebalance-a")
|
||||
.await
|
||||
.expect_err("old worker must not complete replacement pool"),
|
||||
store
|
||||
.save_rebalance_stats_for_id(0, RebalSaveOpt::Stats, "rebalance-a")
|
||||
.await
|
||||
.expect_err("old save task must not persist replacement metadata"),
|
||||
store
|
||||
.save_rebalance_stats_for_id(usize::MAX, RebalSaveOpt::StoppedAt, "rebalance-a")
|
||||
.await
|
||||
.expect_err("old stop path must not persist replacement metadata"),
|
||||
] {
|
||||
assert!(err.to_string().contains("stale rebalance worker rejected"));
|
||||
}
|
||||
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().expect("replacement metadata should remain present");
|
||||
assert_eq!(meta.id, "rebalance-b");
|
||||
assert!(meta.pool_stats[0].rebalanced_buckets.is_empty());
|
||||
assert_eq!(meta.pool_stats[0].bytes, 0);
|
||||
assert_eq!(meta.pool_stats[0].info.status, RebalStatus::Started);
|
||||
assert!(meta.stopped_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rebalance_metadata_reload_under_start_gate_does_not_reacquire_gate() {
|
||||
let store = test_store_with_rebalance_meta(RebalanceMeta::default());
|
||||
let _start_guard = store.start_gate.lock().await;
|
||||
|
||||
let err = store
|
||||
.load_rebalance_meta_under_start_gate()
|
||||
.await
|
||||
.expect_err("empty test store should reach the metadata load without waiting on start_gate again");
|
||||
|
||||
assert!(err.to_string().contains("no pools available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stale_stop_propagation_cannot_mutate_replacement_rebalance() {
|
||||
let meta = RebalanceMeta {
|
||||
id: "rebalance-b".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let store = test_store_with_rebalance_meta(meta);
|
||||
let record = RebalanceStopPropagationRecord {
|
||||
stop_failures: vec!["old rebalance stop failed".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = store
|
||||
.record_rebalance_stop_propagation("rebalance-a", record)
|
||||
.await
|
||||
.expect_err("old propagation failure must not mutate replacement metadata");
|
||||
|
||||
assert!(err.to_string().contains("stale rebalance worker rejected"));
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().expect("replacement metadata should remain present");
|
||||
assert_eq!(meta.id, "rebalance-b");
|
||||
assert!(meta.last_refreshed_at.is_none());
|
||||
assert!(meta.pool_stats[0].info.last_error.is_none());
|
||||
}
|
||||
|
||||
fn test_store_with_rebalance_meta(meta: RebalanceMeta) -> Arc<crate::store::ECStore> {
|
||||
let endpoint_pools: crate::layout::endpoints::EndpointServerPools = Vec::new().into();
|
||||
Arc::new(crate::store::ECStore {
|
||||
@@ -2719,7 +3002,7 @@ fn test_store_with_rebalance_meta(meta: RebalanceMeta) -> Arc<crate::store::ECSt
|
||||
rebalance_meta: tokio::sync::RwLock::new(Some(meta)),
|
||||
decommission_cancelers: tokio::sync::RwLock::new(Vec::new()),
|
||||
start_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::new(()),
|
||||
pool_meta_save_gate: tokio::sync::Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::control::RebalanceWorkerActivationFence;
|
||||
use super::meta::{
|
||||
apply_rebalance_save_option, apply_rebalance_terminal_event, classify_rebalance_terminal_event, clone_first_arc,
|
||||
complete_rebalance_pools_at_goal, complete_rebalance_pools_with_empty_queue, ensure_valid_rebalance_pool_index,
|
||||
@@ -39,18 +40,103 @@ pub(super) fn source_cleanup_defer_attempt(deferred_attempts: &mut HashMap<Strin
|
||||
*attempts
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum RebalanceLocalActivationOutcome {
|
||||
Started,
|
||||
NotStartedTerminal,
|
||||
}
|
||||
|
||||
pub(super) fn commit_local_rebalance_worker_activation(
|
||||
meta: &mut super::RebalanceMeta,
|
||||
expected_id: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<RebalanceLocalActivationOutcome> {
|
||||
if meta.id != expected_id {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance metadata changed before local worker activation: expected {expected_id}, found {}",
|
||||
meta.id
|
||||
)));
|
||||
}
|
||||
if meta.stopped_at.is_some() || !is_rebalance_in_progress(meta) {
|
||||
return Ok(RebalanceLocalActivationOutcome::NotStartedTerminal);
|
||||
}
|
||||
meta.cancel = Some(cancel);
|
||||
Ok(RebalanceLocalActivationOutcome::Started)
|
||||
}
|
||||
|
||||
pub(super) fn stage_local_rebalance_worker_activation(
|
||||
meta: &super::RebalanceMeta,
|
||||
expected_id: &str,
|
||||
cancel: CancellationToken,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(super::RebalanceMeta, RebalanceLocalActivationOutcome, bool)> {
|
||||
let mut candidate = meta.clone();
|
||||
let completed_at_goal = complete_rebalance_pools_at_goal(&mut candidate, now);
|
||||
let completed_empty_queue = complete_rebalance_pools_with_empty_queue(&mut candidate, now);
|
||||
let outcome = commit_local_rebalance_worker_activation(&mut candidate, expected_id, cancel)?;
|
||||
let must_persist =
|
||||
completed_at_goal || completed_empty_queue || outcome == RebalanceLocalActivationOutcome::NotStartedTerminal;
|
||||
Ok((candidate, outcome, must_persist))
|
||||
}
|
||||
|
||||
pub(super) fn commit_local_rebalance_worker_activation_candidate(
|
||||
current: &mut super::RebalanceMeta,
|
||||
expected_id: &str,
|
||||
expected_cancel: Option<&CancellationToken>,
|
||||
candidate: super::RebalanceMeta,
|
||||
) -> Result<()> {
|
||||
if current.id != expected_id || candidate.id != expected_id {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance metadata changed before local worker activation commit: expected {expected_id}, found {}",
|
||||
current.id
|
||||
)));
|
||||
}
|
||||
if !Arc::ptr_eq(¤t.activation_gate, &candidate.activation_gate) {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance activation gate changed before local worker activation commit: {expected_id}"
|
||||
)));
|
||||
}
|
||||
if current.cancel.as_ref() != expected_cancel {
|
||||
return Err(Error::other(format!(
|
||||
"rebalance worker token changed before local worker activation commit: {expected_id}"
|
||||
)));
|
||||
}
|
||||
*current = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn rollback_local_rebalance_worker_activation(
|
||||
meta: Option<&mut super::RebalanceMeta>,
|
||||
expected_id: &str,
|
||||
activation_token: &CancellationToken,
|
||||
) -> bool {
|
||||
let Some(meta) = meta else {
|
||||
return false;
|
||||
};
|
||||
if meta.id != expected_id || meta.cancel.as_ref() != Some(activation_token) {
|
||||
return false;
|
||||
}
|
||||
if let Some(cancel) = meta.cancel.take() {
|
||||
cancel.cancel();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn start_rebalance(self: &Arc<Self>) -> Result<()> {
|
||||
let _start_guard = self.start_gate.lock().await;
|
||||
let _activation_guard = self.rebalance_activation_write_guard(None, "start rebalance").await?;
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
if self.start_rebalance_inner().await? {
|
||||
if self.start_rebalance_under_gate().await? {
|
||||
self.ctx.advance_data_movement_operation_epoch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn start_rebalance_inner(self: &Arc<Self>) -> Result<bool> {
|
||||
pub(super) async fn start_rebalance_under_gate(self: &Arc<Self>) -> Result<bool> {
|
||||
info!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -58,13 +144,27 @@ impl ECStore {
|
||||
state = "starting",
|
||||
"Starting rebalance"
|
||||
);
|
||||
let expected_id: Arc<str> = {
|
||||
let rebalance_meta = self.rebalance_meta.read().await;
|
||||
Arc::from(rebalance_meta.as_ref().ok_or(Error::ConfigNotFound)?.id.as_str())
|
||||
};
|
||||
let pool = clone_first_arc(self.pools.as_slice(), "start_rebalance: no pools available")?;
|
||||
let activation_fence = match self
|
||||
.fence_rebalance_worker_activation(pool.clone(), expected_id.as_ref())
|
||||
.await?
|
||||
{
|
||||
RebalanceWorkerActivationFence::Ready(fence) => fence,
|
||||
RebalanceWorkerActivationFence::NotStartedTerminal => return Ok(false),
|
||||
};
|
||||
|
||||
let decommission_running = self.is_decommission_running().await;
|
||||
// let rebalance_meta = self.rebalance_meta.read().await;
|
||||
|
||||
let cancel_tx = CancellationToken::new();
|
||||
let rx = cancel_tx.clone();
|
||||
let mut meta_to_save = None;
|
||||
let mut movement_changed = false;
|
||||
let activation_outcome;
|
||||
let candidate;
|
||||
let expected_cancel;
|
||||
let must_persist;
|
||||
|
||||
{
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
@@ -84,27 +184,70 @@ impl ECStore {
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
if complete_rebalance_pools_at_goal(meta, now) {
|
||||
meta_to_save = Some(meta.clone());
|
||||
movement_changed = true;
|
||||
expected_cancel = meta.cancel.clone();
|
||||
(candidate, activation_outcome, must_persist) = stage_local_rebalance_worker_activation(
|
||||
meta,
|
||||
expected_id.as_ref(),
|
||||
cancel_tx.clone(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)?;
|
||||
if let Err(err) = activation_fence.ensure_held() {
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
if complete_rebalance_pools_with_empty_queue(meta, now) {
|
||||
meta_to_save = Some(meta.clone());
|
||||
movement_changed = true;
|
||||
if !must_persist
|
||||
&& let Err(err) = commit_local_rebalance_worker_activation_candidate(
|
||||
meta,
|
||||
expected_id.as_ref(),
|
||||
expected_cancel.as_ref(),
|
||||
candidate.clone(),
|
||||
)
|
||||
{
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
meta.cancel = Some(cancel_tx);
|
||||
|
||||
drop(rebalance_meta);
|
||||
}
|
||||
|
||||
if let Some(meta) = meta_to_save {
|
||||
let pool = clone_first_arc(self.pools.as_slice(), "start_rebalance: no pools available")?;
|
||||
resolve_rebalance_meta_save_result(
|
||||
self.save_rebalance_meta_with_merge(pool, &meta, "start_rebalance complete pools at goal")
|
||||
.await,
|
||||
"start_rebalance complete pools at goal",
|
||||
)?;
|
||||
if must_persist {
|
||||
let save_result = resolve_rebalance_meta_save_result(
|
||||
self.save_rebalance_meta_under_activation_fence(
|
||||
pool,
|
||||
&candidate,
|
||||
"start_rebalance persist activation candidate",
|
||||
activation_fence.as_ref(),
|
||||
expected_id.as_ref(),
|
||||
)
|
||||
.await,
|
||||
"start_rebalance persist activation candidate",
|
||||
);
|
||||
if let Err(err) = save_result {
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
let Some(meta) = rebalance_meta.as_mut() else {
|
||||
cancel_tx.cancel();
|
||||
return Err(Error::ConfigNotFound);
|
||||
};
|
||||
if let Err(err) = commit_local_rebalance_worker_activation_candidate(
|
||||
meta,
|
||||
expected_id.as_ref(),
|
||||
expected_cancel.as_ref(),
|
||||
candidate,
|
||||
) {
|
||||
cancel_tx.cancel();
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
if !must_persist && let Err(err) = activation_fence.ensure_held() {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
rollback_local_rebalance_worker_activation(rebalance_meta.as_mut(), expected_id.as_ref(), &rx);
|
||||
return Err(err);
|
||||
}
|
||||
drop(activation_fence);
|
||||
|
||||
if activation_outcome != RebalanceLocalActivationOutcome::Started {
|
||||
return Ok(must_persist);
|
||||
}
|
||||
|
||||
let participants = if let Some(ref meta) = *self.rebalance_meta.read().await {
|
||||
@@ -122,6 +265,8 @@ impl ECStore {
|
||||
};
|
||||
|
||||
if !participants.iter().any(|participating| *participating) {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
rollback_local_rebalance_worker_activation(rebalance_meta.as_mut(), expected_id.as_ref(), &rx);
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -130,9 +275,14 @@ impl ECStore {
|
||||
reason = "no_participants",
|
||||
"Skipped rebalance start because no pools are participating"
|
||||
);
|
||||
return Ok(movement_changed);
|
||||
return Ok(must_persist);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let endpoints = self.instance_endpoints().unwrap_or_else(|| self.endpoints());
|
||||
#[cfg(not(test))]
|
||||
let endpoints = self.endpoints();
|
||||
|
||||
let mut workers_started = 0usize;
|
||||
for (idx, participating) in participants.iter().enumerate() {
|
||||
if !*participating {
|
||||
@@ -148,7 +298,7 @@ impl ECStore {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !runtime_sources::endpoint_pool_is_local(idx) {
|
||||
if !runtime_sources::endpoint_pool_is_local(&endpoints, idx) {
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -164,9 +314,10 @@ impl ECStore {
|
||||
let pool_idx = idx;
|
||||
let store = self.clone();
|
||||
let rx_clone = rx.clone();
|
||||
let worker_id = Arc::clone(&expected_id);
|
||||
workers_started += 1;
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = store.rebalance_buckets(rx_clone, pool_idx).await {
|
||||
if let Err(err) = store.rebalance_buckets(rx_clone, pool_idx, worker_id).await {
|
||||
error!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -190,6 +341,8 @@ impl ECStore {
|
||||
}
|
||||
|
||||
if workers_started == 0 {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
rollback_local_rebalance_worker_activation(rebalance_meta.as_mut(), expected_id.as_ref(), &rx);
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -198,7 +351,7 @@ impl ECStore {
|
||||
reason = "no_local_participants",
|
||||
"Skipped rebalance start because no local pools are participating"
|
||||
);
|
||||
return Ok(movement_changed);
|
||||
return Ok(must_persist);
|
||||
}
|
||||
|
||||
info!(
|
||||
@@ -213,13 +366,14 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, rx))]
|
||||
async fn rebalance_buckets(self: &Arc<Self>, rx: CancellationToken, pool_index: usize) -> Result<()> {
|
||||
async fn rebalance_buckets(self: &Arc<Self>, rx: CancellationToken, pool_index: usize, rebalance_id: Arc<str>) -> Result<()> {
|
||||
ensure_valid_rebalance_pool_index(self.pools.len(), pool_index)?;
|
||||
|
||||
let (done_tx, mut done_rx) = tokio::sync::mpsc::channel::<Result<()>>(1);
|
||||
|
||||
// Save rebalance metadata periodically
|
||||
let store = self.clone();
|
||||
let save_rebalance_id = Arc::clone(&rebalance_id);
|
||||
let save_task = tokio::spawn(async move {
|
||||
let mut timer = tokio::time::interval_at(Instant::now() + Duration::from_secs(30), Duration::from_secs(10));
|
||||
let mut msg: String;
|
||||
@@ -238,6 +392,11 @@ impl ECStore {
|
||||
let previous_meta = store.rebalance_meta.read().await.clone();
|
||||
let terminal_state_present = {
|
||||
let mut rebalance_meta = store.rebalance_meta.write().await;
|
||||
super::control::ensure_rebalance_run_id(
|
||||
rebalance_meta.as_ref(),
|
||||
save_rebalance_id.as_ref(),
|
||||
"apply rebalance terminal event",
|
||||
)?;
|
||||
if let Some(meta) = rebalance_meta.as_mut() {
|
||||
let meta_stopped = meta.stopped_at.is_some();
|
||||
if let Some(pool_stat) = meta.pool_stats.get_mut(pool_index) {
|
||||
@@ -287,7 +446,14 @@ impl ECStore {
|
||||
};
|
||||
|
||||
if terminal_state_present {
|
||||
if let Err(err) = store.save_rebalance_stats_inner(pool_index, RebalSaveOpt::Stats).await {
|
||||
if let Err(err) = store
|
||||
.save_rebalance_stats_inner(
|
||||
pool_index,
|
||||
RebalSaveOpt::Stats,
|
||||
Some(save_rebalance_id.as_ref()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let mut rebalance_meta = store.rebalance_meta.write().await;
|
||||
*rebalance_meta = previous_meta;
|
||||
drop(movement_guard);
|
||||
@@ -305,7 +471,11 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
if !terminal_state_saved && let Err(err) = store.save_rebalance_stats(pool_index, RebalSaveOpt::Stats).await {
|
||||
if !terminal_state_saved
|
||||
&& let Err(err) = store
|
||||
.save_rebalance_stats_for_id(pool_index, RebalSaveOpt::Stats, save_rebalance_id.as_ref())
|
||||
.await
|
||||
{
|
||||
let wrapped = Error::other(format!("rebalance save_task stats save failed for pool {pool_index}: {err}"));
|
||||
error!("{} err: {:?}", msg, wrapped);
|
||||
if quit {
|
||||
@@ -371,7 +541,7 @@ impl ECStore {
|
||||
break;
|
||||
}
|
||||
|
||||
let next_bucket = match self.next_rebal_bucket(pool_index).await {
|
||||
let next_bucket = match self.next_rebal_bucket(pool_index, rebalance_id.as_ref()).await {
|
||||
Ok(bucket) => bucket,
|
||||
Err(err) => {
|
||||
error!(
|
||||
@@ -403,7 +573,8 @@ impl ECStore {
|
||||
);
|
||||
|
||||
let outcome = match resolve_rebalance_bucket_result(
|
||||
self.rebalance_bucket(rx.clone(), bucket.clone(), pool_index).await,
|
||||
self.rebalance_bucket(rx.clone(), bucket.clone(), pool_index, Arc::clone(&rebalance_id))
|
||||
.await,
|
||||
pool_index,
|
||||
&bucket,
|
||||
) {
|
||||
@@ -466,7 +637,7 @@ impl ECStore {
|
||||
"Deferred rebalance bucket after transient object failures"
|
||||
);
|
||||
if let Err(err) = self
|
||||
.defer_rebalance_bucket(pool_index, bucket.clone(), last_error.clone())
|
||||
.defer_rebalance_bucket(pool_index, bucket.clone(), last_error.clone(), rebalance_id.as_ref())
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
@@ -530,7 +701,7 @@ impl ECStore {
|
||||
"Completed rebalance bucket"
|
||||
);
|
||||
source_cleanup_deferred_attempts.remove(&bucket);
|
||||
if let Err(err) = self.bucket_rebalance_done(pool_index, bucket).await {
|
||||
if let Err(err) = self.bucket_rebalance_done(pool_index, bucket, rebalance_id.as_ref()).await {
|
||||
error!(
|
||||
event = EVENT_REBALANCE_BUCKET,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -591,8 +762,9 @@ impl ECStore {
|
||||
final_result
|
||||
}
|
||||
|
||||
pub(super) async fn check_if_rebalance_done(&self, pool_index: usize) -> bool {
|
||||
pub(super) async fn check_if_rebalance_done(&self, pool_index: usize, expected_id: &str) -> Result<bool> {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
super::control::ensure_rebalance_worker_active(rebalance_meta.as_ref(), expected_id, "check rebalance completion")?;
|
||||
|
||||
if let Some(meta) = rebalance_meta.as_mut()
|
||||
&& let Some(pool_stat) = meta.pool_stats.get_mut(pool_index)
|
||||
@@ -607,7 +779,7 @@ impl ECStore {
|
||||
state = "already_completed",
|
||||
"Rebalance pool is already completed"
|
||||
);
|
||||
return true;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Mark pool rebalance as done only after it reaches the PercentFreeGoal.
|
||||
@@ -635,11 +807,11 @@ impl ECStore {
|
||||
percent_free = pfi,
|
||||
"Rebalance pool reached completion goal"
|
||||
);
|
||||
return true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,12 +820,26 @@ impl ECStore {
|
||||
pub async fn save_rebalance_stats(&self, pool_idx: usize, opt: RebalSaveOpt) -> Result<()> {
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
self.save_rebalance_stats_inner(pool_idx, opt).await
|
||||
self.save_rebalance_stats_inner(pool_idx, opt, None).await
|
||||
}
|
||||
|
||||
pub(super) async fn save_rebalance_stats_inner(&self, pool_idx: usize, opt: RebalSaveOpt) -> Result<()> {
|
||||
pub async fn save_rebalance_stats_for_id(&self, pool_idx: usize, opt: RebalSaveOpt, expected_id: &str) -> Result<()> {
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
self.save_rebalance_stats_inner(pool_idx, opt, Some(expected_id)).await
|
||||
}
|
||||
|
||||
pub(super) async fn save_rebalance_stats_inner(
|
||||
&self,
|
||||
pool_idx: usize,
|
||||
opt: RebalSaveOpt,
|
||||
expected_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let meta_to_save = {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
if let Some(expected_id) = expected_id {
|
||||
super::control::ensure_rebalance_run_id(rebalance_meta.as_ref(), expected_id, "save rebalance stats")?;
|
||||
}
|
||||
let Some(meta) = rebalance_meta.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -675,10 +861,14 @@ impl ECStore {
|
||||
"Rebalance metadata save requested"
|
||||
);
|
||||
let stage = format!("save_rebalance_stats for pool {pool_idx} opt {opt:?}");
|
||||
resolve_rebalance_meta_save_result(
|
||||
self.save_rebalance_meta_with_merge(pool, &meta_to_save, stage.as_str()).await,
|
||||
stage.as_str(),
|
||||
)?;
|
||||
let save_result = match expected_id {
|
||||
Some(expected_id) => {
|
||||
self.save_rebalance_meta_for_id_with_merge(pool, &meta_to_save, stage.as_str(), expected_id)
|
||||
.await
|
||||
}
|
||||
None => self.save_rebalance_meta_with_merge(pool, &meta_to_save, stage.as_str()).await,
|
||||
};
|
||||
resolve_rebalance_meta_save_result(save_result, stage.as_str())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -144,6 +144,8 @@ pub struct RebalanceMeta {
|
||||
#[serde(skip)]
|
||||
pub cancel: Option<CancellationToken>, // To be invoked on rebalance-stop
|
||||
#[serde(skip)]
|
||||
pub activation_gate: std::sync::Arc<tokio::sync::RwLock<()>>,
|
||||
#[serde(skip)]
|
||||
pub last_refreshed_at: Option<OffsetDateTime>,
|
||||
#[serde(rename = "stopTs")]
|
||||
pub stopped_at: Option<OffsetDateTime>, // Time when rebalance-stop was issued
|
||||
|
||||
@@ -100,17 +100,17 @@ pub(super) fn resolve_rebalance_meta_save_result(result: Result<()>, stage: &str
|
||||
result.map_err(|err| Error::other(format!("rebalance meta save failed during {stage}: {err}")))
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_meta_lock_error(err: rustfs_lock::LockError) -> Error {
|
||||
pub(super) fn rebalance_meta_lock_error(err: rustfs_lock::LockError, mode: &'static str) -> Error {
|
||||
match err {
|
||||
rustfs_lock::LockError::QuorumNotReached { required, achieved } => Error::NamespaceLockQuorumUnavailable {
|
||||
mode: "write",
|
||||
mode,
|
||||
bucket: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
||||
object: REBAL_META_NAME.to_string(),
|
||||
required,
|
||||
achieved,
|
||||
},
|
||||
other => Error::other(format!(
|
||||
"failed to acquire rebalance metadata write lock on {}/{}: {other}",
|
||||
"failed to acquire rebalance metadata {mode} lock on {}/{}: {other}",
|
||||
crate::disk::RUSTFS_META_BUCKET,
|
||||
REBAL_META_NAME
|
||||
)),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -228,6 +228,69 @@ pub(super) fn restore_commit_operation_id_from_metadata(metadata: &HashMap<Strin
|
||||
restore_operation_id_from_metadata(metadata)
|
||||
}
|
||||
|
||||
async fn inspect_decommission_tier_free_version_target(
|
||||
disk: &DiskStore,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
source: &FileInfo,
|
||||
) -> Result<bool> {
|
||||
let raw = match disk.read_xl(bucket, object, false).await {
|
||||
Ok(raw) => raw,
|
||||
Err(DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::VolumeNotFound) => return Ok(false),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let meta = FileMeta::load(&raw.buf)?;
|
||||
let source_version_id = source.version_id.filter(|version_id| !version_id.is_nil());
|
||||
let mut matching_count = 0;
|
||||
let mut all_matching_versions_equivalent = true;
|
||||
for existing in meta
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| version.header.version_id.filter(|version_id| !version_id.is_nil()) == source_version_id)
|
||||
{
|
||||
matching_count += 1;
|
||||
let existing = existing.into_fileinfo(bucket, object, true)?;
|
||||
existing.validate_for_metadata_read()?;
|
||||
if !existing.tier_free_version() || !crate::store::tiered_data_movement_source_matches(source, &existing)? {
|
||||
all_matching_versions_equivalent = false;
|
||||
}
|
||||
}
|
||||
if matching_count == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if matching_count == 1 && all_matching_versions_equivalent {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
source_version_id.map(|version_id| version_id.to_string()).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_decommission_tier_free_version_commit_fence(bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
|
||||
if opts
|
||||
.namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
|| opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "decommission_tier_free_version_commit",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
pub(super) async fn require_current_restore_operation_id(
|
||||
&self,
|
||||
@@ -557,8 +620,9 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an ad-hoc, deduplicated dirty scope from `disks`. Used by the heal
|
||||
/// path where disks are not in physical-slot order (backlog#1315).
|
||||
/// Build an ad-hoc, deduplicated dirty scope from `disks`. Used where the
|
||||
/// per-set generation fast path is intentionally bypassed: heal rewrites and
|
||||
/// an early-ACK tail whose first mark was drained before it completed.
|
||||
fn capacity_scope_from_disks(disks: &[Option<DiskStore>]) -> CapacityScope {
|
||||
let mut unique = HashSet::with_capacity(disks.len());
|
||||
let mut scoped_disks = Vec::with_capacity(disks.len());
|
||||
@@ -736,6 +800,9 @@ pub(crate) use core::io_primitives::disk_call_counters;
|
||||
mod ctx;
|
||||
mod metadata;
|
||||
mod ops;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::hermetic_set_disks_isolated;
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::multipart::NewMultipartUploadCommitObservation;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
@@ -1450,6 +1517,21 @@ mod prepared_get_object_metadata_tests {
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
#[cfg(test)]
|
||||
async fn pause_tiered_metadata_commit(bucket: &str, object: &str) {
|
||||
let barrier = TIERED_METADATA_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("tiered metadata commit barrier should not be poisoned")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.bucket == bucket && barrier.object == object)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_get_object_metadata(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -3028,6 +3110,9 @@ pub struct SetDisks {
|
||||
capacity_dirty_generation: Arc<AtomicU64>,
|
||||
#[cfg(test)]
|
||||
storage_class_config_override: Arc<std::sync::RwLock<Option<Arc<storageclass::Config>>>>,
|
||||
#[cfg(test)]
|
||||
rename_tail_heal_capture:
|
||||
Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<rustfs_common::heal_channel::HealChannelRequest>>>>,
|
||||
}
|
||||
|
||||
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
|
||||
@@ -3303,6 +3388,37 @@ impl DiskHealthEntry {
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
pub(in crate::set_disk) async fn submit_rename_tail_heal(&self, request: rustfs_common::heal_channel::HealChannelRequest) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let capture = self
|
||||
.rename_tail_heal_capture
|
||||
.lock()
|
||||
.expect("rename tail heal capture mutex should not poison")
|
||||
.clone();
|
||||
if let Some(capture) = capture {
|
||||
let _ = capture.send(request);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = rustfs_common::heal_channel::send_heal_request(request).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::set_disk) fn capture_test_rename_tail_heals(
|
||||
&self,
|
||||
) -> tokio::sync::mpsc::UnboundedReceiver<rustfs_common::heal_channel::HealChannelRequest> {
|
||||
let (capture, requests) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut slot = self
|
||||
.rename_tail_heal_capture
|
||||
.lock()
|
||||
.expect("rename tail heal capture mutex should not poison");
|
||||
assert!(slot.is_none(), "only one rename tail heal capture may be installed per set");
|
||||
*slot = Some(capture);
|
||||
requests
|
||||
}
|
||||
|
||||
fn storage_class_config_snapshot(&self) -> Arc<storageclass::Config> {
|
||||
#[cfg(test)]
|
||||
if let Some(config) = self
|
||||
@@ -3606,6 +3722,8 @@ impl SetDisks {
|
||||
capacity_dirty_generation: Arc::new(AtomicU64::new(u64::MAX)),
|
||||
#[cfg(test)]
|
||||
storage_class_config_override: Arc::new(std::sync::RwLock::new(None)),
|
||||
#[cfg(test)]
|
||||
rename_tail_heal_capture: Arc::new(std::sync::Mutex::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4686,6 +4804,94 @@ fn resolve_delete_version_state(opts: &ObjectOptions, goi: &ObjectInfo, version_
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
/// Publish an internal tier free-version record without changing its
|
||||
/// delete-marker shape or remote-tier identity. The caller holds the
|
||||
/// source and target object locks; a write quorum is required before the
|
||||
/// source cleanup may remove the original record.
|
||||
#[tracing::instrument(skip(self, fi, opts))]
|
||||
pub(crate) async fn decommission_tier_free_version(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
fi: &FileInfo,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
if !fi.deleted || !fi.tier_free_version() {
|
||||
return Err(Error::other("decommission tier free-version write requires a free version record"));
|
||||
}
|
||||
ensure_decommission_tier_free_version_commit_fence(bucket, object, opts)?;
|
||||
|
||||
let write_quorum = self.default_write_quorum();
|
||||
if self
|
||||
.count_decommission_tier_free_version_equivalents(bucket, object, fi)
|
||||
.await?
|
||||
>= write_quorum
|
||||
{
|
||||
ensure_decommission_tier_free_version_commit_fence(bucket, object, opts)?;
|
||||
return Ok(());
|
||||
}
|
||||
ensure_decommission_tier_free_version_commit_fence(bucket, object, opts)?;
|
||||
|
||||
let disks = self.disks.read().await.clone();
|
||||
let futures = disks.into_iter().map(|disk| {
|
||||
let file_info = fi.clone();
|
||||
async move {
|
||||
if let Some(disk) = disk {
|
||||
disk.write_metadata("", bucket, object, file_info).await
|
||||
} else {
|
||||
Err(DiskError::DiskNotFound)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut errs = Vec::new();
|
||||
for result in join_all(futures).await {
|
||||
match result {
|
||||
Ok(_) => errs.push(None),
|
||||
Err(err) => errs.push(Some(err)),
|
||||
}
|
||||
}
|
||||
|
||||
ensure_decommission_tier_free_version_commit_fence(bucket, object, opts)?;
|
||||
|
||||
resolve_tiered_decommission_write_quorum_result(&errs, write_quorum, bucket, object)
|
||||
}
|
||||
|
||||
async fn count_decommission_tier_free_version_equivalents(&self, bucket: &str, object: &str, fi: &FileInfo) -> Result<usize> {
|
||||
// The caller holds the source and target object locks. Inspect every
|
||||
// target disk before an idempotent return or metadata fan-out so a
|
||||
// sub-quorum conflict cannot be hidden by a successful quorum.
|
||||
let disks = self.disks.read().await.clone();
|
||||
let preflight = disks.iter().map(|disk| async {
|
||||
match disk {
|
||||
Some(disk) => inspect_decommission_tier_free_version_target(disk, bucket, object, fi).await,
|
||||
None => Ok(false),
|
||||
}
|
||||
});
|
||||
let mut equivalent = 0;
|
||||
for result in join_all(preflight).await {
|
||||
if result? {
|
||||
equivalent += 1;
|
||||
}
|
||||
}
|
||||
Ok(equivalent)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_decommission_tier_free_version_write_quorum(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
fi: &FileInfo,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<bool> {
|
||||
ensure_decommission_tier_free_version_commit_fence(bucket, object, opts)?;
|
||||
let equivalent = self
|
||||
.count_decommission_tier_free_version_equivalents(bucket, object, fi)
|
||||
.await?;
|
||||
ensure_decommission_tier_free_version_commit_fence(bucket, object, opts)?;
|
||||
Ok(equivalent >= self.default_write_quorum())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, fi, opts))]
|
||||
pub(crate) async fn decommission_tiered_object(
|
||||
&self,
|
||||
@@ -4738,6 +4944,8 @@ impl SetDisks {
|
||||
)?;
|
||||
let fi = build_tiered_decommission_file_info(bucket, object, fi, layout);
|
||||
let write_quorum = layout.write_quorum;
|
||||
#[cfg(test)]
|
||||
Self::pause_tiered_metadata_commit(bucket, object).await;
|
||||
if _lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| opts
|
||||
.namespace_lock_fence
|
||||
@@ -4793,6 +5001,66 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct TieredMetadataCommitBarrierState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct TieredMetadataCommitBarrier {
|
||||
state: Arc<TieredMetadataCommitBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static TIERED_METADATA_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<TieredMetadataCommitBarrierState>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl TieredMetadataCommitBarrier {
|
||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
||||
let state = Arc::new(TieredMetadataCommitBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = TIERED_METADATA_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("tiered metadata commit barrier should not be poisoned");
|
||||
assert!(slot.is_none(), "tiered metadata commit barrier must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("tiered metadata write should reach its deterministic commit barrier");
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TieredMetadataCommitBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = TIERED_METADATA_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("tiered metadata commit barrier should not be poisoned");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct ObjProps {
|
||||
successor_mod_time: Option<OffsetDateTime>,
|
||||
@@ -7363,7 +7631,7 @@ mod tests {
|
||||
// the ad-hoc scope the previous per-write construction produced, otherwise
|
||||
// dirty-disk keys diverge from the disk-cache keys and capacity counts drift.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[serial(capacity_dirty_scope)]
|
||||
async fn capacity_scope_memo_matches_adhoc_and_is_reused() {
|
||||
use rustfs_object_capacity::capacity_scope::drain_global_dirty_scopes;
|
||||
|
||||
@@ -7389,7 +7657,7 @@ mod tests {
|
||||
// write of each generation; steady-state writes skip it. Reverting the
|
||||
// generation skip makes the upgrade count grow per write and fails this test.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[serial(capacity_dirty_scope)]
|
||||
async fn record_capacity_scope_upgrades_registry_once_per_generation() {
|
||||
use rustfs_object_capacity::capacity_scope::{drain_global_dirty_scopes, global_dirty_upgrade_count};
|
||||
|
||||
@@ -7438,7 +7706,7 @@ mod tests {
|
||||
// backlog#1315: an offline slot must not force the per-write slow path, and
|
||||
// the resolved scope must still cover every online disk.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[serial(capacity_dirty_scope)]
|
||||
async fn capacity_scope_tolerates_offline_slot_without_reallocating() {
|
||||
use rustfs_object_capacity::capacity_scope::drain_global_dirty_scopes;
|
||||
|
||||
@@ -9916,6 +10184,147 @@ mod tests {
|
||||
assert_ne!(updated.erasure.distribution, original.erasure.distribution);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decommission_tier_free_version_preserves_remote_identity() {
|
||||
let set_disks = make_local_bucket_test_set_disks().await;
|
||||
let bucket = "free-version-decommission";
|
||||
let object = "object.txt";
|
||||
set_disks
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("target bucket should exist before free-version migration");
|
||||
let version_id = Uuid::new_v4();
|
||||
let mut free_version = FileInfo {
|
||||
name: object.to_string(),
|
||||
volume: bucket.to_string(),
|
||||
version_id: Some(version_id),
|
||||
mod_time: Some(time::OffsetDateTime::now_utc()),
|
||||
deleted: true,
|
||||
transition_tier: "WARM-TIER".to_string(),
|
||||
transitioned_objname: "remote/object".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
free_version.set_tier_free_version();
|
||||
// Decoded free versions always carry the on-disk free-version
|
||||
// suffix alongside the in-memory tier marker; mirror that here so
|
||||
// the record satisfies delete-marker metadata validation.
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut free_version.metadata,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_FREE_VERSION,
|
||||
String::new(),
|
||||
);
|
||||
|
||||
set_disks
|
||||
.decommission_tier_free_version(bucket, object, &free_version, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("free-version metadata should reach the target quorum");
|
||||
set_disks
|
||||
.decommission_tier_free_version(bucket, object, &free_version, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("replaying the same free-version metadata should be idempotent");
|
||||
|
||||
let versions = set_disks
|
||||
.load_file_info_versions_exact(bucket, object)
|
||||
.await
|
||||
.expect("migrated free-version metadata should decode")
|
||||
.expect("migrated free-version metadata should exist");
|
||||
let migrated = versions
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.version_id == Some(version_id))
|
||||
.expect("free version should be present on the target");
|
||||
|
||||
assert_eq!(
|
||||
versions
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| version.version_id == Some(version_id))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(migrated.tier_free_version());
|
||||
assert_eq!(migrated.transition_tier, "WARM-TIER");
|
||||
assert_eq!(migrated.transitioned_objname, "remote/object");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decommission_tier_free_version_resume_requires_write_quorum() {
|
||||
let set_disks = make_local_bucket_test_set_disks_with_drive_count(4).await;
|
||||
let bucket = "free-version-decommission-resume";
|
||||
let object = "object.txt";
|
||||
set_disks
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("target bucket should exist before free-version migration");
|
||||
let mut free_version = FileInfo {
|
||||
name: object.to_string(),
|
||||
volume: bucket.to_string(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
mod_time: Some(time::OffsetDateTime::now_utc()),
|
||||
deleted: true,
|
||||
transition_tier: "WARM-TIER".to_string(),
|
||||
transitioned_objname: "remote/object".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
free_version.set_tier_free_version();
|
||||
// Decoded free versions always carry the on-disk free-version
|
||||
// suffix alongside the in-memory tier marker; mirror that here so
|
||||
// the record satisfies delete-marker metadata validation.
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut free_version.metadata,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_FREE_VERSION,
|
||||
String::new(),
|
||||
);
|
||||
let opts = ObjectOptions::default();
|
||||
|
||||
let disks = set_disks.get_disks_internal().await;
|
||||
for disk in disks.iter().take(2).flatten() {
|
||||
disk.write_metadata("", bucket, object, free_version.clone())
|
||||
.await
|
||||
.expect("partial first attempt should leave equivalent metadata");
|
||||
}
|
||||
assert!(
|
||||
!set_disks
|
||||
.has_decommission_tier_free_version_write_quorum(bucket, object, &free_version, &opts)
|
||||
.await
|
||||
.expect("partial target metadata should remain valid"),
|
||||
"write-quorum-minus-one must not be accepted as an idempotent migration"
|
||||
);
|
||||
|
||||
disks[2]
|
||||
.as_ref()
|
||||
.expect("third target disk should be online")
|
||||
.write_metadata("", bucket, object, free_version.clone())
|
||||
.await
|
||||
.expect("third equivalent target write should complete quorum");
|
||||
assert!(
|
||||
set_disks
|
||||
.has_decommission_tier_free_version_write_quorum(bucket, object, &free_version, &opts)
|
||||
.await
|
||||
.expect("write-quorum target metadata should remain valid")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_tier_free_version_commit_rejects_lost_fence() {
|
||||
let opts = ObjectOptions {
|
||||
namespace_lock_fence: Some(NamespaceLockFence::lost_for_test()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = ensure_decommission_tier_free_version_commit_fence("bucket", "object", &opts)
|
||||
.expect_err("lost target lock must fail the free-version commit");
|
||||
assert!(matches!(
|
||||
err,
|
||||
Error::NamespaceLockQuorumUnavailable {
|
||||
mode: "decommission_tier_free_version_commit",
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_tiered_decommission_write_quorum_result_allows_successful_quorum() {
|
||||
let errs = vec![None, None, Some(DiskError::DiskNotFound), None];
|
||||
|
||||
@@ -27,13 +27,19 @@ use super::super::*;
|
||||
impl SetDisks {
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn delete_all(&self, bucket: &str, prefix: &str) -> Result<()> {
|
||||
ListOperations::new(self.ctx()).delete_all(bucket, prefix).await
|
||||
let (result, disks) = ListOperations::new(self.ctx())
|
||||
.delete_all_observed(bucket, prefix, None)
|
||||
.await;
|
||||
self.record_capacity_scope_if_needed(None, &disks);
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_all_with_quorum(&self, bucket: &str, prefix: &str, write_quorum: usize) -> Result<()> {
|
||||
ListOperations::new(self.ctx())
|
||||
.delete_all_with_quorum(bucket, prefix, write_quorum)
|
||||
.await
|
||||
let (result, disks) = ListOperations::new(self.ctx())
|
||||
.delete_all_observed(bucket, prefix, Some(write_quorum))
|
||||
.await;
|
||||
self.record_capacity_scope_if_needed(None, &disks);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,19 +59,24 @@ impl<'a> ListOperations<'a> {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_all(&self, bucket: &str, prefix: &str) -> Result<()> {
|
||||
self.delete_all_inner(bucket, prefix, None).await
|
||||
pub(crate) async fn delete_all_observed(
|
||||
&self,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
write_quorum: Option<usize>,
|
||||
) -> (Result<()>, Vec<Option<DiskStore>>) {
|
||||
let disks = self.ctx.disks().read().await.clone();
|
||||
let result = self.delete_all_inner(bucket, prefix, write_quorum, disks.clone()).await;
|
||||
(result, disks)
|
||||
}
|
||||
|
||||
async fn delete_all_with_quorum(&self, bucket: &str, prefix: &str, write_quorum: usize) -> Result<()> {
|
||||
self.delete_all_inner(bucket, prefix, Some(write_quorum)).await
|
||||
}
|
||||
|
||||
async fn delete_all_inner(&self, bucket: &str, prefix: &str, write_quorum: Option<usize>) -> Result<()> {
|
||||
let disks = self.ctx.disks().read().await;
|
||||
|
||||
let disks = disks.clone();
|
||||
|
||||
async fn delete_all_inner(
|
||||
&self,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
write_quorum: Option<usize>,
|
||||
disks: Vec<Option<DiskStore>>,
|
||||
) -> Result<()> {
|
||||
let mut futures = Vec::with_capacity(disks.len());
|
||||
let mut errors = Vec::with_capacity(disks.len());
|
||||
|
||||
|
||||
@@ -922,10 +922,11 @@ mod tests {
|
||||
|
||||
// The List family runs through the borrow handle with unchanged
|
||||
// behavior: delete_all reports success even when the prefix is absent.
|
||||
ListOperations::new(set_disks.ctx())
|
||||
.delete_all("nonexistent-bucket", "nonexistent-prefix")
|
||||
.await
|
||||
.expect("delete_all via borrow handle should succeed");
|
||||
let (result, observed_disks) = ListOperations::new(set_disks.ctx())
|
||||
.delete_all_observed("nonexistent-bucket", "nonexistent-prefix", None)
|
||||
.await;
|
||||
result.expect("delete_all via borrow handle should succeed");
|
||||
assert_eq!(observed_disks.len(), disk_count);
|
||||
set_disks
|
||||
.delete_all("nonexistent-bucket", "nonexistent-prefix")
|
||||
.await
|
||||
|
||||
@@ -25,3 +25,6 @@ pub(crate) mod list;
|
||||
pub(crate) mod locking;
|
||||
pub(crate) mod multipart;
|
||||
pub(crate) mod object;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use object::hermetic_set_disks_support::hermetic_set_disks_isolated;
|
||||
|
||||
@@ -30,6 +30,7 @@ use super::object::{
|
||||
use crate::bucket::quota::reservation;
|
||||
use crate::crash_inject::{self, CrashPoint};
|
||||
use crate::multipart_listing::paginate_multipart_listing;
|
||||
use crate::set_disk::core::io_primitives::finish_rename_tail_heal;
|
||||
use futures::{StreamExt, stream};
|
||||
use std::future::Future;
|
||||
#[cfg(test)]
|
||||
@@ -86,6 +87,8 @@ struct MultipartCommitBarrierState {
|
||||
pause: MultipartCommitPause,
|
||||
expected_arrivals: usize,
|
||||
arrivals: AtomicUsize,
|
||||
#[cfg(test)]
|
||||
committed: AtomicBool,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Semaphore,
|
||||
}
|
||||
@@ -113,6 +116,8 @@ impl MultipartCommitBarrier {
|
||||
pause,
|
||||
expected_arrivals,
|
||||
arrivals: AtomicUsize::new(0),
|
||||
#[cfg(test)]
|
||||
committed: AtomicBool::new(false),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Semaphore::new(0),
|
||||
});
|
||||
@@ -143,6 +148,11 @@ impl MultipartCommitBarrier {
|
||||
pub fn release(&self) {
|
||||
self.state.release.add_permits(self.state.expected_arrivals);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn commit_observed(&self) -> bool {
|
||||
self.state.committed.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
@@ -263,6 +273,20 @@ async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartComm
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn observe_multipart_commit(bucket: &str, object: &str, pause: MultipartCommitPause) {
|
||||
let slot = MULTIPART_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("multipart commit barrier mutex should not poison");
|
||||
if let Some(barrier) = slot
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && barrier.pause == pause)
|
||||
{
|
||||
barrier.committed.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn map_upload_id_metadata_error(bucket: &str, object: &str, upload_id: &str, err: DiskError) -> Error {
|
||||
if err == DiskError::FileNotFound {
|
||||
return StorageError::InvalidUploadID(bucket.to_owned(), object.to_owned(), upload_id.to_owned());
|
||||
@@ -1337,6 +1361,19 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockLost).await;
|
||||
fence_commit_on_lock_loss(_upload_commit_guard.as_ref(), "put_object_part_commit", &upload_id_path)?;
|
||||
fence_commit_on_lock_loss(_part_commit_guard.as_ref(), "put_object_part_commit", &part_lock_path)?;
|
||||
if opts
|
||||
.namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "put_object_part_outer_lock",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
|
||||
let _ = self
|
||||
@@ -1357,6 +1394,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
#[cfg(test)]
|
||||
observe_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockLost);
|
||||
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartAfterRename).await;
|
||||
@@ -1737,7 +1776,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
.await
|
||||
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
||||
#[cfg(test)]
|
||||
observe_new_multipart_upload_commit(bucket, object);
|
||||
{
|
||||
observe_multipart_commit(bucket, object, MultipartCommitPause::NewUploadBeforeLockLost);
|
||||
observe_new_multipart_upload_commit(bucket, object);
|
||||
}
|
||||
|
||||
// evalDisks
|
||||
|
||||
@@ -2580,7 +2622,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
let commit_is_versioned = opts.versioned || opts.version_suspended;
|
||||
let commit_capacity_scope_token = opts.capacity_scope_token;
|
||||
let commit_object_lock_guard = object_lock_guard.take();
|
||||
let detach_commit_owner = commit_object_lock_guard.is_some() || upload_guard.is_some() || quota_mutation_fence;
|
||||
let commit_allows_early_ack = commit_object_lock_guard.is_some();
|
||||
let detach_commit_owner = commit_allows_early_ack || upload_guard.is_some() || quota_mutation_fence;
|
||||
let commit = async move {
|
||||
let mut _object_lock_guard = commit_object_lock_guard;
|
||||
let mut _upload_guard = upload_guard;
|
||||
@@ -2687,17 +2730,101 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
// (rustfs/backlog#1009): CompleteMultipartUpload keeps its pre-commit
|
||||
// `get_object_info` lookup, so the backfill has no consumer here yet.
|
||||
Self::assign_rename_data_indexes(&mut parts_metadatas);
|
||||
let rename_result = SetDisks::rename_data_owned(
|
||||
let mut rename_result = SetDisks::rename_data_owned(
|
||||
&commit_disks,
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&commit_upload_id_path,
|
||||
(RUSTFS_META_MULTIPART_BUCKET, &commit_upload_id_path),
|
||||
parts_metadatas,
|
||||
&commit_bucket,
|
||||
&commit_object,
|
||||
(&commit_bucket, &commit_object),
|
||||
write_quorum,
|
||||
commit_allows_early_ack,
|
||||
)
|
||||
.await;
|
||||
if quota_mutation_fence {
|
||||
let mut rename_guard_release = None;
|
||||
let mut needs_immediate_heal = false;
|
||||
let mut tail_owns_staging_cleanup = false;
|
||||
if let Ok(rename_commit) = rename_result.as_mut() {
|
||||
commit_set.record_capacity_scope_if_needed(commit_capacity_scope_token, &rename_commit.capacity_disks);
|
||||
// Install the tail watcher before any post-commit await. The
|
||||
// latch keeps namespace guards through their prior handoff point.
|
||||
needs_immediate_heal = rename_commit.needs_immediate_heal();
|
||||
if let Some(rename_tail_drain) = rename_commit.tail_drain.take() {
|
||||
tail_owns_staging_cleanup = true;
|
||||
let mut request = rustfs_common::heal_channel::create_heal_request_with_options(
|
||||
commit_bucket.clone(),
|
||||
Some(commit_object.clone()),
|
||||
false,
|
||||
Some(HealChannelPriority::Normal),
|
||||
Some(commit_set.pool_index),
|
||||
Some(commit_set.set_index),
|
||||
);
|
||||
request.object_version_id = fi
|
||||
.version_id
|
||||
.or_else(|| commit_version_suspended.then(Uuid::nil))
|
||||
.map(|version_id| version_id.to_string());
|
||||
let object_lock_guard = _object_lock_guard.take();
|
||||
let upload_guard = _upload_guard.take();
|
||||
let cleanup_bucket = commit_bucket.clone();
|
||||
let cleanup_object = commit_object.clone();
|
||||
let heal_set = commit_set.clone();
|
||||
let cleanup_set = commit_set.clone();
|
||||
let committed_data_dir = fi.data_dir;
|
||||
let cleanup_parts = parts.clone();
|
||||
let cleanup_upload_path = commit_upload_id_path.clone();
|
||||
let cleanup_upload_id = commit_upload_id.clone();
|
||||
let fence_disks = commit_disks.clone();
|
||||
let fence_tokens = quota_fence_tokens.clone();
|
||||
let fence_bucket = commit_bucket.clone();
|
||||
let fence_object = commit_object.clone();
|
||||
let (guard_release_tx, guard_release_rx) = tokio::sync::oneshot::channel();
|
||||
rename_guard_release = Some(guard_release_tx);
|
||||
tokio::spawn(finish_rename_tail_heal(
|
||||
rename_tail_drain,
|
||||
guard_release_rx,
|
||||
(object_lock_guard, upload_guard),
|
||||
request,
|
||||
move || async move {
|
||||
if quota_mutation_fence {
|
||||
let _ = SetDisks::release_quota_mutation_fences(
|
||||
&fence_disks,
|
||||
&fence_tokens,
|
||||
&fence_bucket,
|
||||
&fence_object,
|
||||
write_quorum,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
},
|
||||
move |(object_lock_guard, upload_guard), targets| async move {
|
||||
drop(object_lock_guard);
|
||||
cleanup_set.cleanup_multipart_path(&cleanup_parts).await;
|
||||
cleanup_set
|
||||
.cleanup_rename_tail(
|
||||
targets,
|
||||
&cleanup_bucket,
|
||||
&cleanup_object,
|
||||
committed_data_dir,
|
||||
transaction_epoch,
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = cleanup_set
|
||||
.delete_all_with_quorum(RUSTFS_META_MULTIPART_BUCKET, &cleanup_upload_path, write_quorum)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
bucket = %cleanup_bucket,
|
||||
object = %cleanup_object,
|
||||
upload_id = %cleanup_upload_id,
|
||||
error = ?err,
|
||||
"completed multipart upload staging cleanup did not reach write quorum"
|
||||
);
|
||||
}
|
||||
drop(upload_guard);
|
||||
},
|
||||
|request| async move { heal_set.submit_rename_tail_heal(request).await },
|
||||
));
|
||||
}
|
||||
}
|
||||
if quota_mutation_fence && !tail_owns_staging_cleanup {
|
||||
let _ = SetDisks::release_quota_mutation_fences(
|
||||
&commit_disks,
|
||||
"a_fence_tokens,
|
||||
@@ -2714,16 +2841,11 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
Ok(result) => result,
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let online_disks = rename_commit.online_disks;
|
||||
let convergence = rename_commit.convergence;
|
||||
let op_old_dir = rename_commit.data_dir;
|
||||
let cleanup_disks = rename_commit.cleanup_disks;
|
||||
let committed_file_info = rename_commit.committed_file_info;
|
||||
let rename_tail_drain = rename_commit.tail_drain;
|
||||
|
||||
// Detach admission before any post-commit await: client cancellation
|
||||
// must not couple durable convergence repair to cleanup work.
|
||||
if convergence.needs_heal() {
|
||||
if needs_immediate_heal {
|
||||
let mut request = rustfs_common::heal_channel::create_heal_request_with_options(
|
||||
commit_bucket.clone(),
|
||||
Some(commit_object.clone()),
|
||||
@@ -2761,14 +2883,15 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
// parts are swept by a retried completion or upload GC (rustfs/backlog#946).
|
||||
// Compiles to a no-op outside `#[cfg(test)]`.
|
||||
if crash_inject::should_crash_at(CrashPoint::MultipartAfterCommitBeforePartsCleanup, &commit_object) {
|
||||
if let Some(release) = rename_guard_release.take() {
|
||||
let _ = release.send(false);
|
||||
}
|
||||
return Err(StorageError::Unexpected);
|
||||
}
|
||||
|
||||
fi = committed_file_info;
|
||||
let committed_dir = fi.data_dir.unwrap_or_default().to_string();
|
||||
|
||||
commit_set.record_capacity_scope_if_needed(commit_capacity_scope_token, &online_disks);
|
||||
|
||||
fi.is_latest = true;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2778,30 +2901,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
.invalidate_get_object_metadata_cache(&commit_bucket, &commit_object)
|
||||
.await;
|
||||
|
||||
if let Some(rename_tail_drain) = rename_tail_drain {
|
||||
let object_lock_guard = _object_lock_guard.take();
|
||||
let upload_guard = _upload_guard.take();
|
||||
let tail_bucket = commit_bucket.clone();
|
||||
let tail_object = commit_object.clone();
|
||||
tokio::spawn(async move {
|
||||
let _object_lock_guard = object_lock_guard;
|
||||
let _upload_guard = upload_guard;
|
||||
if let Err(err) = rename_tail_drain.await {
|
||||
warn!(
|
||||
event = EVENT_SET_DISK_RENAME_TAIL_DRAIN_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_SET_DISK,
|
||||
state = "failed",
|
||||
bucket = %tail_bucket,
|
||||
object = %tail_object,
|
||||
error = %err,
|
||||
"rename tail drain failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
drop(_object_lock_guard.take()); // release the object lock before multipart cleanup tail IO.
|
||||
if let Some(release) = rename_guard_release.take() {
|
||||
let _ = release.send(true);
|
||||
}
|
||||
drop(_object_lock_guard.take()); // release the object lock before multipart cleanup tail IO.
|
||||
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(&commit_bucket, &commit_object, MultipartCommitPause::AfterObjectPublication).await;
|
||||
@@ -2814,14 +2917,16 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
// parts; deleting them before the commit would strand the upload
|
||||
// permanently. This mirrors the "clean up only after commit" pattern
|
||||
// already used for the old data-dir GC and the upload-dir delete_all below.
|
||||
commit_set.cleanup_multipart_path(&parts).await;
|
||||
if !tail_owns_staging_cleanup {
|
||||
commit_set.cleanup_multipart_path(&parts).await;
|
||||
}
|
||||
|
||||
if let Some(old_dir) = op_old_dir {
|
||||
// backlog#898: best-effort reclaim of the dereferenced old data dir.
|
||||
// Returns a receipt (never `Err`); a failed GC must not turn an
|
||||
// already-committed multipart completion into a 503.
|
||||
let cleanup = commit_set
|
||||
.commit_rename_data_dir(
|
||||
.commit_rename_data_dir_and_mark_capacity(
|
||||
&cleanup_disks,
|
||||
&commit_bucket,
|
||||
&commit_object,
|
||||
@@ -2845,9 +2950,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(&commit_bucket, &commit_object, MultipartCommitPause::AfterRename).await;
|
||||
|
||||
if let Err(err) = commit_set
|
||||
.delete_all_with_quorum(RUSTFS_META_MULTIPART_BUCKET, &commit_upload_id_path, write_quorum)
|
||||
.await
|
||||
if !tail_owns_staging_cleanup
|
||||
&& let Err(err) = commit_set
|
||||
.delete_all_with_quorum(RUSTFS_META_MULTIPART_BUCKET, &commit_upload_id_path, write_quorum)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
bucket = %commit_bucket,
|
||||
@@ -2896,6 +3002,7 @@ mod tests {
|
||||
use crate::disk::{endpoint::Endpoint, format::FormatV3};
|
||||
use crate::layout::endpoints::SetupType;
|
||||
use crate::services::notification_sys::install_remote_version_state_fleet_proof_for_test;
|
||||
use crate::set_disk::core::io_primitives::{ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, rename_fanout_barrier};
|
||||
// No-locker helpers resolve to the isolated-context variants (see
|
||||
// `hermetic_set_disks_isolated`); the guard-based tests build through
|
||||
// `hermetic_set_disks_with_lockers`, which stays on the bootstrap context
|
||||
@@ -2903,6 +3010,7 @@ mod tests {
|
||||
use crate::set_disk::ops::object::hermetic_set_disks_support::{
|
||||
hermetic_set_disks_for_pool_with_default_parity_isolated as hermetic_set_disks_for_pool_with_default_parity,
|
||||
hermetic_set_disks_isolated as hermetic_set_disks, hermetic_set_disks_with_lockers,
|
||||
hermetic_set_disks_with_lockers_and_ctx,
|
||||
};
|
||||
use crate::set_disk::ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
@@ -2910,7 +3018,11 @@ mod tests {
|
||||
use rustfs_config::server_config::KVS;
|
||||
use rustfs_lock::{LockClient, client::local::LocalClient};
|
||||
use serial_test::serial;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
|
||||
@@ -3283,6 +3395,203 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(capacity_dirty_scope)]
|
||||
async fn recovered_disk_multipart_cleanup_marks_each_snapshot_it_mutates() {
|
||||
use rustfs_object_capacity::capacity_scope::drain_global_dirty_scopes;
|
||||
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let recovered = disk_stores[0].clone();
|
||||
let part_path = "recovered-multipart/part.1.meta";
|
||||
recovered
|
||||
.write_all(RUSTFS_META_MULTIPART_BUCKET, part_path, Bytes::from_static(b"stale part metadata"))
|
||||
.await
|
||||
.expect("recovered disk should contain staged part metadata");
|
||||
|
||||
set_disks.disks.write().await[0] = None;
|
||||
let stale_snapshot = set_disks.get_disks_internal().await;
|
||||
assert!(stale_snapshot[0].is_none(), "the pre-cleanup snapshot must exclude the disk");
|
||||
set_disks.disks.write().await[0] = Some(recovered.clone());
|
||||
let expected = capacity_scope_from_disks(&[Some(recovered.clone())])
|
||||
.disks
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
let _ = drain_global_dirty_scopes();
|
||||
|
||||
set_disks.cleanup_multipart_path(&[part_path.to_string()]).await;
|
||||
assert!(matches!(
|
||||
recovered.read_all(RUSTFS_META_MULTIPART_BUCKET, part_path).await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
let part_marked = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||
assert!(expected.is_subset(&part_marked), "part cleanup must mark the recovered disk it mutated");
|
||||
|
||||
let upload_path = "recovered-multipart/upload/part.1";
|
||||
recovered
|
||||
.write_all(RUSTFS_META_MULTIPART_BUCKET, upload_path, Bytes::from_static(b"stale upload shard"))
|
||||
.await
|
||||
.expect("recovered disk should contain staged upload data");
|
||||
set_disks
|
||||
.delete_all_with_quorum(RUSTFS_META_MULTIPART_BUCKET, "recovered-multipart/upload", 3)
|
||||
.await
|
||||
.expect("upload cleanup should use the recovered disk");
|
||||
assert!(matches!(
|
||||
recovered.read_all(RUSTFS_META_MULTIPART_BUCKET, upload_path).await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
let upload_marked = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||
assert!(
|
||||
expected.is_subset(&upload_marked),
|
||||
"upload cleanup must mark the recovered disk it actually mutated"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(capacity_dirty_scope)]
|
||||
async fn early_ack_multipart_holds_quota_fences_and_re_marks_capacity_after_tail_drain() {
|
||||
use rustfs_object_capacity::capacity_scope::drain_global_dirty_scopes;
|
||||
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
|
||||
let signaling = Arc::new(SignalingLockClient::new(Arc::new(LocalClient::with_manager(manager))));
|
||||
let lockers: Vec<Arc<dyn LockClient>> = vec![signaling.clone()];
|
||||
let instance_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
instance_ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
let (_temp_dirs, disk_stores, set_disks) =
|
||||
hermetic_set_disks_with_lockers_and_ctx(4, 0, 2, lockers, instance_ctx).await;
|
||||
let bucket = "multipart-early-ack-capacity-scope";
|
||||
let object = "multipart-early-ack-capacity-scope-object";
|
||||
make_bucket_on_all(&disk_stores, bucket).await;
|
||||
let mut initial = PutObjReader::from_vec(vec![b'0'; 1 << 20]);
|
||||
set_disks
|
||||
.put_object(bucket, object, &mut initial, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("initial object should commit before the multipart overwrite");
|
||||
drop(
|
||||
set_disks
|
||||
.acquire_write_lock_diag("multipart_capacity_fixture_ready", bucket, object)
|
||||
.await
|
||||
.expect("initial object tail should drain before the multipart overwrite"),
|
||||
);
|
||||
let multipart_body = vec![b'1'; 1 << 20];
|
||||
let (upload_id, parts) =
|
||||
stage_upload_with_create_opts(&set_disks, bucket, object, &multipart_body, &ObjectOptions::default()).await;
|
||||
let (staged, _) = set_disks
|
||||
.check_upload_id_exists(bucket, object, &upload_id, true)
|
||||
.await
|
||||
.expect("staged upload metadata should be readable");
|
||||
let competing_upload_id = upload_id.clone();
|
||||
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
|
||||
let staged_part = format!(
|
||||
"{upload_id_path}/{}/part.1",
|
||||
staged.data_dir.expect("staged multipart data dir should exist")
|
||||
);
|
||||
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path.clone()));
|
||||
let candidate_disks = disk_stores.iter().cloned().map(Some).collect::<Vec<_>>();
|
||||
let expected = capacity_scope_from_disks(&candidate_disks)
|
||||
.disks
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
let _ = drain_global_dirty_scopes();
|
||||
|
||||
let rename_tasks = rename_fanout_barrier::observe_tasks(object);
|
||||
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||
let complete_store = Arc::clone(&set_disks);
|
||||
let complete = tokio::spawn(async move {
|
||||
let mut opts = ObjectOptions::default();
|
||||
assert!(opts.set_quota_admission(0, u64::MAX));
|
||||
complete_store
|
||||
.complete_multipart_upload(bucket, object, &upload_id, parts, &opts)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(30), rename_barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("multipart completion should pause one tail disk during rename");
|
||||
let cleanup_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_CLEANUP);
|
||||
complete
|
||||
.await
|
||||
.expect("early-ACK multipart task should join before tail release")
|
||||
.expect("multipart completion should return after write quorum");
|
||||
assert!(
|
||||
rename_tasks.running() >= 1,
|
||||
"the paused multipart tail disk must remain in flight after quorum ACK"
|
||||
);
|
||||
|
||||
let initial = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||
assert!(
|
||||
expected.is_subset(&initial),
|
||||
"the multipart quorum ACK must mark every candidate disk dirty"
|
||||
);
|
||||
|
||||
let abort_store = Arc::clone(&set_disks);
|
||||
let abort = tokio::spawn(async move {
|
||||
abort_store
|
||||
.abort_multipart_upload(bucket, object, &competing_upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
});
|
||||
signaling.wait_for_attempts(2).await;
|
||||
assert!(!abort.is_finished(), "the detached tail owner must retain the multipart upload guard");
|
||||
|
||||
let retained_staging = futures::future::join_all(
|
||||
disk_stores
|
||||
.iter()
|
||||
.map(|disk| disk.read_all(RUSTFS_META_MULTIPART_BUCKET, &staged_part)),
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|result| result.is_ok())
|
||||
.count();
|
||||
assert_eq!(
|
||||
retained_staging, 1,
|
||||
"only the paused tail disk should still retain the multipart rename source"
|
||||
);
|
||||
|
||||
signaling.set_target(rustfs_lock::ObjectKey::new(bucket, object));
|
||||
let object_attempt = signaling.attempts.load(Ordering::Acquire) + 1;
|
||||
let probe_store = Arc::clone(&set_disks);
|
||||
let object_probe = tokio::spawn(async move {
|
||||
probe_store
|
||||
.acquire_write_lock_diag("multipart_tail_object_guard_probe", bucket, object)
|
||||
.await
|
||||
});
|
||||
signaling.wait_for_attempts(object_attempt).await;
|
||||
assert!(!object_probe.is_finished(), "the detached tail owner must retain the object guard");
|
||||
|
||||
rename_barrier.release();
|
||||
object_probe
|
||||
.await
|
||||
.expect("object guard probe should join after the tail releases")
|
||||
.expect("object guard probe should acquire after the tail releases");
|
||||
tokio::time::timeout(Duration::from_secs(30), cleanup_barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("the multipart tail should pause before reclaiming its old body");
|
||||
let after_tail = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||
assert!(
|
||||
expected.is_subset(&after_tail),
|
||||
"the multipart rename tail must re-mark capacity after the first scope was drained"
|
||||
);
|
||||
cleanup_barrier.release();
|
||||
let abort_err = abort
|
||||
.await
|
||||
.expect("abort task should join after the tail releases")
|
||||
.expect_err("the committed upload should no longer exist");
|
||||
assert!(matches!(abort_err, StorageError::InvalidUploadID(..)));
|
||||
|
||||
for (disk_index, disk) in disk_stores.iter().enumerate() {
|
||||
disk.read_version("", bucket, object, "", &ReadOptions::default())
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("disk {disk_index} must claim its quota fence and finish the rename: {err}"));
|
||||
}
|
||||
|
||||
let after_cleanup = drain_global_dirty_scopes().into_iter().collect::<HashSet<_>>();
|
||||
assert!(
|
||||
expected.is_subset(&after_cleanup),
|
||||
"the multipart tail cleanup must re-mark capacity after its preceding scope was drained"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn put_test_part(
|
||||
set_disks: &Arc<SetDisks>,
|
||||
bucket: &str,
|
||||
@@ -7469,13 +7778,30 @@ mod tests {
|
||||
let new = payload(0xC3);
|
||||
let (u_new, parts_new) = stage_upload(&set_disks, bucket, object, &new).await;
|
||||
let parts_retry = parts_new.clone();
|
||||
let rename_tasks = rename_fanout_barrier::observe_tasks(object);
|
||||
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||
crash_inject::arm(CrashPoint::MultipartAfterCommitBeforePartsCleanup, object);
|
||||
let crashed = complete(&set_disks, bucket, object, &u_new, parts_new).await;
|
||||
assert!(
|
||||
matches!(crashed, Err(StorageError::Unexpected)),
|
||||
"the armed post-commit crash point must be the failure that surfaced, got {crashed:?}"
|
||||
);
|
||||
assert!(rename_tasks.running() >= 1, "the crash must interrupt an actual early-ACK tail handoff");
|
||||
crash_inject::disarm(CrashPoint::MultipartAfterCommitBeforePartsCleanup, object);
|
||||
rename_barrier.release();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while rename_tasks.running() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the crash-interrupted rename tail should drain after release");
|
||||
drop(
|
||||
set_disks
|
||||
.acquire_write_lock_diag("post_commit_crash_tail_probe", bucket, object)
|
||||
.await
|
||||
.expect("the crash-interrupted tail should release its object guard"),
|
||||
);
|
||||
|
||||
// The commit landed: the new version reads back whole and correct.
|
||||
let (body, _etag) = read_object(&set_disks, bucket, object).await;
|
||||
@@ -7519,7 +7845,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(storage_class_env)]
|
||||
async fn post_commit_crash_receipt_reclaims_old_data_after_restart() {
|
||||
async fn post_commit_crash_reclaims_old_data_after_restart() {
|
||||
let _proof = install_remote_version_state_fleet_proof_for_test("object-transaction-fencing-test");
|
||||
let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "multipart-crash-old-data-receipt";
|
||||
@@ -7537,30 +7863,55 @@ mod tests {
|
||||
complete(&set_disks, bucket, object, &u_old, parts_old)
|
||||
.await
|
||||
.expect("the old version should commit");
|
||||
drop(
|
||||
set_disks
|
||||
.acquire_write_lock_diag("post_commit_receipt_fixture_ready", bucket, object)
|
||||
.await
|
||||
.expect("the initial multipart tail should drain before inspecting one disk"),
|
||||
);
|
||||
let old_dir = current_data_dir(&disk_stores[0], bucket, object).await;
|
||||
|
||||
let new = payload(0x52);
|
||||
let (u_new, parts_new) = stage_upload(&set_disks, bucket, object, &new).await;
|
||||
let rename_tasks = rename_fanout_barrier::observe_tasks(object);
|
||||
let rename_barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||
crash_inject::arm(CrashPoint::MultipartAfterCommitBeforePartsCleanup, object);
|
||||
let crashed = complete(&set_disks, bucket, object, &u_new, parts_new).await;
|
||||
assert!(
|
||||
matches!(crashed, Err(StorageError::Unexpected)),
|
||||
"the post-commit crash point must surface as unexpected, got {crashed:?}"
|
||||
);
|
||||
assert!(rename_tasks.running() >= 1, "the crash must interrupt an actual early-ACK tail handoff");
|
||||
crash_inject::disarm(CrashPoint::MultipartAfterCommitBeforePartsCleanup, object);
|
||||
rename_barrier.release();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while rename_tasks.running() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the crash-interrupted rename tail should drain after release");
|
||||
drop(
|
||||
set_disks
|
||||
.acquire_write_lock_diag("post_commit_receipt_tail_probe", bucket, object)
|
||||
.await
|
||||
.expect("the crash-interrupted tail should release its object guard"),
|
||||
);
|
||||
|
||||
let (body, _) = read_object(&set_disks, bucket, object).await;
|
||||
assert_eq!(body, new, "the committed replacement must remain readable after the crash");
|
||||
let mut receipts = 0;
|
||||
for disk in &disk_stores {
|
||||
assert!(
|
||||
cleanup_receipt_exists(disk, bucket, object, old_dir).await,
|
||||
"post-commit crash must leave a durable old-data cleanup receipt"
|
||||
);
|
||||
receipts += usize::from(cleanup_receipt_exists(disk, bucket, object, old_dir).await);
|
||||
assert!(
|
||||
data_dir_exists(disk, bucket, object, old_dir).await,
|
||||
"post-commit crash must leave old data for restart reconciliation"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
receipts, 3,
|
||||
"the committed quorum must persist receipts while the crash-interrupted tail preserves staging"
|
||||
);
|
||||
|
||||
let restarted_endpoints = temp_dirs
|
||||
.iter()
|
||||
@@ -7605,7 +7956,12 @@ mod tests {
|
||||
.reconcile_old_data_cleanup_receipts(bucket, object)
|
||||
.await
|
||||
.expect("restart receipt reconciliation should succeed");
|
||||
assert_eq!(removed, 4, "restart reconciler should delete all receipt targets");
|
||||
assert_eq!(removed, 3, "restart receipt reconciliation should delete the committed quorum's targets");
|
||||
let reclaimed = restarted_set
|
||||
.reclaim_orphan_data_dirs(bucket, object)
|
||||
.await
|
||||
.expect("restart orphan reconciliation should succeed");
|
||||
assert_eq!(reclaimed, 1, "the late commit without a receipt must remain reclaimable as an orphan");
|
||||
for disk in &reloaded {
|
||||
assert!(
|
||||
!data_dir_exists(disk, bucket, object, old_dir).await,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,8 @@ pub(crate) mod internode {
|
||||
PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_AUTH_V1, PUT_FILE_CAPABILITY_CHALLENGE_QUERY, PUT_FILE_CAPABILITY_QUERY,
|
||||
PUT_FILE_CAPABILITY_VERSION, PUT_FILE_NONCE_QUERY, PUT_FILE_SERVER_EPOCH_QUERY, PutFileCapabilityResponse,
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY, WALK_DIR_STREAM_COMPLETION_V1,
|
||||
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION, WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY,
|
||||
WALK_DIR_STREAM_COMPLETION_V1,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -842,6 +842,7 @@ mod tests {
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketOperations as _, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
|
||||
list::ListOperations as _,
|
||||
object::{ObjectIO as _, ObjectOperations as _},
|
||||
};
|
||||
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
|
||||
@@ -1664,6 +1665,70 @@ mod tests {
|
||||
.expect("DeleteBucket must succeed once the client has drained the bucket");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn bucket_delete_succeeds_after_listing_and_deleting_an_unversioned_overwrite() {
|
||||
let (disk_paths, ecstore) = setup_bucket_delete_test_env().await;
|
||||
let bucket = format!("bucket-delete-after-overwrite-{}", Uuid::new_v4().simple());
|
||||
let object = "object.txt";
|
||||
|
||||
ecstore
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("unversioned bucket should be created");
|
||||
|
||||
let mut first_reader = PutObjReader::from_vec(b"version A".to_vec());
|
||||
let first = ecstore
|
||||
.put_object(&bucket, object, &mut first_reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("version A should be written");
|
||||
let mut second_reader = PutObjReader::from_vec(b"version B".to_vec());
|
||||
let second = ecstore
|
||||
.put_object(&bucket, object, &mut second_reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("version B should overwrite version A");
|
||||
assert_ne!(first.data_dir, second.data_dir, "the overwrite must publish a new body generation");
|
||||
|
||||
let listing = ecstore
|
||||
.clone()
|
||||
.list_object_versions(&bucket, "", None, None, None, 1000)
|
||||
.await
|
||||
.expect("the overwritten object should remain listable for teardown");
|
||||
assert_eq!(listing.objects.len(), 1, "an unversioned overwrite should expose one current version");
|
||||
let current = &listing.objects[0];
|
||||
assert_eq!(current.name, object);
|
||||
assert!(current.is_latest, "the listed null version must be current");
|
||||
assert_eq!(current.version_id, None, "an unversioned object must be exposed as the null version");
|
||||
assert_eq!(
|
||||
current.data_dir, second.data_dir,
|
||||
"listing must expose version B, not the overwritten body"
|
||||
);
|
||||
|
||||
for version in listing.objects {
|
||||
let version_id = version.version_id.map(|version_id| version_id.to_string());
|
||||
ecstore
|
||||
.delete_object(
|
||||
&bucket,
|
||||
&version.name,
|
||||
ObjectOptions {
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("each version returned by teardown listing should be deletable");
|
||||
}
|
||||
|
||||
assert!(
|
||||
!any_disk_has_object_metadata(&disk_paths, &bucket).await,
|
||||
"deleting the listed null version must remove every xl.meta"
|
||||
);
|
||||
ecstore
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("DeleteBucket should succeed after the listed overwrite is deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn bucket_delete_default_s3_delete_still_rejects_non_empty_bucket() {
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::set_disk::get_lock_acquire_timeout;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use rustfs_lock::NamespaceLockGuard;
|
||||
use std::collections::BTreeSet;
|
||||
use tracing::trace;
|
||||
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
@@ -96,6 +97,8 @@ impl ECStore {
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::other("heal format requires at least one storage pool"))?;
|
||||
let mut write_state = self.pool_meta_save_gate.lock().await;
|
||||
write_state.ensure_write_safe("heal format fence failed")?;
|
||||
|
||||
// Metadata fence order is part of the decommission/rebalance protocol:
|
||||
// pool.bin must always be acquired before rebalance.bin.
|
||||
@@ -109,8 +112,12 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let mut pool_meta = PoolMeta::default();
|
||||
let replica_state = pool_meta.load_no_lock_from_replicas(self.pools.clone()).await?;
|
||||
replica_state.ensure_write_safe("heal format fence failed")?;
|
||||
let replica_state = pool_meta
|
||||
.load_no_lock_from_replicas_observing(self.pools.clone(), &mut write_state)
|
||||
.await?;
|
||||
write_state.observe_replicas(replica_state);
|
||||
write_state.ensure_write_safe("heal format fence failed")?;
|
||||
drop(write_state);
|
||||
if pool_meta.pools.len() != self.pools.len()
|
||||
|| pool_meta.pools.iter().enumerate().any(|(pool_idx, pool)| {
|
||||
pool.id != pool_idx || pool.cmd_line.is_empty() || pool.cmd_line != self.pools[pool_idx].endpoints.cmd_line
|
||||
@@ -292,7 +299,50 @@ impl ECStore {
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub(super) async fn handle_heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
let res = self.peer_sys.heal_bucket(bucket, opts).await?;
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.read().await;
|
||||
let save_guard = self.pool_meta_save_gate.lock().await;
|
||||
save_guard.ensure_write_safe("bucket heal cannot run while pool metadata requires recovery")?;
|
||||
let mut fenced_pools = BTreeSet::new();
|
||||
{
|
||||
let pool_meta = self.pool_meta.read().await;
|
||||
fenced_pools.extend((0..pool_meta.pools.len()).filter(|pool_idx| pool_meta.is_suspended(*pool_idx)));
|
||||
if let Some(pool_idx) = opts.pool {
|
||||
if pool_idx >= pool_meta.pools.len() {
|
||||
return Err(invalid_heal_pool_index(pool_idx, pool_meta.pools.len()));
|
||||
}
|
||||
if pool_meta.is_suspended(pool_idx) {
|
||||
let complete = pool_meta.pools[pool_idx]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.is_some_and(|decommission| decommission.complete);
|
||||
return Err(if complete {
|
||||
StorageError::InvalidArgument(
|
||||
"heal".to_string(),
|
||||
"pool".to_string(),
|
||||
format!("heal pool {pool_idx} has completed decommission"),
|
||||
)
|
||||
} else {
|
||||
Error::SlowDown
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dispatch_fenced_pools = fenced_pools.iter().copied().collect::<Vec<_>>();
|
||||
drop(save_guard);
|
||||
let mut res = self
|
||||
.peer_sys
|
||||
.heal_bucket_with_fence_from_movement_guarded_coordinator(bucket, opts, &dispatch_fenced_pools)
|
||||
.await?;
|
||||
{
|
||||
let pool_meta = self.pool_meta.read().await;
|
||||
fenced_pools.extend((0..pool_meta.pools.len()).filter(|pool_idx| pool_meta.is_suspended(*pool_idx)));
|
||||
}
|
||||
if !fenced_pools.is_empty() {
|
||||
let pools = fenced_pools.iter().map(usize::to_string).collect::<Vec<_>>().join(", ");
|
||||
res.detail = format!("skipped: bucket-volume heal fenced on decommission-suspended pool(s): {pools}");
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
@@ -440,17 +490,113 @@ impl ECStore {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bucket::metadata_sys;
|
||||
use crate::core::pools::{PoolDecommissionInfo, PoolStatus};
|
||||
use crate::cluster::rpc::PeerS3Client;
|
||||
use crate::core::pools::{PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus};
|
||||
use crate::disk::error::Result as DiskResult;
|
||||
use crate::disk::{DeleteOptions, DiskOption, format::FormatV3, new_disk};
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::services::rebalance::{RebalanceInfo, RebalanceStats};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::bucket::{
|
||||
BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions,
|
||||
};
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations};
|
||||
use crate::store::init_format::{load_format_erasure, save_format_file};
|
||||
use crate::store::init_local_disks_with_instance_ctx;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BlockingHealPeer {
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PeerS3Client for BlockingHealPeer {
|
||||
async fn heal_bucket(&self, _bucket: &str, _opts: &HealOpts) -> DiskResult<HealResultItem> {
|
||||
self.started.notify_one();
|
||||
self.release.notified().await;
|
||||
Ok(HealResultItem::default())
|
||||
}
|
||||
|
||||
async fn make_bucket(&self, _bucket: &str, _opts: &MakeBucketOptions) -> DiskResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_bucket(&self, _opts: &BucketOptions) -> DiskResult<Vec<BucketInfo>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn delete_bucket(&self, _bucket: &str, _opts: &DeleteBucketOptions) -> DiskResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_bucket_info(&self, _bucket: &str, _opts: &BucketOptions) -> DiskResult<BucketInfo> {
|
||||
Ok(BucketInfo::default())
|
||||
}
|
||||
|
||||
fn get_pools(&self) -> Option<Vec<usize>> {
|
||||
Some(vec![0, 1])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WriterQueuedLocalHealPeer {
|
||||
movement_gate: Arc<tokio::sync::RwLock<()>>,
|
||||
writer_queued: Arc<tokio::sync::Notify>,
|
||||
writer_acquired: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl PeerS3Client for WriterQueuedLocalHealPeer {
|
||||
async fn heal_bucket(&self, _bucket: &str, _opts: &HealOpts) -> DiskResult<HealResultItem> {
|
||||
let _movement_guard = self
|
||||
.movement_gate
|
||||
.try_read()
|
||||
.map_err(|_| crate::error::StorageError::SlowDown)?;
|
||||
Ok(HealResultItem::default())
|
||||
}
|
||||
|
||||
async fn heal_bucket_with_fence_from_movement_guarded_coordinator(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_opts: &HealOpts,
|
||||
_fenced_pools: &[usize],
|
||||
) -> DiskResult<HealResultItem> {
|
||||
Ok(HealResultItem::default())
|
||||
}
|
||||
|
||||
async fn make_bucket(&self, _bucket: &str, _opts: &MakeBucketOptions) -> DiskResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_bucket(&self, _opts: &BucketOptions) -> DiskResult<Vec<BucketInfo>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn delete_bucket(&self, _bucket: &str, _opts: &DeleteBucketOptions) -> DiskResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_bucket_info(&self, _bucket: &str, _opts: &BucketOptions) -> DiskResult<BucketInfo> {
|
||||
let movement_gate = self.movement_gate.clone();
|
||||
let writer_acquired = self.writer_acquired.clone();
|
||||
tokio::spawn(async move {
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
writer_acquired.notify_one();
|
||||
});
|
||||
while self.movement_gate.try_read().is_ok() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
self.writer_queued.notify_one();
|
||||
Ok(BucketInfo::default())
|
||||
}
|
||||
|
||||
fn get_pools(&self) -> Option<Vec<usize>> {
|
||||
Some(vec![0, 1])
|
||||
}
|
||||
}
|
||||
|
||||
async fn minimal_heal_pool(pool_idx: usize) -> Arc<Sets> {
|
||||
let format = FormatV3::new(1, 1);
|
||||
let endpoint_url = format!("http://127.0.0.1:{}/data", 19000 + pool_idx);
|
||||
@@ -490,7 +636,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
}
|
||||
@@ -858,6 +1004,151 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_heal_bucket_blocks_before_dispatch_when_pool_is_suspended() {
|
||||
let mut store = minimal_heal_store().await;
|
||||
store.pool_meta = RwLock::new(PoolMeta {
|
||||
pools: vec![
|
||||
PoolStatus {
|
||||
id: 0,
|
||||
cmd_line: "pool-0".to_string(),
|
||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||
decommission: None,
|
||||
},
|
||||
PoolStatus {
|
||||
id: 1,
|
||||
cmd_line: "pool-1".to_string(),
|
||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||
decommission: Some(PoolDecommissionInfo {
|
||||
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = store
|
||||
.handle_heal_bucket(
|
||||
"bucket",
|
||||
&HealOpts {
|
||||
pool: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("suspended pool must be blocked before bucket-heal fan-out");
|
||||
assert_eq!(err, Error::SlowDown);
|
||||
|
||||
store.pool_meta.write().await.pools[1]
|
||||
.decommission
|
||||
.as_mut()
|
||||
.expect("decommission state should exist")
|
||||
.complete = true;
|
||||
let err = store
|
||||
.handle_heal_bucket(
|
||||
"bucket",
|
||||
&HealOpts {
|
||||
pool: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("completed pool must remain fenced from bucket heal");
|
||||
assert!(
|
||||
matches!(err, StorageError::InvalidArgument(_, ref field, ref reason)
|
||||
if field == "pool" && reason.contains("completed decommission")),
|
||||
"unexpected completed-pool error: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bucket_heal_blocks_before_dispatch_after_unreadable_pool_meta_replica() {
|
||||
let store = minimal_heal_store().await;
|
||||
store.pool_meta_save_gate.lock().await.observe_replicas(PoolMetaReplicaState {
|
||||
needs_repair: true,
|
||||
repair_write_safe: false,
|
||||
});
|
||||
|
||||
let err = store
|
||||
.handle_heal_bucket("bucket", &HealOpts::default())
|
||||
.await
|
||||
.expect_err("bucket heal must stay blocked until restart after an unreadable replica");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("restart after all replicas are readable and consistent")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bucket_heal_releases_save_gate_before_peer_dispatch_and_holds_movement_snapshot() {
|
||||
let mut store = minimal_heal_store().await;
|
||||
let started = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let peer: Box<dyn PeerS3Client> = Box::new(BlockingHealPeer {
|
||||
started: started.clone(),
|
||||
release: release.clone(),
|
||||
});
|
||||
store.peer_sys.clients = vec![Arc::new(peer)];
|
||||
let store = Arc::new(store);
|
||||
let movement_gate = store.ctx.data_movement_operation_gate();
|
||||
let mut heal = tokio::spawn({
|
||||
let store = store.clone();
|
||||
async move { store.handle_heal_bucket("bucket", &HealOpts::default()).await }
|
||||
});
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), started.notified())
|
||||
.await
|
||||
.expect("peer dispatch should start");
|
||||
assert!(
|
||||
store.pool_meta_save_gate.try_lock().is_ok(),
|
||||
"coordinator must release its local save gate before waiting for peers"
|
||||
);
|
||||
assert!(
|
||||
movement_gate.try_write().is_err(),
|
||||
"bucket heal must hold the movement snapshot through peer dispatch"
|
||||
);
|
||||
|
||||
release.notify_one();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), &mut heal)
|
||||
.await
|
||||
.expect("bucket heal should finish after peer release")
|
||||
.expect("bucket heal task should not panic")
|
||||
.expect("bucket heal should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bucket_heal_local_fanout_does_not_reenter_movement_read_behind_queued_writer() {
|
||||
let mut store = minimal_heal_store().await;
|
||||
let movement_gate = store.ctx.data_movement_operation_gate();
|
||||
let writer_queued = Arc::new(tokio::sync::Notify::new());
|
||||
let writer_acquired = Arc::new(tokio::sync::Notify::new());
|
||||
let peer: Box<dyn PeerS3Client> = Box::new(WriterQueuedLocalHealPeer {
|
||||
movement_gate: movement_gate.clone(),
|
||||
writer_queued: writer_queued.clone(),
|
||||
writer_acquired: writer_acquired.clone(),
|
||||
});
|
||||
store.peer_sys.clients = vec![Arc::new(peer)];
|
||||
let store = Arc::new(store);
|
||||
let mut heal = tokio::spawn({
|
||||
let store = store.clone();
|
||||
async move { store.handle_heal_bucket("bucket", &HealOpts::default()).await }
|
||||
});
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), writer_queued.notified())
|
||||
.await
|
||||
.expect("movement writer should queue during local peer lookup");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), &mut heal)
|
||||
.await
|
||||
.expect("local fan-out must not reenter movement read behind the queued writer")
|
||||
.expect("bucket heal task should not panic")
|
||||
.expect("bucket heal should succeed");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), writer_acquired.notified())
|
||||
.await
|
||||
.expect("queued movement writer should proceed after bucket heal releases its read guard");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn unscoped_heal_object_suspended_owner_semantics() {
|
||||
@@ -1185,7 +1476,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
};
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::core::pools::{PoolMetaReplicaState, local_decommission_queue_prefix, pool_meta_has_active_decommission};
|
||||
use crate::core::pools::{
|
||||
PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix, pool_meta_has_active_decommission,
|
||||
};
|
||||
use crate::error::is_err_decommission_running;
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
@@ -109,6 +111,14 @@ fn should_auto_start_rebalance_after_init(decommission_running: bool, rebalance_
|
||||
rebalance_meta_loaded && !decommission_running
|
||||
}
|
||||
|
||||
fn should_schedule_local_decommission_resume(
|
||||
pool_indices: &[usize],
|
||||
pool_meta_replica_state: PoolMetaReplicaState,
|
||||
pool_meta_write_safe: bool,
|
||||
) -> bool {
|
||||
!pool_indices.is_empty() && pool_meta_replica_state.repair_write_safe && pool_meta_write_safe
|
||||
}
|
||||
|
||||
async fn wait_for_local_decommission_resume_delay(rx: &CancellationToken, delay: Duration) -> bool {
|
||||
tokio::select! {
|
||||
_ = rx.cancelled() => false,
|
||||
@@ -120,15 +130,19 @@ fn resolve_store_init_stage_result(result: Result<()>, stage: &str) -> Result<()
|
||||
result.map_err(|err| Error::other(format!("store init failed during {stage}: {err}")))
|
||||
}
|
||||
|
||||
async fn load_pool_meta_for_startup<S>(pools: Vec<Arc<S>>) -> Result<(PoolMeta, PoolMetaReplicaState)>
|
||||
async fn load_pool_meta_for_startup<S>(
|
||||
pools: Vec<Arc<S>>,
|
||||
write_state: &mut PoolMetaWriteState,
|
||||
) -> Result<(PoolMeta, PoolMetaReplicaState)>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let mut meta = PoolMeta::default();
|
||||
let replica_state = meta
|
||||
.load_no_lock_from_replicas(pools)
|
||||
.load_no_lock_from_replicas_observing(pools, write_state)
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("store init failed during load_pool_meta: {err}")))?;
|
||||
write_state.observe_replicas(replica_state);
|
||||
Ok((meta, replica_state))
|
||||
}
|
||||
|
||||
@@ -143,6 +157,7 @@ async fn persist_pool_meta_for_startup_if_safe<S>(
|
||||
meta: &PoolMeta,
|
||||
pools: Vec<Arc<S>>,
|
||||
replica_state: PoolMetaReplicaState,
|
||||
write_state: PoolMetaWriteState,
|
||||
topology_update: bool,
|
||||
elected_writer: bool,
|
||||
) -> Result<()>
|
||||
@@ -152,10 +167,14 @@ where
|
||||
if !elected_writer {
|
||||
return Ok(());
|
||||
}
|
||||
let should_write = topology_update || (replica_state.needs_repair && replica_state.repair_write_safe);
|
||||
if topology_update {
|
||||
replica_state.ensure_write_safe("store init failed during save_validated_pool_meta")?;
|
||||
}
|
||||
if topology_update || (replica_state.needs_repair && replica_state.repair_write_safe) {
|
||||
if should_write {
|
||||
write_state.ensure_write_safe("store init failed during save_validated_pool_meta")?;
|
||||
}
|
||||
if should_write {
|
||||
save_validated_pool_meta_for_startup(meta, pools).await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -431,7 +450,7 @@ impl ECStore {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers,
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
// Adopt the caller's context (the process bootstrap one on the
|
||||
// legacy path) so startup writes (erasure type recorded before
|
||||
// this point) and later reads share one cell.
|
||||
@@ -475,7 +494,10 @@ impl ECStore {
|
||||
pub async fn init(self: &Arc<Self>, rx: CancellationToken) -> Result<()> {
|
||||
runtime_sources::ensure_boot_time().await;
|
||||
|
||||
let (meta, pool_meta_replica_state) = load_pool_meta_for_startup(self.pools.clone()).await?;
|
||||
let (meta, pool_meta_replica_state) = {
|
||||
let mut write_state = self.pool_meta_save_gate.lock().await;
|
||||
load_pool_meta_for_startup(self.pools.clone(), &mut write_state).await?
|
||||
};
|
||||
let update = meta.validate(self.pools.clone())?;
|
||||
let endpoints = runtime_sources::endpoint_pools_or_default();
|
||||
let should_persist_pool_meta = runtime_sources::first_cluster_node_is_local().await;
|
||||
@@ -487,14 +509,18 @@ impl ECStore {
|
||||
};
|
||||
// Only one local node should persist validated pool metadata here; otherwise
|
||||
// distributed startup can race on the same lock and replay the prior init bug.
|
||||
persist_pool_meta_for_startup_if_safe(
|
||||
&installed_pool_meta,
|
||||
self.pools.clone(),
|
||||
pool_meta_replica_state,
|
||||
update,
|
||||
should_persist_pool_meta,
|
||||
)
|
||||
.await?;
|
||||
{
|
||||
let write_state = self.pool_meta_save_gate.lock().await;
|
||||
persist_pool_meta_for_startup_if_safe(
|
||||
&installed_pool_meta,
|
||||
self.pools.clone(),
|
||||
pool_meta_replica_state,
|
||||
*write_state,
|
||||
update,
|
||||
should_persist_pool_meta,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
{
|
||||
let mut pool_meta = self.pool_meta.write().await;
|
||||
@@ -538,7 +564,11 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let local_pool_indices = local_decommission_queue_prefix(&endpoints, &pool_indices)?;
|
||||
if !local_pool_indices.is_empty() {
|
||||
let pool_meta_write_safe = self
|
||||
.ensure_pool_meta_side_effects_safe("decommission resume blocked while pool metadata requires recovery")
|
||||
.await
|
||||
.is_ok();
|
||||
if should_schedule_local_decommission_resume(&local_pool_indices, pool_meta_replica_state, pool_meta_write_safe) {
|
||||
let store = self.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -547,6 +577,16 @@ impl ECStore {
|
||||
}
|
||||
resume_local_decommission_after_init(store, rx, local_pool_indices).await;
|
||||
});
|
||||
} else if !local_pool_indices.is_empty() {
|
||||
error!(
|
||||
event = EVENT_DECOMMISSION_RESUME_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_STORE_INIT,
|
||||
state = "blocked",
|
||||
pool_indices = ?local_pool_indices,
|
||||
reason = "pool_meta_write_blocked",
|
||||
"Decommission resume blocked until pool metadata replicas are readable and consistent"
|
||||
);
|
||||
}
|
||||
|
||||
runtime_sources::init_bucket_monitor_for_current_endpoints();
|
||||
@@ -575,13 +615,15 @@ impl ECStore {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES, load_pool_meta_for_startup, persist_pool_meta_for_startup_if_safe,
|
||||
pool_first_endpoint_is_local, pool_meta_has_active_decommission, preflight_startup_rpc_secret_with,
|
||||
resolve_startup_pool_defaults_with, resolve_store_init_stage_result, save_validated_pool_meta_for_startup,
|
||||
should_auto_start_rebalance_after_init, should_retry_format_load, should_retry_local_decommission_resume,
|
||||
wait_for_local_decommission_resume_delay,
|
||||
LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES, PoolMetaWriteState, load_pool_meta_for_startup,
|
||||
persist_pool_meta_for_startup_if_safe, pool_first_endpoint_is_local, pool_meta_has_active_decommission,
|
||||
preflight_startup_rpc_secret_with, resolve_startup_pool_defaults_with, resolve_store_init_stage_result,
|
||||
save_validated_pool_meta_for_startup, should_auto_start_rebalance_after_init, should_retry_format_load,
|
||||
should_retry_local_decommission_resume, wait_for_local_decommission_resume_delay,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
use crate::disk::DiskAPI;
|
||||
#[cfg(feature = "test-util")]
|
||||
use crate::{
|
||||
bucket::lifecycle::{
|
||||
DurableIlmRecordCheckpoint, ILM_META_PREFIX, ValidatedDurableIlmRecord,
|
||||
@@ -635,7 +677,7 @@ mod tests {
|
||||
};
|
||||
use crate::{
|
||||
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
|
||||
core::pools::{POOL_META_FORMAT, POOL_META_VERSION, PoolDecommissionInfo, PoolMeta, PoolStatus},
|
||||
core::pools::{POOL_META_VERSION, PoolDecommissionInfo, PoolMeta, PoolStatus},
|
||||
disk::endpoint::Endpoint,
|
||||
error::{Error, Result, StorageError},
|
||||
io_support::rio::{WritePlan, compression_metadata_value},
|
||||
@@ -649,7 +691,6 @@ mod tests {
|
||||
range::HTTPRangeSpec,
|
||||
},
|
||||
};
|
||||
use byteorder::{LittleEndian, WriteBytesExt};
|
||||
#[cfg(feature = "test-util")]
|
||||
use futures::{StreamExt as _, TryStreamExt as _};
|
||||
use http::HeaderMap;
|
||||
@@ -679,13 +720,7 @@ mod tests {
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn startup_pool_meta_payload(meta: &PoolMeta) -> Vec<u8> {
|
||||
let mut data = Vec::new();
|
||||
data.write_u16::<LittleEndian>(POOL_META_FORMAT)
|
||||
.expect("pool metadata format should encode");
|
||||
data.write_u16::<LittleEndian>(POOL_META_VERSION)
|
||||
.expect("pool metadata version should encode");
|
||||
data.extend(rmp_serde::to_vec(meta).expect("legacy pool metadata payload should encode"));
|
||||
data
|
||||
meta.encode_config_data_for_test().expect("pool metadata should encode")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -801,8 +836,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_store_init_pool_meta_io_bypasses_namespace_lock_surface() {
|
||||
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||
let mut write_state = PoolMetaWriteState::default();
|
||||
|
||||
let (loaded, replica_state) = load_pool_meta_for_startup(vec![storage.clone()])
|
||||
let (loaded, replica_state) = load_pool_meta_for_startup(vec![storage.clone()], &mut write_state)
|
||||
.await
|
||||
.expect("startup pool metadata load should tolerate missing metadata without locks");
|
||||
assert!(loaded.pools.is_empty());
|
||||
@@ -827,8 +863,9 @@ mod tests {
|
||||
let corrupt = Arc::new(StartupPoolMetaStorage::new(vec![0, 1, 2]));
|
||||
let expected = init_test_pool_meta(None);
|
||||
let backup = Arc::new(StartupPoolMetaStorage::new(startup_pool_meta_payload(&expected)));
|
||||
let mut write_state = PoolMetaWriteState::default();
|
||||
|
||||
let (loaded, replica_state) = load_pool_meta_for_startup(vec![corrupt.clone(), backup.clone()])
|
||||
let (loaded, replica_state) = load_pool_meta_for_startup(vec![corrupt.clone(), backup.clone()], &mut write_state)
|
||||
.await
|
||||
.expect("startup should select the validated backup replica");
|
||||
|
||||
@@ -839,9 +876,16 @@ mod tests {
|
||||
assert!(corrupt.read_without_lock.load(Ordering::SeqCst));
|
||||
assert!(backup.read_without_lock.load(Ordering::SeqCst));
|
||||
|
||||
persist_pool_meta_for_startup_if_safe(&loaded, vec![corrupt.clone(), backup.clone()], replica_state, false, true)
|
||||
.await
|
||||
.expect("the elected startup writer should repair validated corrupt replicas");
|
||||
persist_pool_meta_for_startup_if_safe(
|
||||
&loaded,
|
||||
vec![corrupt.clone(), backup.clone()],
|
||||
replica_state,
|
||||
write_state,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("the elected startup writer should repair validated corrupt replicas");
|
||||
|
||||
let corrupt_write = corrupt
|
||||
.written_payload
|
||||
@@ -863,26 +907,76 @@ mod tests {
|
||||
async fn test_store_init_pool_meta_does_not_repair_unreadable_replica() {
|
||||
let valid = Arc::new(StartupPoolMetaStorage::new(startup_pool_meta_payload(&init_test_pool_meta(None))));
|
||||
let unreadable = Arc::new(StartupPoolMetaStorage::unreadable());
|
||||
let mut write_state = PoolMetaWriteState::default();
|
||||
|
||||
let (loaded, replica_state) = load_pool_meta_for_startup(vec![valid.clone(), unreadable.clone()])
|
||||
let (loaded, replica_state) = load_pool_meta_for_startup(vec![valid.clone(), unreadable.clone()], &mut write_state)
|
||||
.await
|
||||
.expect("startup should use a validated replica without overwriting an unreadable copy");
|
||||
assert!(replica_state.needs_repair);
|
||||
assert!(!replica_state.repair_write_safe);
|
||||
|
||||
persist_pool_meta_for_startup_if_safe(&loaded, vec![valid.clone(), unreadable.clone()], replica_state, false, true)
|
||||
.await
|
||||
.expect("an unreadable copy should defer repair when no topology write is needed");
|
||||
persist_pool_meta_for_startup_if_safe(
|
||||
&loaded,
|
||||
vec![valid.clone(), unreadable.clone()],
|
||||
replica_state,
|
||||
write_state,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("an unreadable copy should defer repair when no topology write is needed");
|
||||
assert!(!valid.wrote_without_lock.load(Ordering::SeqCst));
|
||||
assert!(!unreadable.wrote_without_lock.load(Ordering::SeqCst));
|
||||
|
||||
let err =
|
||||
persist_pool_meta_for_startup_if_safe(&loaded, vec![valid.clone(), unreadable.clone()], replica_state, true, true)
|
||||
.await
|
||||
.expect_err("a topology update must not overwrite an unreadable replica");
|
||||
let err = persist_pool_meta_for_startup_if_safe(
|
||||
&loaded,
|
||||
vec![valid.clone(), unreadable.clone()],
|
||||
replica_state,
|
||||
write_state,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect_err("a topology update must not overwrite an unreadable replica");
|
||||
assert!(err.to_string().contains("cannot overwrite an unreadable replica"));
|
||||
assert!(!valid.wrote_without_lock.load(Ordering::SeqCst));
|
||||
assert!(!unreadable.wrote_without_lock.load(Ordering::SeqCst));
|
||||
assert!(!super::should_schedule_local_decommission_resume(&[0], replica_state, true));
|
||||
assert!(!super::should_schedule_local_decommission_resume(
|
||||
&[0],
|
||||
crate::core::pools::PoolMetaReplicaState {
|
||||
needs_repair: false,
|
||||
repair_write_safe: true,
|
||||
},
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_init_pool_meta_stays_blocked_after_all_replicas_were_unreadable() {
|
||||
let unreadable_a = Arc::new(StartupPoolMetaStorage::unreadable());
|
||||
let unreadable_b = Arc::new(StartupPoolMetaStorage::unreadable());
|
||||
let mut write_state = PoolMetaWriteState::default();
|
||||
|
||||
load_pool_meta_for_startup(vec![unreadable_a, unreadable_b], &mut write_state)
|
||||
.await
|
||||
.expect_err("startup must fail when no readable pool metadata replica exists");
|
||||
|
||||
let repaired = Arc::new(StartupPoolMetaStorage::new(startup_pool_meta_payload(&init_test_pool_meta(None))));
|
||||
let (loaded, replica_state) = load_pool_meta_for_startup(vec![repaired.clone()], &mut write_state)
|
||||
.await
|
||||
.expect("a later startup retry may read the repaired replica");
|
||||
assert!(replica_state.repair_write_safe);
|
||||
|
||||
let err = persist_pool_meta_for_startup_if_safe(&loaded, vec![repaired.clone()], replica_state, write_state, true, true)
|
||||
.await
|
||||
.expect_err("the same store instance must not write after observing unreadable replicas");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("restart after all replicas are readable and consistent")
|
||||
);
|
||||
assert!(!repaired.wrote_without_lock.load(Ordering::SeqCst));
|
||||
assert!(!super::should_schedule_local_decommission_resume(&[0], replica_state, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1530,6 +1624,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn seed_transitioned_free_version(
|
||||
ctx: &Arc<crate::runtime::instance::InstanceContext>,
|
||||
store: &Arc<crate::store::ECStore>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> (uuid::Uuid, uuid::Uuid) {
|
||||
let tier_name = format!("DECOMFREE{}", uuid::Uuid::new_v4().simple());
|
||||
register_mock_tier(&ctx.tier_config_mgr(), &tier_name).await;
|
||||
|
||||
let mut reader = PutObjReader::from_vec(b"transitioned source bytes".to_vec());
|
||||
let source = store.pools[0]
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write transitioned decommission source");
|
||||
let source_version = source.version_id.expect("transitioned source must be versioned");
|
||||
store.pools[0]
|
||||
.transition_object(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(source_version.to_string()),
|
||||
transition: TransitionOptions {
|
||||
status: TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name,
|
||||
etag: source.etag.clone().expect("transitioned source must have an ETag"),
|
||||
..Default::default()
|
||||
},
|
||||
mod_time: source.mod_time,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("transition source before decommission");
|
||||
store.pools[0]
|
||||
.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(source_version.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("delete transitioned source version");
|
||||
|
||||
let versions = store.pools[0]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(bucket, object)
|
||||
.await
|
||||
.expect("source versions should decode after transition delete")
|
||||
.expect("source free version should remain after transition delete");
|
||||
let free_version = versions
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.tier_free_version())
|
||||
.and_then(|version| version.version_id)
|
||||
.expect("transition delete should create a free version");
|
||||
(source_version, free_version)
|
||||
}
|
||||
|
||||
async fn write_decommission_test_multipart_source(
|
||||
store: &Arc<crate::store::ECStore>,
|
||||
pool_idx: usize,
|
||||
@@ -4243,6 +4408,8 @@ mod tests {
|
||||
let bucket = format!("reverse-decom-fixed-target-{}", uuid::Uuid::new_v4());
|
||||
let object = "ordinary.bin";
|
||||
let object_body = b"reverse ordinary generation".to_vec();
|
||||
let self_copy_object = "source-only-self-copy.bin";
|
||||
let self_copy_body = b"source only copy generation".to_vec();
|
||||
let multipart_object = "multipart.bin";
|
||||
let first_part = vec![b'm'; 5 * 1024 * 1024];
|
||||
let second_part = b"reverse multipart tail".to_vec();
|
||||
@@ -4258,6 +4425,11 @@ mod tests {
|
||||
.put_object(&bucket, object, &mut source, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("write ordinary source object to pool 1");
|
||||
let mut self_copy_source = PutObjReader::from_vec(self_copy_body.clone());
|
||||
store.pools[1]
|
||||
.put_object(&bucket, self_copy_object, &mut self_copy_source, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("write self-copy source object to pool 1");
|
||||
|
||||
let upload = store.pools[1]
|
||||
.new_multipart_upload(&bucket, multipart_object, &ObjectOptions::default())
|
||||
@@ -4298,6 +4470,69 @@ mod tests {
|
||||
}
|
||||
assert!(store.is_suspended(1).await, "pool 1 must be the reverse decommission source");
|
||||
|
||||
let self_copy_opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut self_copy_reader = store
|
||||
.get_object_reader(&bucket, self_copy_object, None, HeaderMap::new(), &self_copy_opts)
|
||||
.await
|
||||
.expect("read the suspended source before its target is committed");
|
||||
let mut self_copy_info = self_copy_reader.object_info.clone();
|
||||
let mut self_copy_source_body = Vec::new();
|
||||
self_copy_reader
|
||||
.stream
|
||||
.read_to_end(&mut self_copy_source_body)
|
||||
.await
|
||||
.expect("drain the suspended self-copy source");
|
||||
assert_eq!(self_copy_source_body, self_copy_body);
|
||||
self_copy_info.metadata_only = true;
|
||||
self_copy_info.put_object_reader = Some(PutObjReader::from_vec(self_copy_source_body));
|
||||
store
|
||||
.copy_object(
|
||||
&bucket,
|
||||
self_copy_object,
|
||||
&bucket,
|
||||
self_copy_object,
|
||||
&mut self_copy_info,
|
||||
&self_copy_opts,
|
||||
&self_copy_opts,
|
||||
)
|
||||
.await
|
||||
.expect("self-copy should read the suspended source and commit to an active pool");
|
||||
assert_pool_object_present(&store.pools[0], &bucket, self_copy_object).await;
|
||||
assert_pool_object_present(&store.pools[1], &bucket, self_copy_object).await;
|
||||
|
||||
let mut active_copy_reader = store
|
||||
.get_object_reader(&bucket, self_copy_object, None, HeaderMap::new(), &self_copy_opts)
|
||||
.await
|
||||
.expect("read the active self-copy target while the source remains");
|
||||
let active_copy_data_dir = active_copy_reader.object_info.data_dir;
|
||||
let mut active_copy_info = active_copy_reader.object_info.clone();
|
||||
let mut active_copy_body = Vec::new();
|
||||
active_copy_reader
|
||||
.stream
|
||||
.read_to_end(&mut active_copy_body)
|
||||
.await
|
||||
.expect("drain the active self-copy target");
|
||||
assert_eq!(active_copy_body, self_copy_body);
|
||||
active_copy_info.metadata_only = true;
|
||||
active_copy_info.put_object_reader = Some(PutObjReader::from_vec(active_copy_body));
|
||||
let active_copy_result = store
|
||||
.copy_object(
|
||||
&bucket,
|
||||
self_copy_object,
|
||||
&bucket,
|
||||
self_copy_object,
|
||||
&mut active_copy_info,
|
||||
&self_copy_opts,
|
||||
&self_copy_opts,
|
||||
)
|
||||
.await
|
||||
.expect("self-copy should keep using the committed active target");
|
||||
assert_eq!(active_copy_result.data_dir, active_copy_data_dir);
|
||||
|
||||
let cleanup_barrier = crate::data_movement::SourceCleanupDeleteBarrier::install(&bucket, object);
|
||||
let commit_barrier = crate::set_disk::PutObjectCommitBarrier::install(
|
||||
&bucket,
|
||||
object,
|
||||
@@ -4346,7 +4581,50 @@ mod tests {
|
||||
drop(delete_barrier);
|
||||
|
||||
commit_barrier.release();
|
||||
cleanup_barrier.wait_until_paused().await;
|
||||
drop(commit_barrier);
|
||||
|
||||
let target_info = store.pools[0]
|
||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("read the committed active target before source cleanup");
|
||||
assert_pool_object_present(&store.pools[1], &bucket, object).await;
|
||||
|
||||
let read_opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let routed_info = store
|
||||
.get_object_info(&bucket, object, &read_opts)
|
||||
.await
|
||||
.expect("HEAD routing should prefer the active target before source cleanup");
|
||||
assert_eq!(routed_info.data_dir, target_info.data_dir);
|
||||
|
||||
let mut ranged_reader = store
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
object,
|
||||
Some(HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
start: 8,
|
||||
end: 15,
|
||||
}),
|
||||
HeaderMap::new(),
|
||||
&read_opts,
|
||||
)
|
||||
.await
|
||||
.expect("ranged GET routing should prefer the active target before source cleanup");
|
||||
assert_eq!(ranged_reader.object_info.data_dir, target_info.data_dir);
|
||||
let mut ranged_body = Vec::new();
|
||||
ranged_reader
|
||||
.stream
|
||||
.read_to_end(&mut ranged_body)
|
||||
.await
|
||||
.expect("drain the routed target range");
|
||||
assert_eq!(ranged_body, object_body[8..=15]);
|
||||
|
||||
cleanup_barrier.release();
|
||||
drop(cleanup_barrier);
|
||||
tokio::time::timeout(Duration::from_secs(60), worker)
|
||||
.await
|
||||
.expect("reverse ordinary decommission must not self-deadlock on the fixed target set")
|
||||
@@ -4771,6 +5049,383 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn decommission_entry_skips_cleanup_only_marker_when_free_version_is_present() {
|
||||
let temp_dir = tempfile::tempdir().expect("create free-version decommission store dir");
|
||||
let (ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-free-marker", &[4, 4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("decom-free-marker-{}", uuid::Uuid::new_v4());
|
||||
let object = "free-marker-object";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create free-version decommission bucket");
|
||||
let (_, free_version) = seed_transitioned_free_version(&ctx, &store, &bucket, object).await;
|
||||
let source_free = store.pools[0]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("source free-version metadata should decode")
|
||||
.and_then(|versions| {
|
||||
versions
|
||||
.versions
|
||||
.into_iter()
|
||||
.find(|version| version.version_id == Some(free_version) && version.tier_free_version())
|
||||
})
|
||||
.expect("source free-version identity should be present before decommission");
|
||||
let mut target_reader = PutObjReader::from_vec(b"target ordinary bytes".to_vec());
|
||||
let target_w = store.pools[1]
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut target_reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write unrelated target version");
|
||||
let target_w_version = target_w.version_id.expect("target version should have an id");
|
||||
let marker = store.pools[0]
|
||||
.delete_object(
|
||||
&bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write cleanup-only delete marker");
|
||||
assert!(marker.delete_marker);
|
||||
|
||||
mark_test_pool_decommissioning(&store, 0).await;
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
store
|
||||
.decommission_entry_for_test_with_bucket_incarnation(
|
||||
0,
|
||||
MetaCacheEntry {
|
||||
name: object.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
bucket.clone(),
|
||||
source_set.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("real decommission entry should migrate the free version");
|
||||
|
||||
let target_versions = store.pools[1]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("target versions should decode")
|
||||
.expect("target free version should be present");
|
||||
assert!(
|
||||
target_versions
|
||||
.versions
|
||||
.iter()
|
||||
.any(|version| { version.version_id == Some(free_version) && version.tier_free_version() })
|
||||
);
|
||||
let migrated_free = target_versions
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.version_id == Some(free_version) && version.tier_free_version())
|
||||
.expect("migrated free-version identity should remain readable from target disks");
|
||||
assert!(
|
||||
crate::store::tiered_data_movement_source_matches(&source_free, migrated_free)
|
||||
.expect("migrated free-version identity should decode")
|
||||
);
|
||||
let retained_w = target_versions
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.version_id == Some(target_w_version))
|
||||
.expect("unrelated target version should remain");
|
||||
assert!(!retained_w.deleted && !retained_w.tier_free_version());
|
||||
assert_eq!(retained_w.size, target_w.size);
|
||||
assert_eq!(retained_w.get_etag(), target_w.etag);
|
||||
assert!(
|
||||
target_versions
|
||||
.versions
|
||||
.iter()
|
||||
.all(|version| { version.tier_free_version() || !version.deleted })
|
||||
);
|
||||
assert!(
|
||||
source_set
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("source versions should be readable after cleanup")
|
||||
.is_none(),
|
||||
"successful free-version migration should permit source cleanup"
|
||||
);
|
||||
let (heal_versions, _, _) = store
|
||||
.heal_walk_versions_page(1, 0, &bucket, "", None, 2, 16, true)
|
||||
.await
|
||||
.expect("heal walk should decode the migrated free version");
|
||||
let free_version_string = free_version.to_string();
|
||||
let healed_free = heal_versions
|
||||
.iter()
|
||||
.find(|version| version.version_id.as_deref() == Some(free_version_string.as_str()))
|
||||
.expect("heal walk should surface the migrated free version");
|
||||
let healed_info = healed_free
|
||||
.lifecycle_object_info
|
||||
.as_ref()
|
||||
.expect("heal walk should retain lifecycle identity for the migrated free version");
|
||||
assert!(healed_info.transitioned_object.free_version);
|
||||
assert_eq!(healed_info.transitioned_object.tier, source_free.transition_tier);
|
||||
assert_eq!(healed_info.transitioned_object.name, source_free.transitioned_objname);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn decommission_entry_allows_free_version_consumed_before_source_lock() {
|
||||
let temp_dir = tempfile::tempdir().expect("create consumed free-version store dir");
|
||||
let (ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-free-consumed", &[4, 4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("decom-free-consumed-{}", uuid::Uuid::new_v4());
|
||||
let object = "free-consumed-object";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create consumed free-version bucket");
|
||||
let (_, free_version) = seed_transitioned_free_version(&ctx, &store, &bucket, object).await;
|
||||
|
||||
mark_test_pool_decommissioning(&store, 0).await;
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
let barrier = crate::store::object::DecommissionFreeVersionSourceRaceBarrier::install(&bucket, object);
|
||||
let decommission = tokio::spawn({
|
||||
let store = store.clone();
|
||||
let bucket = bucket.clone();
|
||||
let source_set = source_set.clone();
|
||||
async move {
|
||||
store
|
||||
.decommission_entry_for_test_with_bucket_incarnation(
|
||||
0,
|
||||
MetaCacheEntry {
|
||||
name: object.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
bucket,
|
||||
source_set,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
barrier.wait_until_paused().await;
|
||||
store.pools[0]
|
||||
.delete_object(
|
||||
&bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(free_version.to_string()),
|
||||
incl_free_versions: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("lifecycle should consume the source free version before decommission locks it");
|
||||
assert!(
|
||||
source_set
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("consumed source metadata should remain readable")
|
||||
.is_none(),
|
||||
"the lifecycle delete should remove the source free version"
|
||||
);
|
||||
|
||||
barrier.release();
|
||||
decommission
|
||||
.await
|
||||
.expect("decommission task should join")
|
||||
.expect("a concurrently consumed free version should not fail source cleanup");
|
||||
assert!(
|
||||
store.pools[1]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("target metadata should remain readable")
|
||||
.is_none(),
|
||||
"an already consumed free version should not be recreated on the target"
|
||||
);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn decommission_entry_rejects_subquorum_free_version_conflict_and_retains_source() {
|
||||
let temp_dir = tempfile::tempdir().expect("create sub-quorum free-version store dir");
|
||||
let (ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "decommission-free-conflict", &[4, 4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("decom-free-conflict-{}", uuid::Uuid::new_v4());
|
||||
let object = "free-conflict-object";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create sub-quorum conflict bucket");
|
||||
let (_, free_version) = seed_transitioned_free_version(&ctx, &store, &bucket, object).await;
|
||||
let source_free = store.pools[0]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("source free version should decode before crash replay setup")
|
||||
.and_then(|versions| {
|
||||
versions
|
||||
.versions
|
||||
.into_iter()
|
||||
.find(|version| version.version_id == Some(free_version))
|
||||
})
|
||||
.expect("source free version should be available for crash replay setup");
|
||||
|
||||
let mut target_reader = PutObjReader::from_vec(b"target ordinary bytes".to_vec());
|
||||
let target = store.pools[1]
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut target_reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed ordinary target version");
|
||||
let target_version = target.version_id.expect("target version must have an ID");
|
||||
let target_disks = store.pools[1].get_disks_by_key(object).disks.read().await.clone();
|
||||
for disk in target_disks.iter().skip(1) {
|
||||
disk.as_ref()
|
||||
.expect("target crash replay quorum disk should be online")
|
||||
.write_metadata("", &bucket, object, source_free.clone())
|
||||
.await
|
||||
.expect("seed an equivalent free version on the target quorum");
|
||||
}
|
||||
let conflict_path = temp_dir
|
||||
.path()
|
||||
.join(format!("pool1/set0/disk0/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
|
||||
let encoded = tokio::fs::read(&conflict_path)
|
||||
.await
|
||||
.expect("target metadata should be readable");
|
||||
let mut metadata = FileMeta::load(&encoded).expect("target metadata should decode");
|
||||
let target_index = metadata
|
||||
.versions
|
||||
.iter()
|
||||
.position(|version| version.header.version_id == Some(target_version))
|
||||
.expect("target version should be present on the conflict disk");
|
||||
let mut target_meta = metadata.versions[target_index]
|
||||
.parse_version_meta()
|
||||
.expect("target version metadata should decode");
|
||||
target_meta
|
||||
.object
|
||||
.as_mut()
|
||||
.expect("target conflict must remain an ordinary object")
|
||||
.version_id = Some(free_version);
|
||||
metadata.versions[target_index] = target_meta.try_into().expect("conflict metadata should encode");
|
||||
let expected_conflict_meta = metadata.versions[target_index].meta.clone();
|
||||
let expected_conflict = metadata.versions[target_index]
|
||||
.into_fileinfo(&bucket, object, true)
|
||||
.expect("conflict metadata should decode as an ordinary object");
|
||||
let duplicate_free: rustfs_filemeta::FileMetaShallowVersion = rustfs_filemeta::FileMetaVersion::from(source_free.clone())
|
||||
.try_into()
|
||||
.expect("duplicate free metadata should encode");
|
||||
metadata.versions.insert(target_index, duplicate_free);
|
||||
tokio::fs::write(&conflict_path, metadata.marshal_msg().expect("conflict metadata should encode"))
|
||||
.await
|
||||
.expect("write sub-quorum conflict metadata");
|
||||
|
||||
mark_test_pool_decommissioning(&store, 0).await;
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
store
|
||||
.decommission_entry_for_test_with_bucket_incarnation(
|
||||
0,
|
||||
MetaCacheEntry {
|
||||
name: object.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
bucket.clone(),
|
||||
source_set.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("conflicted decommission entry should retain the source and retry later");
|
||||
|
||||
let source_versions = source_set
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("retained source versions should decode")
|
||||
.expect("source free version should be retained after conflict");
|
||||
assert!(
|
||||
source_versions
|
||||
.versions
|
||||
.iter()
|
||||
.any(|version| { version.version_id == Some(free_version) && version.tier_free_version() })
|
||||
);
|
||||
let post_encoded = tokio::fs::read(&conflict_path)
|
||||
.await
|
||||
.expect("conflict metadata should remain readable");
|
||||
let post_metadata = FileMeta::load(&post_encoded).expect("post-conflict metadata should decode");
|
||||
let same_id = post_metadata
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| version.header.version_id == Some(free_version))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(same_id.len(), 2, "conflict metadata should retain both same-ID records");
|
||||
assert_eq!(same_id.iter().filter(|version| version.header.free_version()).count(), 1);
|
||||
assert_eq!(same_id.iter().filter(|version| !version.header.free_version()).count(), 1);
|
||||
let post_conflict = same_id
|
||||
.into_iter()
|
||||
.find(|version| !version.header.free_version())
|
||||
.expect("ordinary conflict version must remain addressable by the source ID");
|
||||
let post_conflict_info = post_conflict
|
||||
.into_fileinfo(&bucket, object, true)
|
||||
.expect("post-conflict ordinary metadata should decode");
|
||||
assert!(!post_conflict_info.deleted && !post_conflict_info.tier_free_version());
|
||||
assert_eq!(post_conflict.meta, expected_conflict_meta);
|
||||
assert_eq!(post_conflict_info.size, expected_conflict.size);
|
||||
assert_eq!(post_conflict_info.data_dir, expected_conflict.data_dir);
|
||||
assert_eq!(post_conflict_info.metadata, expected_conflict.metadata);
|
||||
assert_eq!(post_conflict_info.get_etag(), expected_conflict.get_etag());
|
||||
|
||||
for disk_index in 0..4 {
|
||||
let target_path = temp_dir
|
||||
.path()
|
||||
.join(format!("pool1/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
|
||||
let target_encoded = tokio::fs::read(&target_path)
|
||||
.await
|
||||
.expect("target metadata should remain readable");
|
||||
let target_meta = FileMeta::load(&target_encoded).expect("target metadata should decode");
|
||||
let same_id = target_meta
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| version.header.version_id == Some(free_version))
|
||||
.collect::<Vec<_>>();
|
||||
if disk_index == 0 {
|
||||
assert_eq!(same_id.len(), 2);
|
||||
assert!(same_id[0].header.free_version());
|
||||
assert!(!same_id[1].header.free_version());
|
||||
} else {
|
||||
assert_eq!(same_id.len(), 1);
|
||||
assert!(same_id[0].header.free_version());
|
||||
}
|
||||
}
|
||||
let sweep_err = store
|
||||
.check_after_decommission_for_test(0)
|
||||
.await
|
||||
.expect_err("final sweep must report the retained free version");
|
||||
assert!(
|
||||
sweep_err.to_string().contains("version(s) were found"),
|
||||
"unexpected final sweep error: {sweep_err}"
|
||||
);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn versioned_batch_delete_marker_skips_decommission_source() {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::bucket::metadata_sys::get_versioning_config;
|
||||
use crate::bucket::metadata_sys::{get_versioning_config, has_authoritative_never_versioned_state};
|
||||
use crate::bucket::utils::check_list_objs_args;
|
||||
use crate::bucket::versioning::VersioningApi;
|
||||
use crate::cache_value::metacache_set::{FallbackClaimTracker, ListPathRawOptions, list_path_raw_with_claim_tracker};
|
||||
@@ -261,6 +261,9 @@ pub struct ListPathOptions {
|
||||
// InclDeleted will keep all entries where latest version is a delete marker.
|
||||
pub incl_deleted: bool,
|
||||
|
||||
// Authoritative bucket metadata proves that delete markers cannot exist.
|
||||
pub skip_hidden_prefix_check: bool,
|
||||
|
||||
// Scan recursively.
|
||||
// If false only main directory will be scanned.
|
||||
// Should always be true if Separator is n SlashSeparator.
|
||||
@@ -293,6 +296,16 @@ pub struct ListPathOptions {
|
||||
pub walkdir_stall_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
async fn can_skip_hidden_prefix_check(options: &ListPathOptions) -> bool {
|
||||
if options.recursive || options.incl_deleted || options.versioned {
|
||||
return false;
|
||||
}
|
||||
|
||||
has_authoritative_never_versioned_state(&options.bucket)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
const MARKER_TAG_VERSION: &str = "v2";
|
||||
const LEGACY_MARKER_TAG_VERSIONS: &[&str] = &["v1", MARKER_TAG_VERSION];
|
||||
const LIST_CACHE_MARKER_PREFIX: &str = "[rustfs_cache:";
|
||||
@@ -2523,6 +2536,7 @@ struct ListingSupplementOptions {
|
||||
path: String,
|
||||
recursive: bool,
|
||||
incl_deleted: bool,
|
||||
skip_hidden_prefix_check: bool,
|
||||
filter_prefix: Option<String>,
|
||||
forward_to: Option<String>,
|
||||
per_disk_limit: i32,
|
||||
@@ -2655,6 +2669,7 @@ async fn read_fallback_listing_disk(
|
||||
base_dir: options.path,
|
||||
recursive: options.recursive,
|
||||
incl_deleted: options.incl_deleted,
|
||||
skip_hidden_prefix_check: options.skip_hidden_prefix_check,
|
||||
report_notfound: false,
|
||||
filter_prefix: options.filter_prefix,
|
||||
forward_to: options.forward_to,
|
||||
@@ -4010,6 +4025,8 @@ impl ECStore {
|
||||
o.recursive = true
|
||||
}
|
||||
|
||||
o.skip_hidden_prefix_check = can_skip_hidden_prefix_check(&o).await;
|
||||
|
||||
o.parse_marker();
|
||||
|
||||
if o.base_dir.is_empty() {
|
||||
@@ -4357,6 +4374,7 @@ impl ECStore {
|
||||
path: path.clone(),
|
||||
recursive: true,
|
||||
incl_deleted: !opts.latest_only,
|
||||
skip_hidden_prefix_check: false,
|
||||
filter_prefix: Some(filter_prefix.clone()),
|
||||
forward_to: opts.marker.clone(),
|
||||
per_disk_limit: bounded_usize_to_i32(opts.limit),
|
||||
@@ -5288,6 +5306,8 @@ impl Sets {
|
||||
o.recursive = true;
|
||||
}
|
||||
|
||||
o.skip_hidden_prefix_check = can_skip_hidden_prefix_check(&o).await;
|
||||
|
||||
o.parse_marker();
|
||||
|
||||
if o.base_dir.is_empty() {
|
||||
@@ -5583,6 +5603,7 @@ impl Sets {
|
||||
path: path.clone(),
|
||||
recursive: true,
|
||||
incl_deleted: !opts.latest_only,
|
||||
skip_hidden_prefix_check: false,
|
||||
filter_prefix: Some(filter_prefix.clone()),
|
||||
forward_to: opts.marker.clone(),
|
||||
per_disk_limit: bounded_usize_to_i32(opts.limit),
|
||||
@@ -6338,6 +6359,8 @@ impl SetDisks {
|
||||
o.recursive = true;
|
||||
}
|
||||
|
||||
o.skip_hidden_prefix_check = can_skip_hidden_prefix_check(&o).await;
|
||||
|
||||
o.parse_marker();
|
||||
|
||||
if o.base_dir.is_empty() {
|
||||
@@ -6562,6 +6585,7 @@ impl SetDisks {
|
||||
path: opts.base_dir.clone(),
|
||||
recursive: opts.recursive,
|
||||
incl_deleted: opts.incl_deleted,
|
||||
skip_hidden_prefix_check: opts.skip_hidden_prefix_check,
|
||||
filter_prefix: opts.filter_prefix.clone(),
|
||||
forward_to: opts.marker.clone(),
|
||||
per_disk_limit: limit,
|
||||
@@ -6584,6 +6608,7 @@ impl SetDisks {
|
||||
path: opts.base_dir,
|
||||
recursive: opts.recursive,
|
||||
incl_deleted: opts.incl_deleted,
|
||||
skip_hidden_prefix_check: opts.skip_hidden_prefix_check,
|
||||
filter_prefix: opts.filter_prefix,
|
||||
forward_to: opts.marker,
|
||||
min_disks: raw_min_disks,
|
||||
@@ -6960,6 +6985,7 @@ mod test {
|
||||
path: String::new(),
|
||||
recursive: true,
|
||||
incl_deleted: false,
|
||||
skip_hidden_prefix_check: false,
|
||||
filter_prefix: None,
|
||||
forward_to: None,
|
||||
per_disk_limit: 100,
|
||||
@@ -6976,6 +7002,7 @@ mod test {
|
||||
path: String::new(),
|
||||
recursive: true,
|
||||
incl_deleted: false,
|
||||
skip_hidden_prefix_check: false,
|
||||
filter_prefix: None,
|
||||
forward_to: None,
|
||||
per_disk_limit: 0,
|
||||
@@ -7032,6 +7059,7 @@ mod test {
|
||||
path: String::new(),
|
||||
recursive: true,
|
||||
incl_deleted: false,
|
||||
skip_hidden_prefix_check: false,
|
||||
filter_prefix: None,
|
||||
forward_to: None,
|
||||
per_disk_limit: 0,
|
||||
|
||||
+281
-12
@@ -33,7 +33,7 @@ use crate::bucket::utils::check_put_object_part_args;
|
||||
use crate::bucket::utils::{check_valid_bucket_name, check_valid_bucket_name_strict, is_meta_bucketname};
|
||||
use crate::cluster::rpc::{RemoteClient, S3PeerSys};
|
||||
use crate::config::storageclass;
|
||||
use crate::core::pools::{DecommissionCanceler, PoolMeta};
|
||||
use crate::core::pools::{DecommissionCanceler, PoolMeta, PoolMetaWriteState};
|
||||
use crate::disk::endpoint::{Endpoint, EndpointType};
|
||||
use crate::disk::{DiskAPI, DiskInfo, DiskInfoOptions};
|
||||
use crate::error::{Error, Result};
|
||||
@@ -87,6 +87,8 @@ type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
pub const SCANNER_PUBLICATION_LEASE_TTL_MS: u64 = 60_000;
|
||||
|
||||
/// Check if a directory contains any xl.meta files (indicating actual S3 objects)
|
||||
/// This is used to determine if a bucket is empty for deletion purposes.
|
||||
pub(crate) async fn has_xlmeta_files(path: &std::path::Path) -> std::io::Result<bool> {
|
||||
@@ -151,7 +153,7 @@ pub(crate) mod init_format;
|
||||
pub(crate) mod list_objects;
|
||||
mod multipart;
|
||||
mod object;
|
||||
pub(crate) use object::{ObjectLockDiagGuard, SourceCleanupMutationFence};
|
||||
pub(crate) use object::{ObjectLockDiagGuard, SourceCleanupMutationFence, tiered_data_movement_source_matches};
|
||||
pub use object::{
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
SnapshotConsistencyError,
|
||||
@@ -183,12 +185,12 @@ pub struct ECStore {
|
||||
/// or `decommission_cancelers`. The guarded sections may perform bounded
|
||||
/// async metadata work so check/init/start cannot race across operations.
|
||||
pub(crate) start_gate: Mutex<()>,
|
||||
/// Serializes full-document pool metadata saves.
|
||||
/// Serializes full-document pool metadata saves and retains a fail-closed
|
||||
/// write block after startup observes an unreadable replica.
|
||||
///
|
||||
/// Lock order: acquire `pool_meta_save_gate` without holding `pool_meta`.
|
||||
/// The saver then clones the latest `pool_meta` under a short read lock and
|
||||
/// releases it before awaiting disk writes.
|
||||
pub(crate) pool_meta_save_gate: Mutex<()>,
|
||||
/// Lock order: acquire `pool_meta_save_gate`, then the distributed
|
||||
/// `pool.bin` fence, then clone `pool_meta` under a short read lock.
|
||||
pub(crate) pool_meta_save_gate: Mutex<PoolMetaWriteState>,
|
||||
/// Per-instance runtime state (Phase 5, backlog#939).
|
||||
///
|
||||
/// Carries this instance's identity/runtime out of the process globals so
|
||||
@@ -344,6 +346,33 @@ impl ECStore {
|
||||
decommission || rebalance
|
||||
}
|
||||
|
||||
/// Return the storage-owned movement state and generation as one
|
||||
/// authenticated activity snapshot. The read lock is acquired before
|
||||
/// the state locks (cancelers, pool metadata, then rebalance metadata),
|
||||
/// matching the transition writer order and preventing a terminal state
|
||||
/// from being reported with the preceding generation.
|
||||
pub async fn scanner_data_movement_activity(&self) -> (bool, bool, u64) {
|
||||
let operation_gate = self.ctx.data_movement_operation_gate();
|
||||
let _operation_guard = operation_gate.read_owned().await;
|
||||
let (active, blocked) = self.scanner_data_movement_snapshot_locked().await;
|
||||
let blocked =
|
||||
blocked || self.ctx.data_movement_operation_epoch_exhausted() || self.ctx.data_movement_generation_exhausted();
|
||||
self.ctx.set_scanner_publication_state(blocked);
|
||||
(active, blocked, self.ctx.data_movement_generation())
|
||||
}
|
||||
|
||||
pub fn scanner_data_movement_generation(&self) -> u64 {
|
||||
self.ctx.data_movement_generation()
|
||||
}
|
||||
|
||||
pub fn scanner_data_movement_generation_exhausted(&self) -> bool {
|
||||
self.ctx.data_movement_generation_exhausted()
|
||||
}
|
||||
|
||||
pub fn scanner_data_movement_changed(&self) -> std::sync::Arc<tokio::sync::Notify> {
|
||||
self.ctx.data_movement_generation_notify()
|
||||
}
|
||||
|
||||
/// Returns whether scanner metadata may still be hidden by a local
|
||||
/// data-movement state. Terminal failed/canceled decommission entries
|
||||
/// remain suspended until an operator clears or retries them, so they are
|
||||
@@ -355,10 +384,16 @@ impl ECStore {
|
||||
}
|
||||
|
||||
async fn scanner_data_usage_publication_snapshot_blocked(&self) -> bool {
|
||||
if self.ctx.data_movement_operation_epoch_exhausted() {
|
||||
if self.ctx.data_movement_operation_epoch_exhausted() || self.ctx.data_movement_generation_exhausted() {
|
||||
self.ctx.set_scanner_publication_state(true);
|
||||
return true;
|
||||
}
|
||||
let (_, blocked) = self.scanner_data_movement_snapshot_locked().await;
|
||||
self.ctx.set_scanner_publication_state(blocked);
|
||||
blocked
|
||||
}
|
||||
|
||||
async fn scanner_data_movement_snapshot_locked(&self) -> (bool, bool) {
|
||||
let decommission_cancelers = self.decommission_cancelers.read().await;
|
||||
let decommission_active = decommission_cancelers
|
||||
.iter()
|
||||
@@ -385,8 +420,7 @@ impl ECStore {
|
||||
.is_some_and(is_rebalance_conflicting_with_decommission);
|
||||
|
||||
let blocked = decommission_active || decommission_terminal || rebalance_active;
|
||||
self.ctx.set_scanner_publication_state(blocked);
|
||||
blocked
|
||||
(decommission_active || rebalance_active, blocked)
|
||||
}
|
||||
|
||||
/// Admit one short data-usage publication commit under the same
|
||||
@@ -407,7 +441,7 @@ impl ECStore {
|
||||
pub async fn scanner_data_usage_publication_admission_guard(&self) -> Option<(tokio::sync::OwnedRwLockReadGuard<()>, u64)> {
|
||||
let operation_gate = self.ctx.data_movement_operation_gate();
|
||||
let operation_guard = operation_gate.read_owned().await;
|
||||
if self.ctx.data_movement_operation_epoch_exhausted() {
|
||||
if self.ctx.data_movement_operation_epoch_exhausted() || self.ctx.data_movement_generation_exhausted() {
|
||||
return None;
|
||||
}
|
||||
if self.scanner_data_usage_publication_snapshot_blocked().await {
|
||||
@@ -425,6 +459,98 @@ impl ECStore {
|
||||
drop(operation_guard);
|
||||
Some(epoch)
|
||||
}
|
||||
|
||||
/// Acquire a storage-owned read admission for a coordinator's final
|
||||
/// scanner publication. The guard remains in the context's lease table,
|
||||
/// so a local movement writer cannot pass the peer while its authoritative
|
||||
/// PUT is in flight.
|
||||
pub async fn acquire_scanner_publication_lease(
|
||||
&self,
|
||||
expected_generation: u64,
|
||||
ttl: std::time::Duration,
|
||||
) -> Result<(Uuid, u64)> {
|
||||
if ttl != crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL {
|
||||
return Err(Error::other("scanner publication lease TTL is not supported"));
|
||||
}
|
||||
|
||||
let operation_gate = self.ctx.data_movement_operation_gate();
|
||||
let operation_guard = operation_gate.read_owned().await;
|
||||
if self.ctx.data_movement_generation_exhausted()
|
||||
|| self.ctx.data_movement_operation_epoch_exhausted()
|
||||
|| self.ctx.data_movement_generation() != expected_generation
|
||||
{
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
|
||||
let token = Uuid::new_v4();
|
||||
let expires_at = tokio::time::Instant::now() + ttl;
|
||||
if !self
|
||||
.ctx
|
||||
.install_scanner_publication_lease(token, expires_at, expected_generation, operation_guard)
|
||||
.await
|
||||
{
|
||||
return Err(Error::other("scanner publication lease capacity is exhausted"));
|
||||
}
|
||||
|
||||
let context = Arc::clone(&self.ctx);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep_until(expires_at).await;
|
||||
context.expire_scanner_publication_lease(token, expires_at).await;
|
||||
});
|
||||
Ok((token, expected_generation))
|
||||
}
|
||||
|
||||
pub async fn release_scanner_publication_lease(&self, token: Uuid) -> bool {
|
||||
self.ctx.remove_scanner_publication_lease(token).await
|
||||
}
|
||||
|
||||
/// Revalidate a previously acquired remote publication lease immediately
|
||||
/// before the coordinator's final metadata write. The operation read
|
||||
/// guard makes the movement snapshot and token lookup one storage-owned
|
||||
/// admission; a restarted context has no old token and therefore fails
|
||||
/// closed even if its generation counter has returned to zero.
|
||||
pub async fn validate_scanner_publication_lease(&self, token: Uuid, expected_generation: u64) -> Result<()> {
|
||||
let _operation_guard = self.acquire_scanner_publication_lease_guard(token).await?;
|
||||
if self.ctx.data_movement_generation_exhausted()
|
||||
|| self.ctx.data_movement_operation_epoch_exhausted()
|
||||
|| self.ctx.data_movement_generation() != expected_generation
|
||||
{
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
if !self.ctx.scanner_publication_lease_is_active(token).await {
|
||||
return Err(Error::other("scanner publication lease is unknown or expired"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Acquire the target-side read guard bound to a previously granted lease.
|
||||
/// The guard is returned to the RPC handler and must remain alive through
|
||||
/// the complete rename/write operation. A lease token is process-owned;
|
||||
/// restart, expiry, generation changes, or blocked movement all reject it
|
||||
/// before the target disk is touched.
|
||||
pub async fn acquire_scanner_publication_lease_guard(&self, token: Uuid) -> Result<tokio::sync::OwnedRwLockReadGuard<()>> {
|
||||
let operation_gate = self.ctx.data_movement_operation_gate();
|
||||
let operation_guard = operation_gate.read_owned().await;
|
||||
if self.ctx.data_movement_generation_exhausted() || self.ctx.data_movement_operation_epoch_exhausted() {
|
||||
return Err(Error::other("scanner publication lease generation is exhausted"));
|
||||
}
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
let Some(lease_generation) = self.ctx.scanner_publication_lease_generation(token).await else {
|
||||
return Err(Error::other("scanner publication lease is unknown or expired"));
|
||||
};
|
||||
if lease_generation != self.ctx.data_movement_generation() {
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
Ok(operation_guard)
|
||||
}
|
||||
}
|
||||
|
||||
// impl Clone for ECStore {
|
||||
@@ -988,7 +1114,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: Arc::default(),
|
||||
})
|
||||
@@ -1084,6 +1210,149 @@ mod tests {
|
||||
.await
|
||||
.expect("idle store should admit the next publication");
|
||||
assert_eq!(next_epoch, 1);
|
||||
assert_eq!(store.scanner_data_movement_generation(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn scanner_publication_lease_blocks_movement_writer_until_release_or_expiry() {
|
||||
let store = build_store_with_ctx(Arc::new(InstanceContext::new()));
|
||||
let (token, generation) = store
|
||||
.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("an idle store should grant a publication lease");
|
||||
assert_eq!(generation, 0);
|
||||
|
||||
let gate = store.ctx.data_movement_operation_gate();
|
||||
let (writer_started, writer_started_rx) = tokio::sync::oneshot::channel();
|
||||
let movement_writer = tokio::spawn(async move {
|
||||
let _ = writer_started.send(());
|
||||
gate.write_owned().await
|
||||
});
|
||||
writer_started_rx
|
||||
.await
|
||||
.expect("movement writer should reach the gate before waiting");
|
||||
assert!(
|
||||
!movement_writer.is_finished(),
|
||||
"a movement writer must wait while the remote lease owns the read guard"
|
||||
);
|
||||
|
||||
assert!(store.release_scanner_publication_lease(token).await);
|
||||
tokio::time::timeout(Duration::from_secs(1), movement_writer)
|
||||
.await
|
||||
.expect("movement writer should proceed after lease release")
|
||||
.expect("movement writer task should not panic");
|
||||
|
||||
let (expiring_token, _) = store
|
||||
.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("the store should grant a second publication lease");
|
||||
let expiry_gate = store.ctx.data_movement_operation_gate();
|
||||
let (expiry_started, expiry_started_rx) = tokio::sync::oneshot::channel();
|
||||
let mut expiry_writer = tokio::spawn(async move {
|
||||
let _ = expiry_started.send(());
|
||||
expiry_gate.write_owned().await
|
||||
});
|
||||
expiry_started_rx
|
||||
.await
|
||||
.expect("expiry writer should reach the gate before waiting");
|
||||
assert!(!expiry_writer.is_finished());
|
||||
tokio::time::advance(crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL + Duration::from_millis(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::timeout(Duration::from_secs(1), &mut expiry_writer)
|
||||
.await
|
||||
.expect("movement writer should proceed after lease expiry")
|
||||
.expect("expiry writer task should not panic");
|
||||
assert!(!store.release_scanner_publication_lease(expiring_token).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_publication_lease_rejects_stale_generation_before_install() {
|
||||
let store = build_store_with_ctx(Arc::new(InstanceContext::new()));
|
||||
let error = store
|
||||
.acquire_scanner_publication_lease(1, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect_err("a stale movement generation must not acquire a lease");
|
||||
assert!(error.to_string().contains("generation is stale"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_target_guard_keeps_movement_writer_fenced_after_lease_release() {
|
||||
let store = build_store_with_ctx(Arc::new(InstanceContext::new()));
|
||||
let (token, _) = store
|
||||
.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("an idle store should grant a publication lease");
|
||||
let target_guard = store
|
||||
.acquire_scanner_publication_lease_guard(token)
|
||||
.await
|
||||
.expect("the target-side rename should acquire its short guard");
|
||||
assert!(store.release_scanner_publication_lease(token).await);
|
||||
|
||||
let movement_gate = store.ctx.data_movement_operation_gate();
|
||||
let movement_writer = tokio::spawn(async move { movement_gate.write_owned().await });
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!movement_writer.is_finished(), "the target guard must span the rename operation");
|
||||
drop(target_guard);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), movement_writer)
|
||||
.await
|
||||
.expect("movement writer should proceed after the target rename guard is dropped")
|
||||
.expect("movement writer task should not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_publication_lease_rejects_restart_aba_token() {
|
||||
let first_store = build_store_with_ctx(Arc::new(InstanceContext::new()));
|
||||
let (token, generation) = first_store
|
||||
.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("the initial instance should grant a publication lease");
|
||||
first_store
|
||||
.validate_scanner_publication_lease(token, generation)
|
||||
.await
|
||||
.expect("the current instance should validate its own live token");
|
||||
first_store
|
||||
.acquire_scanner_publication_lease_guard(token)
|
||||
.await
|
||||
.expect("the current instance should admit the target-side rename");
|
||||
|
||||
// A restarted storage instance starts its local generation at zero,
|
||||
// but its process-owned lease table is empty. The old token must not
|
||||
// pass validation just because the numeric generation matches again.
|
||||
let restarted_store = build_store_with_ctx(Arc::new(InstanceContext::new()));
|
||||
let error = restarted_store
|
||||
.validate_scanner_publication_lease(token, generation)
|
||||
.await
|
||||
.expect_err("a token from a prior instance must fail closed after restart");
|
||||
assert!(error.to_string().contains("unknown or expired"));
|
||||
let error = restarted_store
|
||||
.acquire_scanner_publication_lease_guard(token)
|
||||
.await
|
||||
.expect_err("a restarted instance must reject the target-side rename token");
|
||||
assert!(error.to_string().contains("unknown or expired"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn movement_generation_notifies_waiters_and_fails_closed_at_maximum() {
|
||||
let store = build_store_with_ctx(Arc::new(InstanceContext::new()));
|
||||
let notify = store.scanner_data_movement_changed();
|
||||
let notified = notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
|
||||
assert_eq!(store.ctx.advance_data_movement_operation_epoch(), 1);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), notified)
|
||||
.await
|
||||
.expect("movement transition should wake scanner waiters");
|
||||
assert_eq!(store.scanner_data_movement_generation(), 1);
|
||||
|
||||
store.ctx.set_data_movement_generation_for_test(u64::MAX - 1);
|
||||
assert_eq!(store.ctx.advance_data_movement_generation(), Some(u64::MAX));
|
||||
assert!(store.scanner_data_movement_generation_exhausted());
|
||||
assert_eq!(store.ctx.advance_data_movement_generation(), None);
|
||||
assert!(
|
||||
store.scanner_data_usage_publication_admission_guard().await.is_none(),
|
||||
"generation exhaustion must close publication admission"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -960,7 +960,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
}
|
||||
|
||||
@@ -490,6 +490,82 @@ fn decommission_mutation_fence_for_test(
|
||||
.map(|hook| hook.fence.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DecommissionFreeVersionSourceRaceState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct DecommissionFreeVersionSourceRaceBarrier {
|
||||
state: Arc<DecommissionFreeVersionSourceRaceState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static DECOMMISSION_FREE_VERSION_SOURCE_RACE_BARRIER: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<DecommissionFreeVersionSourceRaceState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl DecommissionFreeVersionSourceRaceBarrier {
|
||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
||||
let state = Arc::new(DecommissionFreeVersionSourceRaceState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = DECOMMISSION_FREE_VERSION_SOURCE_RACE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("decommission free-version source race barrier should not poison");
|
||||
assert!(slot.is_none(), "decommission free-version source race barrier must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("decommission should pause before acquiring the free-version source lock");
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for DecommissionFreeVersionSourceRaceBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = DECOMMISSION_FREE_VERSION_SOURCE_RACE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("decommission free-version source race barrier should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_decommission_free_version_before_source_lock(bucket: &str, object: &str) {
|
||||
let state = DECOMMISSION_FREE_VERSION_SOURCE_RACE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("decommission free-version source race barrier should not poison")
|
||||
.as_ref()
|
||||
.filter(|state| state.bucket == bucket && state.object == object)
|
||||
.cloned();
|
||||
if let Some(state) = state {
|
||||
state.arrived.notify_one();
|
||||
state.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SourceCleanupMutationFence {
|
||||
guard: ObjectLockDiagGuard,
|
||||
source_lock_covered: bool,
|
||||
@@ -1495,13 +1571,15 @@ fn is_equivalent_data_movement_tiered_object(source: &rustfs_filemeta::FileInfo,
|
||||
&& source_actual_size == target_actual_size
|
||||
}
|
||||
|
||||
fn tiered_data_movement_source_matches(
|
||||
pub(crate) fn tiered_data_movement_source_matches(
|
||||
expected: &rustfs_filemeta::FileInfo,
|
||||
current: &rustfs_filemeta::FileInfo,
|
||||
) -> Result<bool> {
|
||||
let expected_backend = crate::services::tier::tier::tier_destination_id_from_metadata(&expected.metadata)?;
|
||||
let current_backend = crate::services::tier::tier::tier_destination_id_from_metadata(¤t.metadata)?;
|
||||
Ok(expected.version_id == current.version_id
|
||||
&& expected.deleted == current.deleted
|
||||
&& expected.tier_free_version() == current.tier_free_version()
|
||||
&& expected.data_dir == current.data_dir
|
||||
&& expected.mod_time == current.mod_time
|
||||
&& expected.size == current.size
|
||||
@@ -1515,6 +1593,14 @@ fn tiered_data_movement_source_matches(
|
||||
&& expected_backend == current_backend)
|
||||
}
|
||||
|
||||
fn decommission_free_version_overwrite_error(bucket: &str, object: &str, version_id: Option<Uuid>) -> Error {
|
||||
StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
version_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn should_check_data_movement_resume_target(src_pool_idx: usize, target_pool_idx: usize) -> bool {
|
||||
target_pool_idx != src_pool_idx
|
||||
}
|
||||
@@ -2222,6 +2308,23 @@ impl ECStore {
|
||||
)
|
||||
}
|
||||
|
||||
async fn has_equivalent_data_movement_tier_free_version(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
source: &rustfs_filemeta::FileInfo,
|
||||
opts: &ObjectOptions,
|
||||
target_pool_idx: usize,
|
||||
) -> Result<bool> {
|
||||
let pool = self
|
||||
.pools
|
||||
.get(target_pool_idx)
|
||||
.ok_or_else(|| Error::other(format!("invalid tiered data movement target pool {target_pool_idx}")))?;
|
||||
pool.get_disks_by_key(object)
|
||||
.has_decommission_tier_free_version_write_quorum(bucket, object, source, opts)
|
||||
.await
|
||||
}
|
||||
|
||||
fn resolve_decommission_target_pool_idx_result(result: Result<usize>, bucket: &str, object: &str) -> Result<usize> {
|
||||
result.map_err(|err| Error::other(format!("failed to select decommission target pool for {bucket}/{object}: {err}")))
|
||||
}
|
||||
@@ -2241,6 +2344,10 @@ impl ECStore {
|
||||
check_put_object_args(bucket, object)?;
|
||||
|
||||
let mut opts = opts.clone();
|
||||
let is_free_version = fi.tier_free_version();
|
||||
if is_free_version {
|
||||
opts.incl_free_versions = true;
|
||||
}
|
||||
let bucket_incarnation_fence = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
@@ -2278,6 +2385,10 @@ impl ECStore {
|
||||
&object,
|
||||
)?
|
||||
};
|
||||
#[cfg(test)]
|
||||
if is_free_version {
|
||||
pause_decommission_free_version_before_source_lock(bucket, logical_object).await;
|
||||
}
|
||||
let _object_guards = self
|
||||
.acquire_data_movement_object_write_locks(bucket, &object, opts.src_pool_idx, idx, &mut opts)
|
||||
.await?;
|
||||
@@ -2295,7 +2406,7 @@ impl ECStore {
|
||||
versions
|
||||
.versions
|
||||
.iter()
|
||||
.find(|current| current.version_id == fi.version_id && !current.tier_free_version())
|
||||
.find(|current| current.version_id == fi.version_id && current.tier_free_version() == is_free_version)
|
||||
})
|
||||
.ok_or_else(|| to_object_err(StorageError::FileNotFound, vec![bucket, object.as_str()]))?;
|
||||
if !tiered_data_movement_source_matches(fi, current_source)? {
|
||||
@@ -2310,24 +2421,34 @@ impl ECStore {
|
||||
.get_available_pool_idx_excluding(bucket, &object, fi.size, opts.src_pool_idx)
|
||||
.await;
|
||||
let target_pool_idx = resolve_data_movement_resume_target_pool(idx, resume_target_pool_idx, opts.src_pool_idx);
|
||||
if self
|
||||
.has_equivalent_data_movement_tiered_object(bucket, &object, &fi, &opts, target_pool_idx)
|
||||
.await?
|
||||
{
|
||||
if is_free_version && target_pool_idx == opts.src_pool_idx {
|
||||
return Err(Error::DiskFull);
|
||||
}
|
||||
let equivalent = if is_free_version {
|
||||
self.has_equivalent_data_movement_tier_free_version(bucket, &object, &fi, &opts, target_pool_idx)
|
||||
.await?
|
||||
} else {
|
||||
self.has_equivalent_data_movement_tiered_object(bucket, &object, &fi, &opts, target_pool_idx)
|
||||
.await?
|
||||
};
|
||||
if equivalent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
opts.version_id.clone().unwrap_or_default(),
|
||||
));
|
||||
return Err(decommission_free_version_overwrite_error(bucket, &object, fi.version_id));
|
||||
}
|
||||
|
||||
let result = self.pools[idx]
|
||||
.get_disks_by_key(&object)
|
||||
.decommission_tiered_object(bucket, &object, &fi, &opts)
|
||||
.await;
|
||||
let result = if is_free_version {
|
||||
self.pools[idx]
|
||||
.get_disks_by_key(&object)
|
||||
.decommission_tier_free_version(bucket, &object, &fi, &opts)
|
||||
.await
|
||||
} else {
|
||||
self.pools[idx]
|
||||
.get_disks_by_key(&object)
|
||||
.decommission_tiered_object(bucket, &object, &fi, &opts)
|
||||
.await
|
||||
};
|
||||
if matches!(result, Err(Error::PreconditionFailed)) {
|
||||
if self
|
||||
.has_equivalent_data_movement_tiered_object(bucket, &object, &fi, &opts, idx)
|
||||
@@ -2587,13 +2708,13 @@ impl ECStore {
|
||||
};
|
||||
|
||||
if cp_src_dst_same {
|
||||
let pool_idx = self
|
||||
.get_pool_info_existing_with_opts(src_bucket, &src_object, &writer_pool_lookup_opts(src_opts, true))
|
||||
.await?
|
||||
.0
|
||||
.index;
|
||||
let (_, pool_idx) = self
|
||||
.get_latest_accessible_object_info_with_idx(src_bucket, &src_object, &version_aware_lookup_opts(src_opts, true))
|
||||
.await?;
|
||||
let source_pool_writable = !self.is_suspended(pool_idx).await && !self.is_pool_rebalancing(pool_idx).await;
|
||||
|
||||
if let (Some(src_vid), Some(dst_vid)) = (&src_opts.version_id, &dst_opts.version_id)
|
||||
if source_pool_writable
|
||||
&& let (Some(src_vid), Some(dst_vid)) = (&src_opts.version_id, &dst_opts.version_id)
|
||||
&& src_vid == dst_vid
|
||||
{
|
||||
return self.pools[pool_idx]
|
||||
@@ -2601,7 +2722,7 @@ impl ECStore {
|
||||
.await;
|
||||
}
|
||||
|
||||
if !dst_opts.versioned && src_opts.version_id.is_none() {
|
||||
if source_pool_writable && !dst_opts.versioned && src_opts.version_id.is_none() {
|
||||
if src_info.metadata_only {
|
||||
// Zero-copy update: only xl.meta is rewritten, the data blocks stay as they
|
||||
// are. The caller must therefore guarantee that the destination metadata
|
||||
@@ -2644,7 +2765,7 @@ impl ECStore {
|
||||
};
|
||||
}
|
||||
|
||||
if dst_opts.versioned && src_opts.version_id != dst_opts.version_id {
|
||||
if source_pool_writable && dst_opts.versioned && src_opts.version_id != dst_opts.version_id {
|
||||
// Restoring a specific historical version onto the current key creates a NEW
|
||||
// version. When the caller supplies a reader (S3 CopyObject), write the fetched
|
||||
// bytes through put_object so any re-encryption/compression applied to the reader
|
||||
@@ -5328,7 +5449,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
}
|
||||
@@ -5391,7 +5512,7 @@ mod tests {
|
||||
rebalance_meta: RwLock::new(None),
|
||||
decommission_cancelers: RwLock::new(Vec::new()),
|
||||
start_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::new(()),
|
||||
pool_meta_save_gate: Mutex::default(),
|
||||
ctx,
|
||||
bucket_fence_registry: std::sync::Arc::default(),
|
||||
}
|
||||
|
||||
@@ -23,10 +23,13 @@ pub(in crate::store) mod support;
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_POOLS: &str = "pools";
|
||||
const EVENT_POOL_META_RELOAD: &str = "pool_meta_reload";
|
||||
#[cfg(test)]
|
||||
use support::resolve_latest_object_info_candidates;
|
||||
use support::{
|
||||
LatestObjectInfoCandidate, PoolErr, PoolObjInfo, RebalanceDeletePoolResult, pool_lookup_not_found_error,
|
||||
rebalance_disk_set_lookup_error, resolve_latest_object_info_candidates, resolve_rebalance_delete_from_all_pools_result,
|
||||
resolve_rebalance_delete_from_all_pools_results, resolve_store_rebalance_pool_meta_reload_result,
|
||||
rebalance_disk_set_lookup_error, resolve_latest_object_info_candidates_with_pool_state,
|
||||
resolve_rebalance_delete_from_all_pools_result, resolve_rebalance_delete_from_all_pools_results,
|
||||
resolve_store_rebalance_pool_meta_reload_result,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Eq, PartialEq)]
|
||||
@@ -611,9 +614,19 @@ impl ECStore {
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(ObjectInfo, usize)> {
|
||||
let suspended_pools = if opts.skip_decommissioned {
|
||||
let pool_meta = self.pool_meta.read().await;
|
||||
Some(
|
||||
(0..self.pools.len())
|
||||
.map(|idx| pool_meta.is_suspended(idx))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut futures = Vec::with_capacity(self.pools.len());
|
||||
for (idx, pool) in self.pools.iter().enumerate() {
|
||||
if opts.skip_decommissioned && self.is_suspended(idx).await {
|
||||
if suspended_pools.as_ref().is_some_and(|pools| pools[idx]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -646,10 +659,20 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
let suspended_pools = match suspended_pools {
|
||||
Some(pools) => pools,
|
||||
None => {
|
||||
let pool_meta = self.pool_meta.read().await;
|
||||
(0..self.pools.len())
|
||||
.map(|idx| pool_meta.is_suspended(idx))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
};
|
||||
|
||||
// Delete markers are returned as latest object infos here. Higher-level
|
||||
// access paths are responsible for translating them into read/write
|
||||
// semantics such as object-not-found or method-not-allowed.
|
||||
resolve_latest_object_info_candidates(candidates, bucket, object, opts)
|
||||
resolve_latest_object_info_candidates_with_pool_state(candidates, &suspended_pools, bucket, object, opts)
|
||||
}
|
||||
|
||||
pub(super) async fn delete_object_from_all_pools(
|
||||
@@ -699,9 +722,8 @@ impl ECStore {
|
||||
// overwrite a newer local transition after the writer commits.
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
let mut reloaded = PoolMeta::default();
|
||||
resolve_store_rebalance_pool_meta_reload_result(
|
||||
reloaded.load(self.pools[0].clone(), self.pools.clone()).await,
|
||||
let reloaded = resolve_store_rebalance_pool_meta_reload_result(
|
||||
self.load_runtime_pool_meta("store rebalance pool meta reload failed").await,
|
||||
"reload_pool_meta",
|
||||
)?;
|
||||
|
||||
@@ -910,11 +932,12 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::bucket::replication::{ReplicationStatusType, VersionPurgeStatusType};
|
||||
use crate::config::storageclass::{CLASS_RRS, CLASS_STANDARD, lookup_config_for_pools_without_env};
|
||||
use crate::core::pools::{POOL_META_VERSION, PoolDecommissionInfo, PoolStatus};
|
||||
use crate::core::pools::{POOL_META_NAME, POOL_META_VERSION, PoolDecommissionInfo, PoolStatus};
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::object_api::ObjectLockConfigSnapshot;
|
||||
use crate::set_disk::get_lock_acquire_timeout;
|
||||
use crate::storage_api_contracts::bucket::MakeBucketOptions;
|
||||
use crate::storage_api_contracts::object::ObjectIO as _;
|
||||
use arc_swap::ArcSwap;
|
||||
@@ -1526,6 +1549,83 @@ mod tests {
|
||||
assert_eq!(idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_latest_object_info_candidates_prefers_active_target_over_higher_suspended_source() {
|
||||
let source = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
||||
let target = source.clone();
|
||||
|
||||
let (info, idx) = resolve_latest_object_info_candidates_with_pool_state(
|
||||
vec![
|
||||
LatestObjectInfoCandidate {
|
||||
info: Some(target),
|
||||
idx: 0,
|
||||
err: None,
|
||||
},
|
||||
LatestObjectInfoCandidate {
|
||||
info: Some(source),
|
||||
idx: 1,
|
||||
err: None,
|
||||
},
|
||||
],
|
||||
&[false, true],
|
||||
"bucket",
|
||||
"object",
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.expect("an active committed target should fence an equivalent suspended source");
|
||||
|
||||
assert_eq!(idx, 0);
|
||||
assert_eq!(info.version_id, Some(Uuid::from_u128(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_latest_object_info_candidates_keeps_lone_suspended_source_readable() {
|
||||
let source = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-a".to_string()));
|
||||
|
||||
let (_, idx) = resolve_latest_object_info_candidates_with_pool_state(
|
||||
vec![LatestObjectInfoCandidate {
|
||||
info: Some(source),
|
||||
idx: 1,
|
||||
err: None,
|
||||
}],
|
||||
&[false, true],
|
||||
"bucket",
|
||||
"object",
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.expect("a source-only object must remain readable before migration commits");
|
||||
|
||||
assert_eq!(idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_latest_object_info_candidates_rejects_suspended_source_identity_conflict() {
|
||||
let target = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-new".to_string()));
|
||||
let source = object_info_with_identity(10, false, Uuid::from_u128(1), Some("etag-old".to_string()));
|
||||
|
||||
let err = resolve_latest_object_info_candidates_with_pool_state(
|
||||
vec![
|
||||
LatestObjectInfoCandidate {
|
||||
info: Some(target),
|
||||
idx: 0,
|
||||
err: None,
|
||||
},
|
||||
LatestObjectInfoCandidate {
|
||||
info: Some(source),
|
||||
idx: 1,
|
||||
err: None,
|
||||
},
|
||||
],
|
||||
&[false, true],
|
||||
"bucket",
|
||||
"object",
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.expect_err("pool state must not mask an equal-time identity conflict");
|
||||
|
||||
assert_eq!(err, Error::ErasureReadQuorum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_latest_object_info_candidates_keeps_index_fallback_for_fully_equivalent_identities() {
|
||||
let candidates = vec![
|
||||
@@ -2022,7 +2122,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_store_rebalance_pool_meta_reload_result_wraps_error_context() {
|
||||
let err = resolve_store_rebalance_pool_meta_reload_result(Err(Error::SlowDown), "reload_pool_meta")
|
||||
let err = resolve_store_rebalance_pool_meta_reload_result::<()>(Err(Error::SlowDown), "reload_pool_meta")
|
||||
.expect_err("failed pool meta reload should be wrapped");
|
||||
let err_message = err.to_string();
|
||||
assert!(err_message.contains("store rebalance pool meta reload failed during reload_pool_meta"));
|
||||
@@ -2221,6 +2321,60 @@ mod tests {
|
||||
.expect("pool meta snapshot should persist to every pool");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn pool_meta_runtime_load_waits_for_multi_pool_commit_fence() {
|
||||
let (_temp_dir, store, shutdown) = setup_multi_pool_test_store("pool-meta-load-fence", &[2, 2]).await;
|
||||
let old = PoolMeta::new(&store.pools, &PoolMeta::default());
|
||||
old.save(store.pools.clone()).await.expect("old pool metadata should persist");
|
||||
let mut newer = old.clone();
|
||||
newer.pools[0].last_update += TimeDuration::seconds(1);
|
||||
|
||||
let pool_meta_lock = store.pools[0]
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||
.await
|
||||
.expect("pool metadata lock should be created");
|
||||
let pool_meta_guard = pool_meta_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("pool metadata write fence should be acquired");
|
||||
newer
|
||||
.save_for_startup(vec![store.pools[0].clone()])
|
||||
.await
|
||||
.expect("first replica should enter the new generation");
|
||||
|
||||
let started = Arc::new(tokio::sync::Notify::new());
|
||||
let mut load_task = tokio::spawn({
|
||||
let store = store.clone();
|
||||
let started = started.clone();
|
||||
async move {
|
||||
started.notify_one();
|
||||
store.load_runtime_pool_meta("test runtime pool metadata load").await
|
||||
}
|
||||
});
|
||||
started.notified().await;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), &mut load_task)
|
||||
.await
|
||||
.is_err(),
|
||||
"runtime load must not observe a valid-old/valid-new intermediate state"
|
||||
);
|
||||
|
||||
newer
|
||||
.save_for_startup(vec![store.pools[1].clone()])
|
||||
.await
|
||||
.expect("second replica should enter the new generation");
|
||||
drop(pool_meta_guard);
|
||||
|
||||
let loaded = tokio::time::timeout(std::time::Duration::from_secs(5), load_task)
|
||||
.await
|
||||
.expect("runtime load should finish after commit publication")
|
||||
.expect("runtime load task should not panic")
|
||||
.expect("runtime load should select the completed snapshot");
|
||||
assert_eq!(loaded.pools[0].last_update, newer.pools[0].last_update);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn peer_pool_meta_reload_does_not_rollback_newer_local_states() {
|
||||
|
||||
@@ -67,7 +67,7 @@ pub(super) fn pool_lookup_not_found_error(bucket: &str, object: &str, opts: &Obj
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_store_rebalance_pool_meta_reload_result(result: Result<()>, stage: &str) -> Result<()> {
|
||||
pub(super) fn resolve_store_rebalance_pool_meta_reload_result<T>(result: Result<T>, stage: &str) -> Result<T> {
|
||||
result.map_err(|err| Error::other(format!("store rebalance pool meta reload failed during {stage}: {err}")))
|
||||
}
|
||||
|
||||
@@ -277,11 +277,22 @@ fn same_latest_object_info_identity(left: &ObjectInfo, right: &ObjectInfo) -> bo
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn resolve_latest_object_info_candidates(
|
||||
candidates: Vec<LatestObjectInfoCandidate>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(ObjectInfo, usize)> {
|
||||
resolve_latest_object_info_candidates_with_pool_state(candidates, &[], bucket, object, opts)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_latest_object_info_candidates_with_pool_state(
|
||||
candidates: Vec<LatestObjectInfoCandidate>,
|
||||
suspended_pools: &[bool],
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(ObjectInfo, usize)> {
|
||||
let latest_mod_time = candidates.iter().filter_map(latest_candidate_mod_time).max();
|
||||
|
||||
@@ -291,7 +302,15 @@ pub(super) fn resolve_latest_object_info_candidates(
|
||||
.filter(|candidate| latest_candidate_mod_time(candidate) == Some(latest_mod_time))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
latest_candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.idx));
|
||||
// A decommission source remains readable until its target commits. Once
|
||||
// equivalent copies exist, prefer the active target without hiding a
|
||||
// same-time identity conflict behind pool state.
|
||||
latest_candidates.sort_by_key(|candidate| {
|
||||
(
|
||||
suspended_pools.get(candidate.idx).copied().unwrap_or(false),
|
||||
std::cmp::Reverse(candidate.idx),
|
||||
)
|
||||
});
|
||||
|
||||
let Some(winner) = latest_candidates.first() else {
|
||||
return Err(Error::ErasureReadQuorum);
|
||||
|
||||
@@ -90,6 +90,21 @@ fn legacy_data_key_for_version(version_id: Option<Uuid>) -> Option<String> {
|
||||
pub const TRANSITION_COMPLETE: &str = "complete";
|
||||
pub const TRANSITION_PENDING: &str = "pending";
|
||||
|
||||
/// xl.meta key marking a tier free-version record.
|
||||
///
|
||||
/// A free version is a delete-marker-shaped cleanup hint appended by
|
||||
/// [`MetaObject::delete_version`] when a version whose remote transition
|
||||
/// completed is removed from xl.meta; it carries the remote tier identity for
|
||||
/// an idempotent remote delete and is never a user-visible version
|
||||
/// (`num_versions` excludes it). While the record exists it is consumed by the
|
||||
/// lifecycle free-version recovery scan and the usage scanner, which re-enqueue
|
||||
/// the pending remote delete, and by heal metadata walks. On S3 and lifecycle
|
||||
/// delete paths the same obligation is also carried by a committed tier-journal
|
||||
/// entry; deletes without such an entry (for example a removed version whose
|
||||
/// transition state decodes as unknown) rely on this record alone until the
|
||||
/// worker removes it after a successful remote delete. Decommission preserves
|
||||
/// the record and its remote identity on the target pool before source cleanup
|
||||
/// — see docs/architecture/decommission-compatibility.md.
|
||||
pub const FREE_VERSION: &str = "free-version";
|
||||
|
||||
pub const TRANSITION_STATUS: &str = "transition-status";
|
||||
@@ -447,6 +462,10 @@ impl FileMeta {
|
||||
};
|
||||
|
||||
if let Some(fidx) = existing_idx {
|
||||
let existing = self.versions[fidx].parse_version_meta()?;
|
||||
if existing.free_version() != version.free_version() {
|
||||
return Err(Error::other("cannot replace a free version with a non-free version"));
|
||||
}
|
||||
return self.set_idx(fidx, version);
|
||||
}
|
||||
|
||||
@@ -1363,6 +1382,39 @@ mod test {
|
||||
assert!(fm.versions[0].header.sorts_before(&fm.versions[1].header));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_rejects_free_and_ordinary_same_id_replacement() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let mut free_meta_sys = HashMap::new();
|
||||
insert_bytes(&mut free_meta_sys, rustfs_utils::http::SUFFIX_FREE_VERSION, Vec::new());
|
||||
let free_version = FileMetaVersion {
|
||||
version_type: VersionType::Delete,
|
||||
delete_marker: Some(MetaDeleteMarker {
|
||||
version_id: Some(version_id),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
meta_sys: free_meta_sys,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let ordinary_version = valid_object_version(version_id, vec![10, 20]);
|
||||
|
||||
for (existing, replacement) in [
|
||||
(free_version.clone(), ordinary_version.clone()),
|
||||
(ordinary_version, free_version),
|
||||
] {
|
||||
let mut fm = FileMeta::new();
|
||||
fm.add_version_filemata(existing).expect("seed same-id version");
|
||||
let before = fm.marshal_msg().expect("serialize original metadata");
|
||||
|
||||
let err = fm
|
||||
.add_version_filemata(replacement)
|
||||
.expect_err("free and ordinary versions with the same ID must not replace each other");
|
||||
|
||||
assert!(err.to_string().contains("cannot replace a free version"));
|
||||
assert_eq!(fm.marshal_msg().expect("serialize rejected metadata"), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// `add_version_filemata` positions an inserted version with
|
||||
/// `partition_point(sorts_before)`, which only yields the canonical slot when
|
||||
/// `versions` is already canonically ordered. Nothing establishes that
|
||||
|
||||
@@ -2725,6 +2725,15 @@ impl MetaObject {
|
||||
self.meta_sys.retain(|k, _| !k.starts_with("X-Amz-Restore"));
|
||||
}
|
||||
|
||||
/// Builds the free-version cleanup record appended when a transitioned
|
||||
/// version is removed from xl.meta. The record keeps the remote tier
|
||||
/// identity so the lifecycle worker can issue the idempotent remote delete
|
||||
/// and only then remove the record; until then the recovery scan and the
|
||||
/// usage scanner keep re-enqueueing it. S3 and lifecycle deletes also
|
||||
/// persist a committed tier-journal entry for the same remote delete. The
|
||||
/// decommission path copies this record unchanged before source cleanup,
|
||||
/// including when the transition state is unknown — see
|
||||
/// docs/architecture/decommission-compatibility.md.
|
||||
pub fn init_free_version(&self, fi: &FileInfo) -> Result<(FileMetaVersion, bool)> {
|
||||
if fi.skip_tier_free_version() {
|
||||
return Ok((FileMetaVersion::default(), false));
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod error;
|
||||
pub mod heal;
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
//! HS-01 (rustfs/backlog#1865): MRF intent pipeline integration tests.
|
||||
//!
|
||||
//! Drives the real consumer loop (`spawn_mrf_consumer`) against a real
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::model::LogLevel;
|
||||
pub(super) fn rules() -> Vec<Rule> {
|
||||
vec![
|
||||
Rule {
|
||||
anchors: strings(["decommission_object err"]),
|
||||
anchors: strings(["Decommission object migration failed"]),
|
||||
min_count: 3,
|
||||
..base(
|
||||
"decom-object-failed",
|
||||
@@ -29,6 +29,7 @@ pub(super) fn rules() -> Vec<Rule> {
|
||||
"ops",
|
||||
"下线迁移部分对象失败",
|
||||
any([
|
||||
contains("Decommission object migration failed"),
|
||||
contains("decommission_object err"),
|
||||
contains("get_object_reader err"),
|
||||
contains("decommission_entry failed"),
|
||||
|
||||
@@ -265,7 +265,7 @@ fn every_rule_has_a_positive_sample() {
|
||||
msg("failed to start decommission: insufficient target pool capacity: required 100 bytes available 50 bytes"),
|
||||
),
|
||||
// ops
|
||||
("decom-object-failed", msg("decommission_pool: decommission_object err timeout")),
|
||||
("decom-object-failed", msg("Decommission object migration failed")),
|
||||
("rebalance-worker-error", msg("Rebalance worker 3 error: disk gone")),
|
||||
(
|
||||
"datamove-same-pool",
|
||||
|
||||
@@ -142,6 +142,9 @@ pub struct DeleteRequest {
|
||||
pub path: ::prost::alloc::string::String,
|
||||
#[prost(string, tag = "4")]
|
||||
pub options: ::prost::alloc::string::String,
|
||||
/// Optional scanner publication lease token.
|
||||
#[prost(bytes = "bytes", tag = "5")]
|
||||
pub scanner_publication_lease_token: ::prost::bytes::Bytes,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct DeleteResponse {
|
||||
@@ -393,6 +396,8 @@ pub struct RenameDataRequest {
|
||||
pub dst_path: ::prost::alloc::string::String,
|
||||
#[prost(bytes = "bytes", tag = "7")]
|
||||
pub file_info_bin: ::prost::bytes::Bytes,
|
||||
#[prost(bytes = "bytes", tag = "8")]
|
||||
pub scanner_publication_lease_token: ::prost::bytes::Bytes,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct RenameDataResponse {
|
||||
@@ -1213,6 +1218,62 @@ pub struct ScannerActivityResponse {
|
||||
pub dirty_usage_generation: u64,
|
||||
#[prost(bool, tag = "9")]
|
||||
pub dirty_usage_pending: bool,
|
||||
#[prost(uint64, optional, tag = "10")]
|
||||
pub movement_generation: ::core::option::Option<u64>,
|
||||
#[prost(bool, optional, tag = "11")]
|
||||
pub publication_blocked: ::core::option::Option<bool>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ScannerPublicationLeaseRequest {
|
||||
#[prost(bytes = "bytes", tag = "1")]
|
||||
pub challenge: ::prost::bytes::Bytes,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub expected_movement_generation: u64,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub ttl_ms: u64,
|
||||
#[prost(string, tag = "4")]
|
||||
pub expected_session_id: ::prost::alloc::string::String,
|
||||
#[prost(bytes = "bytes", tag = "5")]
|
||||
pub token: ::prost::bytes::Bytes,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ScannerPublicationLeaseResponse {
|
||||
#[prost(bool, tag = "1")]
|
||||
pub success: bool,
|
||||
#[prost(bytes = "bytes", tag = "2")]
|
||||
pub token: ::prost::bytes::Bytes,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub movement_generation: u64,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub lease_ttl_ms: u64,
|
||||
#[prost(message, optional, tag = "5")]
|
||||
pub error: ::core::option::Option<Error>,
|
||||
#[prost(bytes = "bytes", tag = "6")]
|
||||
pub response_proof: ::prost::bytes::Bytes,
|
||||
#[prost(string, tag = "7")]
|
||||
pub owner_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag = "8")]
|
||||
pub session_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ScannerPublicationLeaseReleaseRequest {
|
||||
#[prost(bytes = "bytes", tag = "1")]
|
||||
pub challenge: ::prost::bytes::Bytes,
|
||||
#[prost(bytes = "bytes", tag = "2")]
|
||||
pub token: ::prost::bytes::Bytes,
|
||||
#[prost(string, tag = "3")]
|
||||
pub owner_id: ::prost::alloc::string::String,
|
||||
#[prost(string, tag = "4")]
|
||||
pub session_id: ::prost::alloc::string::String,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct ScannerPublicationLeaseReleaseResponse {
|
||||
#[prost(bool, tag = "1")]
|
||||
pub success: bool,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub error: ::core::option::Option<Error>,
|
||||
#[prost(bytes = "bytes", tag = "3")]
|
||||
pub response_proof: ::prost::bytes::Bytes,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
|
||||
pub struct BackgroundHealStatusRequest {
|
||||
@@ -2695,6 +2756,36 @@ pub mod node_service_client {
|
||||
.insert(GrpcMethod::new("node_service.NodeService", "ScannerActivity"));
|
||||
self.inner.unary(req, path, codec).await
|
||||
}
|
||||
pub async fn acquire_scanner_publication_lease(
|
||||
&mut self,
|
||||
request: impl tonic::IntoRequest<super::ScannerPublicationLeaseRequest>,
|
||||
) -> std::result::Result<tonic::Response<super::ScannerPublicationLeaseResponse>, tonic::Status> {
|
||||
self.inner
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let path = http::uri::PathAndQuery::from_static("/node_service.NodeService/AcquireScannerPublicationLease");
|
||||
let mut req = request.into_request();
|
||||
req.extensions_mut()
|
||||
.insert(GrpcMethod::new("node_service.NodeService", "AcquireScannerPublicationLease"));
|
||||
self.inner.unary(req, path, codec).await
|
||||
}
|
||||
pub async fn release_scanner_publication_lease(
|
||||
&mut self,
|
||||
request: impl tonic::IntoRequest<super::ScannerPublicationLeaseReleaseRequest>,
|
||||
) -> std::result::Result<tonic::Response<super::ScannerPublicationLeaseReleaseResponse>, tonic::Status> {
|
||||
self.inner
|
||||
.ready()
|
||||
.await
|
||||
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let path = http::uri::PathAndQuery::from_static("/node_service.NodeService/ReleaseScannerPublicationLease");
|
||||
let mut req = request.into_request();
|
||||
req.extensions_mut()
|
||||
.insert(GrpcMethod::new("node_service.NodeService", "ReleaseScannerPublicationLease"));
|
||||
self.inner.unary(req, path, codec).await
|
||||
}
|
||||
pub async fn background_heal_status(
|
||||
&mut self,
|
||||
request: impl tonic::IntoRequest<super::BackgroundHealStatusRequest>,
|
||||
@@ -3208,6 +3299,18 @@ pub mod node_service_server {
|
||||
&self,
|
||||
request: tonic::Request<super::ScannerActivityRequest>,
|
||||
) -> std::result::Result<tonic::Response<super::ScannerActivityResponse>, tonic::Status>;
|
||||
async fn acquire_scanner_publication_lease(
|
||||
&self,
|
||||
_request: tonic::Request<super::ScannerPublicationLeaseRequest>,
|
||||
) -> std::result::Result<tonic::Response<super::ScannerPublicationLeaseResponse>, tonic::Status> {
|
||||
Err(tonic::Status::unimplemented("scanner publication leases are unsupported"))
|
||||
}
|
||||
async fn release_scanner_publication_lease(
|
||||
&self,
|
||||
_request: tonic::Request<super::ScannerPublicationLeaseReleaseRequest>,
|
||||
) -> std::result::Result<tonic::Response<super::ScannerPublicationLeaseReleaseResponse>, tonic::Status> {
|
||||
Err(tonic::Status::unimplemented("scanner publication leases are unsupported"))
|
||||
}
|
||||
async fn background_heal_status(
|
||||
&self,
|
||||
request: tonic::Request<super::BackgroundHealStatusRequest>,
|
||||
@@ -5488,6 +5591,67 @@ pub mod node_service_server {
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
"/node_service.NodeService/AcquireScannerPublicationLease" => {
|
||||
#[allow(non_camel_case_types)]
|
||||
struct AcquireScannerPublicationLeaseSvc<T: NodeService>(pub Arc<T>);
|
||||
impl<T: NodeService> tonic::server::UnaryService<super::ScannerPublicationLeaseRequest> for AcquireScannerPublicationLeaseSvc<T> {
|
||||
type Response = super::ScannerPublicationLeaseResponse;
|
||||
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
|
||||
fn call(&mut self, request: tonic::Request<super::ScannerPublicationLeaseRequest>) -> Self::Future {
|
||||
let inner = Arc::clone(&self.0);
|
||||
let fut = async move { <T as NodeService>::acquire_scanner_publication_lease(&inner, request).await };
|
||||
Box::pin(fut)
|
||||
}
|
||||
}
|
||||
let accept_compression_encodings = self.accept_compression_encodings;
|
||||
let send_compression_encodings = self.send_compression_encodings;
|
||||
let max_decoding_message_size = self.max_decoding_message_size;
|
||||
let max_encoding_message_size = self.max_encoding_message_size;
|
||||
let inner = self.inner.clone();
|
||||
let fut = async move {
|
||||
let method = AcquireScannerPublicationLeaseSvc(inner);
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let mut grpc = tonic::server::Grpc::new(codec)
|
||||
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
|
||||
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
|
||||
let res = grpc.unary(method, req).await;
|
||||
Ok(res)
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
"/node_service.NodeService/ReleaseScannerPublicationLease" => {
|
||||
#[allow(non_camel_case_types)]
|
||||
struct ReleaseScannerPublicationLeaseSvc<T: NodeService>(pub Arc<T>);
|
||||
impl<T: NodeService> tonic::server::UnaryService<super::ScannerPublicationLeaseReleaseRequest>
|
||||
for ReleaseScannerPublicationLeaseSvc<T>
|
||||
{
|
||||
type Response = super::ScannerPublicationLeaseReleaseResponse;
|
||||
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
|
||||
fn call(
|
||||
&mut self,
|
||||
request: tonic::Request<super::ScannerPublicationLeaseReleaseRequest>,
|
||||
) -> Self::Future {
|
||||
let inner = Arc::clone(&self.0);
|
||||
let fut = async move { <T as NodeService>::release_scanner_publication_lease(&inner, request).await };
|
||||
Box::pin(fut)
|
||||
}
|
||||
}
|
||||
let accept_compression_encodings = self.accept_compression_encodings;
|
||||
let send_compression_encodings = self.send_compression_encodings;
|
||||
let max_decoding_message_size = self.max_decoding_message_size;
|
||||
let max_encoding_message_size = self.max_encoding_message_size;
|
||||
let inner = self.inner.clone();
|
||||
let fut = async move {
|
||||
let method = ReleaseScannerPublicationLeaseSvc(inner);
|
||||
let codec = tonic_prost::ProstCodec::default();
|
||||
let mut grpc = tonic::server::Grpc::new(codec)
|
||||
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
|
||||
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
|
||||
let res = grpc.unary(method, req).await;
|
||||
Ok(res)
|
||||
};
|
||||
Box::pin(fut)
|
||||
}
|
||||
"/node_service.NodeService/BackgroundHealStatus" => {
|
||||
#[allow(non_camel_case_types)]
|
||||
struct BackgroundHealStatusSvc<T: NodeService>(pub Arc<T>);
|
||||
|
||||
+261
-2
@@ -486,6 +486,160 @@ pub fn canonical_scanner_activity_response_body(
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Builds the protocol-v7 response body. The optional movement fields are
|
||||
/// presence-bound so a missing terminal-generation proof cannot authenticate
|
||||
/// as the value zero.
|
||||
pub fn canonical_scanner_activity_v7_response_body(
|
||||
challenge: &[u8],
|
||||
response: &proto_gen::node_service::ScannerActivityResponse,
|
||||
) -> Result<Vec<u8>, std::num::TryFromIntError> {
|
||||
const DOMAIN: &[u8] = b"rustfs-scanner-activity-response-v3\0";
|
||||
|
||||
let instance_id = response.instance_id.as_bytes();
|
||||
let topology_digest = response.topology_digest.as_ref();
|
||||
let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + instance_id.len() + topology_digest.len() + 4 + 8 * 8 + 4);
|
||||
body.extend_from_slice(DOMAIN);
|
||||
body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
|
||||
body.extend_from_slice(challenge);
|
||||
body.extend_from_slice(&u64::try_from(instance_id.len())?.to_be_bytes());
|
||||
body.extend_from_slice(instance_id);
|
||||
body.extend_from_slice(&response.namespace_generation.to_be_bytes());
|
||||
body.extend_from_slice(&response.maintenance_generation.to_be_bytes());
|
||||
body.extend_from_slice(&response.protocol_version.to_be_bytes());
|
||||
body.extend_from_slice(&u64::try_from(topology_digest.len())?.to_be_bytes());
|
||||
body.extend_from_slice(topology_digest);
|
||||
body.push(u8::from(response.data_movement_active));
|
||||
body.extend_from_slice(&response.dirty_usage_generation.to_be_bytes());
|
||||
body.push(u8::from(response.dirty_usage_pending));
|
||||
body.push(u8::from(response.movement_generation.is_some()));
|
||||
if let Some(generation) = response.movement_generation {
|
||||
body.extend_from_slice(&generation.to_be_bytes());
|
||||
}
|
||||
body.push(u8::from(response.publication_blocked.is_some()));
|
||||
if let Some(blocked) = response.publication_blocked {
|
||||
body.push(u8::from(blocked));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Builds the body authenticated by the short-lived remote scanner publication
|
||||
/// lease request. This is a separate domain from ScannerActivity so v6/v7
|
||||
/// observation proofs remain byte-for-byte compatible.
|
||||
pub fn canonical_scanner_publication_lease_request_body(
|
||||
request: &proto_gen::node_service::ScannerPublicationLeaseRequest,
|
||||
) -> Result<Vec<u8>, std::num::TryFromIntError> {
|
||||
const DOMAIN: &[u8] = b"rustfs-scanner-publication-lease-request-v1\0";
|
||||
let challenge = request.challenge.as_ref();
|
||||
let session_id = request.expected_session_id.as_bytes();
|
||||
let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + session_id.len() + 40);
|
||||
body.extend_from_slice(DOMAIN);
|
||||
body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
|
||||
body.extend_from_slice(challenge);
|
||||
body.extend_from_slice(&request.expected_movement_generation.to_be_bytes());
|
||||
body.extend_from_slice(&request.ttl_ms.to_be_bytes());
|
||||
body.extend_from_slice(&u64::try_from(session_id.len())?.to_be_bytes());
|
||||
body.extend_from_slice(session_id);
|
||||
// Empty keeps the original acquire body byte-for-byte compatible. A
|
||||
// non-empty token is the authenticated validation form used immediately
|
||||
// before a coordinator commits its final publication.
|
||||
let token = request.token.as_ref();
|
||||
if !token.is_empty() {
|
||||
body.extend_from_slice(&u64::try_from(token.len())?.to_be_bytes());
|
||||
body.extend_from_slice(token);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Builds the body authenticated by a remote scanner publication lease
|
||||
/// release request.
|
||||
pub fn canonical_scanner_publication_lease_release_request_body(
|
||||
request: &proto_gen::node_service::ScannerPublicationLeaseReleaseRequest,
|
||||
) -> Result<Vec<u8>, std::num::TryFromIntError> {
|
||||
const DOMAIN: &[u8] = b"rustfs-scanner-publication-lease-release-request-v1\0";
|
||||
let challenge = request.challenge.as_ref();
|
||||
let token = request.token.as_ref();
|
||||
let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + token.len() + 16);
|
||||
body.extend_from_slice(DOMAIN);
|
||||
body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
|
||||
body.extend_from_slice(challenge);
|
||||
body.extend_from_slice(&u64::try_from(token.len())?.to_be_bytes());
|
||||
body.extend_from_slice(token);
|
||||
let owner_id = request.owner_id.as_bytes();
|
||||
let session_id = request.session_id.as_bytes();
|
||||
body.extend_from_slice(&u64::try_from(owner_id.len())?.to_be_bytes());
|
||||
body.extend_from_slice(owner_id);
|
||||
body.extend_from_slice(&u64::try_from(session_id.len())?.to_be_bytes());
|
||||
body.extend_from_slice(session_id);
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub fn canonical_scanner_publication_lease_response_body(
|
||||
challenge: &[u8],
|
||||
response: &proto_gen::node_service::ScannerPublicationLeaseResponse,
|
||||
) -> Result<Vec<u8>, std::num::TryFromIntError> {
|
||||
const DOMAIN: &[u8] = b"rustfs-scanner-publication-lease-response-v1\0";
|
||||
let token = response.token.as_ref();
|
||||
let owner_id = response.owner_id.as_bytes();
|
||||
let session_id = response.session_id.as_bytes();
|
||||
let error_info = response.error.as_ref().map(|error| error.error_info.as_bytes());
|
||||
let error_code = response.error.as_ref().map_or(0, |error| error.code);
|
||||
let mut body = Vec::with_capacity(
|
||||
DOMAIN.len() + challenge.len() + token.len() + owner_id.len() + session_id.len() + error_info.map_or(0, |v| v.len()) + 72,
|
||||
);
|
||||
body.extend_from_slice(DOMAIN);
|
||||
body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
|
||||
body.extend_from_slice(challenge);
|
||||
body.push(u8::from(response.success));
|
||||
body.extend_from_slice(&u64::try_from(token.len())?.to_be_bytes());
|
||||
body.extend_from_slice(token);
|
||||
body.extend_from_slice(&response.movement_generation.to_be_bytes());
|
||||
body.extend_from_slice(&response.lease_ttl_ms.to_be_bytes());
|
||||
body.extend_from_slice(&u64::try_from(owner_id.len())?.to_be_bytes());
|
||||
body.extend_from_slice(owner_id);
|
||||
body.extend_from_slice(&u64::try_from(session_id.len())?.to_be_bytes());
|
||||
body.extend_from_slice(session_id);
|
||||
body.push(u8::from(response.error.is_some()));
|
||||
body.extend_from_slice(&error_code.to_be_bytes());
|
||||
body.extend_from_slice(&u64::try_from(error_info.map_or(0, |value| value.len()))?.to_be_bytes());
|
||||
if let Some(error_info) = error_info {
|
||||
body.extend_from_slice(error_info);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub fn canonical_scanner_publication_lease_release_response_body(
|
||||
challenge: &[u8],
|
||||
request: &proto_gen::node_service::ScannerPublicationLeaseReleaseRequest,
|
||||
response: &proto_gen::node_service::ScannerPublicationLeaseReleaseResponse,
|
||||
) -> Result<Vec<u8>, std::num::TryFromIntError> {
|
||||
const DOMAIN: &[u8] = b"rustfs-scanner-publication-lease-release-response-v1\0";
|
||||
let token = request.token.as_ref();
|
||||
let owner_id = request.owner_id.as_bytes();
|
||||
let session_id = request.session_id.as_bytes();
|
||||
let error_info = response.error.as_ref().map(|error| error.error_info.as_bytes());
|
||||
let error_code = response.error.as_ref().map_or(0, |error| error.code);
|
||||
let mut body = Vec::with_capacity(
|
||||
DOMAIN.len() + challenge.len() + token.len() + owner_id.len() + session_id.len() + error_info.map_or(0, |v| v.len()) + 56,
|
||||
);
|
||||
body.extend_from_slice(DOMAIN);
|
||||
body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
|
||||
body.extend_from_slice(challenge);
|
||||
body.extend_from_slice(&u64::try_from(token.len())?.to_be_bytes());
|
||||
body.extend_from_slice(token);
|
||||
body.extend_from_slice(&u64::try_from(owner_id.len())?.to_be_bytes());
|
||||
body.extend_from_slice(owner_id);
|
||||
body.extend_from_slice(&u64::try_from(session_id.len())?.to_be_bytes());
|
||||
body.extend_from_slice(session_id);
|
||||
body.push(u8::from(response.success));
|
||||
body.push(u8::from(response.error.is_some()));
|
||||
body.extend_from_slice(&error_code.to_be_bytes());
|
||||
body.extend_from_slice(&u64::try_from(error_info.map_or(0, |value| value.len()))?.to_be_bytes());
|
||||
if let Some(error_info) = error_info {
|
||||
body.extend_from_slice(error_info);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Length-prefixed, domain-separated byte builder for the disk-mutation canonical bodies below.
|
||||
/// Every variable-length field is u64-length-prefixed and every list u64-count-prefixed, so
|
||||
/// distinct field values can never collide into the same canonical bytes.
|
||||
@@ -758,6 +912,11 @@ pub fn canonical_rename_data_request_body(
|
||||
body.push_str(&request.dst_volume)?;
|
||||
body.push_str(&request.dst_path)?;
|
||||
body.push_bytes(&request.file_info_bin)?;
|
||||
// Keep legacy rename requests byte-for-byte compatible. The optional
|
||||
// token is included only for the scanner's target-side lease fence.
|
||||
if !request.scanner_publication_lease_token.is_empty() {
|
||||
body.push_bytes(&request.scanner_publication_lease_token)?;
|
||||
}
|
||||
Ok(body.finish())
|
||||
}
|
||||
|
||||
@@ -840,6 +999,9 @@ pub fn canonical_delete_request_body(
|
||||
body.push_str(&request.volume)?;
|
||||
body.push_str(&request.path)?;
|
||||
body.push_str(&request.options)?;
|
||||
if !request.scanner_publication_lease_token.is_empty() {
|
||||
body.push_bytes(&request.scanner_publication_lease_token)?;
|
||||
}
|
||||
Ok(body.finish())
|
||||
}
|
||||
|
||||
@@ -1002,6 +1164,7 @@ mod disk_mutation_canonical_tests {
|
||||
dst_volume: "dst-vol".into(),
|
||||
dst_path: "dst-path".into(),
|
||||
file_info_bin: vec![0x81, 0x01].into(),
|
||||
scanner_publication_lease_token: Vec::new().into(),
|
||||
};
|
||||
let mut bodies = vec![canonical_rename_data_request_body(&baseline).unwrap()];
|
||||
for mutate in [
|
||||
@@ -1013,6 +1176,7 @@ mod disk_mutation_canonical_tests {
|
||||
|r: &mut RenameDataRequest| r.dst_path = "dst-path2".into(),
|
||||
|r: &mut RenameDataRequest| r.file_info_bin = vec![0x81, 0x02].into(),
|
||||
|r: &mut RenameDataRequest| r.file_info_bin = Vec::new().into(),
|
||||
|r: &mut RenameDataRequest| r.scanner_publication_lease_token = vec![0x01; 16].into(),
|
||||
] {
|
||||
let mut request = baseline.clone();
|
||||
mutate(&mut request);
|
||||
@@ -1173,6 +1337,7 @@ mod disk_mutation_canonical_tests {
|
||||
volume: "v".into(),
|
||||
path: "p".into(),
|
||||
options: "{\"o\":1}".into(),
|
||||
scanner_publication_lease_token: Vec::new().into(),
|
||||
};
|
||||
let mut bodies = vec![canonical_delete_request_body(&delete).unwrap()];
|
||||
for mutate in [
|
||||
@@ -1180,6 +1345,7 @@ mod disk_mutation_canonical_tests {
|
||||
|r: &mut DeleteRequest| r.volume = "v2".into(),
|
||||
|r: &mut DeleteRequest| r.path = "p2".into(),
|
||||
|r: &mut DeleteRequest| r.options = "{\"recursive\":true}".into(),
|
||||
|r: &mut DeleteRequest| r.scanner_publication_lease_token = vec![0x01; 16].into(),
|
||||
] {
|
||||
let mut request = delete.clone();
|
||||
mutate(&mut request);
|
||||
@@ -1565,8 +1731,12 @@ mod non_disk_mutation_canonical_tests {
|
||||
mod scanner_activity_tests {
|
||||
use super::{
|
||||
canonical_scanner_activity_request_body, canonical_scanner_activity_response_body,
|
||||
canonical_scanner_activity_v4_response_body,
|
||||
proto_gen::node_service::{ScannerActivityRequest, ScannerActivityResponse},
|
||||
canonical_scanner_activity_v4_response_body, canonical_scanner_activity_v7_response_body,
|
||||
canonical_scanner_publication_lease_release_request_body, canonical_scanner_publication_lease_request_body,
|
||||
canonical_scanner_publication_lease_response_body,
|
||||
proto_gen::node_service::{
|
||||
ScannerActivityRequest, ScannerActivityResponse, ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse,
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1617,6 +1787,8 @@ mod scanner_activity_tests {
|
||||
response_proof: Vec::new().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
movement_generation: Some(19),
|
||||
publication_blocked: Some(false),
|
||||
};
|
||||
let baseline =
|
||||
canonical_scanner_activity_response_body(&[1; 16], &response).expect("scanner activity response should encode");
|
||||
@@ -1667,6 +1839,25 @@ mod scanner_activity_tests {
|
||||
canonical_scanner_activity_response_body(&[2; 16], &response)
|
||||
.expect("scanner activity response with a different challenge should encode")
|
||||
);
|
||||
|
||||
let v7_baseline =
|
||||
canonical_scanner_activity_v7_response_body(&[1; 16], &response).expect("scanner activity v7 response should encode");
|
||||
for variant in [
|
||||
ScannerActivityResponse {
|
||||
movement_generation: Some(20),
|
||||
..response.clone()
|
||||
},
|
||||
ScannerActivityResponse {
|
||||
publication_blocked: Some(true),
|
||||
..response
|
||||
},
|
||||
] {
|
||||
assert_ne!(
|
||||
v7_baseline,
|
||||
canonical_scanner_activity_v7_response_body(&[1; 16], &variant)
|
||||
.expect("scanner activity v7 response variant should encode")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1681,6 +1872,8 @@ mod scanner_activity_tests {
|
||||
response_proof: Vec::new().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: None,
|
||||
publication_blocked: None,
|
||||
};
|
||||
let baseline =
|
||||
canonical_scanner_activity_v4_response_body(&[1; 16], &response).expect("scanner activity v4 response should encode");
|
||||
@@ -1714,6 +1907,72 @@ mod scanner_activity_tests {
|
||||
.expect("scanner activity v4 response should ignore v5 fields")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_publication_lease_canonical_bodies_bind_session_owner_and_generation() {
|
||||
let request = ScannerPublicationLeaseRequest {
|
||||
challenge: vec![1; 16].into(),
|
||||
expected_movement_generation: 7,
|
||||
ttl_ms: 60_000,
|
||||
expected_session_id: "session-a".to_string(),
|
||||
token: Vec::new().into(),
|
||||
};
|
||||
let baseline = canonical_scanner_publication_lease_request_body(&request).unwrap();
|
||||
for variant in [
|
||||
ScannerPublicationLeaseRequest {
|
||||
expected_movement_generation: 8,
|
||||
..request.clone()
|
||||
},
|
||||
ScannerPublicationLeaseRequest {
|
||||
expected_session_id: "session-b".to_string(),
|
||||
..request.clone()
|
||||
},
|
||||
ScannerPublicationLeaseRequest {
|
||||
ttl_ms: 30_000,
|
||||
..request.clone()
|
||||
},
|
||||
ScannerPublicationLeaseRequest {
|
||||
token: vec![3; 16].into(),
|
||||
..request
|
||||
},
|
||||
] {
|
||||
assert_ne!(baseline, canonical_scanner_publication_lease_request_body(&variant).unwrap());
|
||||
}
|
||||
|
||||
let release_a = crate::proto_gen::node_service::ScannerPublicationLeaseReleaseRequest {
|
||||
challenge: vec![2; 16].into(),
|
||||
token: vec![3; 16].into(),
|
||||
owner_id: "owner-a".to_string(),
|
||||
session_id: "session-a".to_string(),
|
||||
};
|
||||
let release_b = crate::proto_gen::node_service::ScannerPublicationLeaseReleaseRequest {
|
||||
owner_id: "owner-b".to_string(),
|
||||
..release_a.clone()
|
||||
};
|
||||
assert_ne!(
|
||||
canonical_scanner_publication_lease_release_request_body(&release_a).unwrap(),
|
||||
canonical_scanner_publication_lease_release_request_body(&release_b).unwrap()
|
||||
);
|
||||
|
||||
let response = ScannerPublicationLeaseResponse {
|
||||
success: true,
|
||||
token: vec![4; 16].into(),
|
||||
movement_generation: 7,
|
||||
lease_ttl_ms: 60_000,
|
||||
error: None,
|
||||
response_proof: Vec::new().into(),
|
||||
owner_id: "owner-a".to_string(),
|
||||
session_id: "session-a".to_string(),
|
||||
};
|
||||
let response_changed = ScannerPublicationLeaseResponse {
|
||||
owner_id: "owner-b".to_string(),
|
||||
..response.clone()
|
||||
};
|
||||
assert_ne!(
|
||||
canonical_scanner_publication_lease_response_body(&[1; 16], &response).unwrap(),
|
||||
canonical_scanner_publication_lease_response_body(&[1; 16], &response_changed).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -111,6 +111,9 @@ message DeleteRequest {
|
||||
string volume = 2;
|
||||
string path = 3;
|
||||
string options = 4;
|
||||
// Optional scanner publication lease token. When present, the target binds
|
||||
// the complete delete operation to its movement read admission.
|
||||
bytes scanner_publication_lease_token = 5;
|
||||
}
|
||||
|
||||
message DeleteResponse {
|
||||
@@ -281,6 +284,10 @@ message RenameDataRequest {
|
||||
string dst_volume = 5;
|
||||
string dst_path = 6;
|
||||
bytes file_info_bin = 7;
|
||||
// Optional target-side scanner publication lease. Empty preserves the
|
||||
// legacy rename request body; a non-empty token is checked at the target's
|
||||
// rename linearization point.
|
||||
bytes scanner_publication_lease_token = 8;
|
||||
}
|
||||
|
||||
message RenameDataResponse {
|
||||
@@ -844,6 +851,52 @@ message ScannerActivityResponse {
|
||||
bytes response_proof = 7;
|
||||
uint64 dirty_usage_generation = 8;
|
||||
bool dirty_usage_pending = 9;
|
||||
// v7 fields. They are optional so v6 peers can continue to decode the
|
||||
// response shape while newer readers fail closed when they are absent.
|
||||
optional uint64 movement_generation = 10;
|
||||
optional bool publication_blocked = 11;
|
||||
}
|
||||
|
||||
// A short-lived storage-owned read admission used only around a final
|
||||
// scanner metadata publication. It is intentionally separate from the
|
||||
// ScannerActivity observation wire so v6/v7 rolling compatibility remains
|
||||
// unchanged.
|
||||
message ScannerPublicationLeaseRequest {
|
||||
bytes challenge = 1;
|
||||
uint64 expected_movement_generation = 2;
|
||||
uint64 ttl_ms = 3;
|
||||
// The activity instance is a process session nonce. It is intentionally
|
||||
// separate from the storage-owned deployment identity returned by the
|
||||
// lease response so a restart cannot reuse an old session token.
|
||||
string expected_session_id = 4;
|
||||
// A non-empty token turns the acquire RPC into an in-place validation of an
|
||||
// existing lease. Keeping this on the existing RPC lets old peers reject
|
||||
// the proof without changing the v7 activity wire shape.
|
||||
bytes token = 5;
|
||||
}
|
||||
|
||||
message ScannerPublicationLeaseResponse {
|
||||
bool success = 1;
|
||||
bytes token = 2;
|
||||
uint64 movement_generation = 3;
|
||||
uint64 lease_ttl_ms = 4;
|
||||
optional Error error = 5;
|
||||
bytes response_proof = 6;
|
||||
string owner_id = 7;
|
||||
string session_id = 8;
|
||||
}
|
||||
|
||||
message ScannerPublicationLeaseReleaseRequest {
|
||||
bytes challenge = 1;
|
||||
bytes token = 2;
|
||||
string owner_id = 3;
|
||||
string session_id = 4;
|
||||
}
|
||||
|
||||
message ScannerPublicationLeaseReleaseResponse {
|
||||
bool success = 1;
|
||||
optional Error error = 2;
|
||||
bytes response_proof = 3;
|
||||
}
|
||||
|
||||
message BackgroundHealStatusRequest {
|
||||
@@ -1096,6 +1149,8 @@ service NodeService {
|
||||
// rpc CommitBinary() returns () {};
|
||||
rpc SignalService(SignalServiceRequest) returns (SignalServiceResponse) {}; // auth-policy: body-bound
|
||||
rpc ScannerActivity(ScannerActivityRequest) returns (ScannerActivityResponse) {}; // auth-policy: body-bound
|
||||
rpc AcquireScannerPublicationLease(ScannerPublicationLeaseRequest) returns (ScannerPublicationLeaseResponse) {}; // auth-policy: body-bound
|
||||
rpc ReleaseScannerPublicationLease(ScannerPublicationLeaseReleaseRequest) returns (ScannerPublicationLeaseReleaseResponse) {}; // auth-policy: body-bound
|
||||
rpc BackgroundHealStatus(BackgroundHealStatusRequest) returns (BackgroundHealStatusResponse) {}; // auth-policy: read-only
|
||||
rpc ReplacementRecoveryStatus(ReplacementRecoveryStatusRequest) returns (ReplacementRecoveryStatusResponse) {}; // auth-policy: read-only
|
||||
rpc GetMetacacheListing(GetMetacacheListingRequest) returns (GetMetacacheListingResponse) {}; // auth-policy: unimplemented
|
||||
|
||||
@@ -153,7 +153,7 @@ pub struct ResyncOpts {
|
||||
pub resync_before: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct TargetReplicationResyncStatus {
|
||||
pub start_time: Option<OffsetDateTime>,
|
||||
pub last_update: Option<OffsetDateTime>,
|
||||
|
||||
@@ -474,8 +474,14 @@ impl Serialize for DataUsageCacheInfo {
|
||||
{
|
||||
// Keep this metadata map-encoded so older readers can ignore fields
|
||||
// appended by newer scanner versions during rolling upgrades.
|
||||
let field_count =
|
||||
21 + usize::from(self.tier_registry_generation.is_some()) + usize::from(!self.size_reconciliation.is_empty());
|
||||
let field_count = 16
|
||||
+ usize::from(self.tier_registry_generation.is_some())
|
||||
+ usize::from(!self.size_reconciliation.is_empty())
|
||||
+ usize::from(self.lkg_snapshot_complete)
|
||||
+ usize::from(self.lkg_next_cycle.is_some())
|
||||
+ usize::from(self.lkg_last_update.is_some())
|
||||
+ usize::from(self.lkg_leader_epoch.is_some())
|
||||
+ usize::from(self.lkg_scan_plan_digest.is_some());
|
||||
let mut state = serializer.serialize_map(Some(field_count))?;
|
||||
state.serialize_entry("name", &self.name)?;
|
||||
state.serialize_entry("next_cycle", &self.next_cycle)?;
|
||||
@@ -499,11 +505,21 @@ impl Serialize for DataUsageCacheInfo {
|
||||
if !self.size_reconciliation.is_empty() {
|
||||
state.serialize_entry("size_reconciliation", &self.size_reconciliation)?;
|
||||
}
|
||||
state.serialize_entry("lkg_snapshot_complete", &self.lkg_snapshot_complete)?;
|
||||
state.serialize_entry("lkg_next_cycle", &self.lkg_next_cycle)?;
|
||||
state.serialize_entry("lkg_last_update", &self.lkg_last_update)?;
|
||||
state.serialize_entry("lkg_leader_epoch", &self.lkg_leader_epoch)?;
|
||||
state.serialize_entry("lkg_scan_plan_digest", &self.lkg_scan_plan_digest)?;
|
||||
if self.lkg_snapshot_complete {
|
||||
state.serialize_entry("lkg_snapshot_complete", &true)?;
|
||||
}
|
||||
if let Some(next_cycle) = self.lkg_next_cycle {
|
||||
state.serialize_entry("lkg_next_cycle", &next_cycle)?;
|
||||
}
|
||||
if let Some(last_update) = self.lkg_last_update {
|
||||
state.serialize_entry("lkg_last_update", &last_update)?;
|
||||
}
|
||||
if let Some(leader_epoch) = self.lkg_leader_epoch {
|
||||
state.serialize_entry("lkg_leader_epoch", &leader_epoch)?;
|
||||
}
|
||||
if let Some(scan_plan_digest) = self.lkg_scan_plan_digest {
|
||||
state.serialize_entry("lkg_scan_plan_digest", &scan_plan_digest)?;
|
||||
}
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1326,6 +1326,24 @@ fn usage_cache_wire_format_is_pinned() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_cache_lkg_fields_round_trip_when_present() {
|
||||
let mut cache = wire_fixture_cache();
|
||||
cache.info.lkg_snapshot_complete = true;
|
||||
cache.info.lkg_next_cycle = Some(6);
|
||||
cache.info.lkg_last_update = Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_699_999_999));
|
||||
cache.info.lkg_leader_epoch = Some(8);
|
||||
cache.info.lkg_scan_plan_digest = Some(DataUsageScanPlanDigest([2; 32]));
|
||||
|
||||
let encoded = cache.marshal_msg().expect("marshal cache with LKG metadata");
|
||||
let decoded = DataUsageCache::unmarshal(&encoded).expect("decode cache with LKG metadata");
|
||||
assert!(decoded.info.lkg_snapshot_complete);
|
||||
assert_eq!(decoded.info.lkg_next_cycle, Some(6));
|
||||
assert_eq!(decoded.info.lkg_last_update, cache.info.lkg_last_update);
|
||||
assert_eq!(decoded.info.lkg_leader_epoch, Some(8));
|
||||
assert_eq!(decoded.info.lkg_scan_plan_digest, Some(DataUsageScanPlanDigest([2; 32])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_usage_cache_prepare_for_scan_rejects_unscoped_distributed_cache() {
|
||||
let mut cache = DataUsageCache {
|
||||
|
||||
@@ -92,7 +92,7 @@ pub use scanner_io::{
|
||||
pub use sleeper::{DynamicSleeper, SCANNER_IDLE_MODE, SCANNER_SLEEPER};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
pub use storage_api::ScannerReplicationConfig as ReplicationConfig;
|
||||
pub use storage_api::scan::SCANNER_ACTIVITY_PROTOCOL_VERSION;
|
||||
pub use storage_api::scan::{SCANNER_ACTIVITY_PROTOCOL_VERSION, SCANNER_ACTIVITY_V6_PROTOCOL_VERSION};
|
||||
|
||||
static SCANNER_ACTIVE_WORK_UNITS: AtomicU64 = AtomicU64::new(0);
|
||||
static SCANNER_RUNTIME_INSTANCES: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -796,17 +796,25 @@ where
|
||||
Some(admission)
|
||||
}
|
||||
|
||||
pub(crate) async fn save_config_shared_with_preconditions<S>(
|
||||
pub(crate) async fn save_config_shared_with_preconditions_and_lease_fence<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
data: Bytes,
|
||||
sha256hex: Option<String>,
|
||||
preconditions: HTTPPreconditions,
|
||||
scanner_publication_lease_fence: Option<&str>,
|
||||
) -> EcstoreResult<ScannerObjectInfo>
|
||||
where
|
||||
S: ScannerObjectIO,
|
||||
{
|
||||
let mut reader = ScannerPutObjReader::from_prehashed_bytes(data, sha256hex)?;
|
||||
let mut user_defined = HashMap::new();
|
||||
if let Some(fence) = scanner_publication_lease_fence {
|
||||
user_defined.insert(
|
||||
storage_api::owner::SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY.to_string(),
|
||||
fence.to_string(),
|
||||
);
|
||||
}
|
||||
api.put_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
file,
|
||||
@@ -814,6 +822,7 @@ where
|
||||
&ScannerObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(preconditions),
|
||||
user_defined,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
|
||||
+344
-48
@@ -54,7 +54,7 @@ use rustfs_config::{
|
||||
};
|
||||
use rustfs_config::{ENV_SCANNER_CYCLE, ENV_SCANNER_SPEED, ENV_SCANNER_START_DELAY_SECS};
|
||||
use rustfs_data_usage::observed_data_usage_is_newer;
|
||||
use rustfs_lock::NamespaceLockGuard;
|
||||
use rustfs_lock::{NamespaceLockGuard, error::LockError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
@@ -63,6 +63,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::AbortOnDropHandle;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use crate::storage_api::owner::SCANNER_PUBLICATION_LEASE_TTL_MS;
|
||||
use crate::storage_api::scan::{
|
||||
BucketOperations, BucketOptions, NamespaceLocking as _, SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION,
|
||||
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
@@ -71,9 +72,9 @@ use crate::{
|
||||
ECStore, EcstoreError, RUSTFS_META_BUCKET, SCANNER_PUBLICATION_EPOCH_CHANGED, ScannerLifecycleConfigExt as _,
|
||||
ScannerReplicationConfigExt as _, delete_config_with_publication_admission_for_epoch, get_lifecycle_config,
|
||||
get_replication_config, invalidate_admin_data_usage_snapshot_cache, invalidate_data_usage_snapshot_cache, read_config,
|
||||
replace_bucket_usage_memory_from_info, save_config, save_config_shared_with_preconditions, save_config_with_preconditions,
|
||||
save_config_with_publication_admission_for_epoch, scanner_is_erasure_sd, scanner_publication_admission_for_epoch,
|
||||
scanner_publication_epoch, scanner_publication_epoch_changed,
|
||||
replace_bucket_usage_memory_from_info, save_config, save_config_shared_with_preconditions_and_lease_fence,
|
||||
save_config_with_preconditions, save_config_with_publication_admission_for_epoch, scanner_is_erasure_sd,
|
||||
scanner_publication_admission_for_epoch, scanner_publication_epoch, scanner_publication_epoch_changed,
|
||||
};
|
||||
|
||||
const LOG_COMPONENT_SCANNER: &str = "scanner";
|
||||
@@ -125,6 +126,8 @@ const MAINTENANCE_FEATURE_INSPECTION_RETRY_MAX_INTERVAL: Duration = Duration::fr
|
||||
const MAX_MAINTENANCE_FEATURE_INSPECTION_ATTEMPTS: usize = 2;
|
||||
const SCANNER_PERSIST_CAS_RETRIES: usize = 2;
|
||||
const DATA_USAGE_BACKUP_INTERVAL_CYCLES: u64 = 10;
|
||||
const SCANNER_PUBLICATION_LEASE_FENCE_MAX_ENTRIES: usize = 256;
|
||||
const SCANNER_PUBLICATION_LEASE_FENCE_MAX_BYTES: usize = 64 * 1024;
|
||||
const SCANNER_CYCLE_STATE_MAGIC: &[u8; 8] = b"RSCYC001";
|
||||
const SCANNER_CYCLE_STATE_HEADER_LEN: usize = 24;
|
||||
#[cfg(test)]
|
||||
@@ -137,6 +140,10 @@ static SCANNER_CYCLE_STATE_PERSIST_TEST_HOOK: LazyLock<StdMutex<Option<ScannerCy
|
||||
|
||||
static SCANNER_CYCLE_RECOVERY_WAKE: LazyLock<Notify> = LazyLock::new(Notify::new);
|
||||
|
||||
fn remote_publication_lease_fence_targets_are_required(target_count: usize, grants_present: bool, fence_present: bool) -> bool {
|
||||
target_count > 0 && (!grants_present || !fence_present)
|
||||
}
|
||||
|
||||
pub(super) fn notify_scanner_cycle_recovery_wake() {
|
||||
SCANNER_CYCLE_RECOVERY_WAKE.notify_one();
|
||||
}
|
||||
@@ -177,6 +184,8 @@ fn notify_scanner_cycle_state_persist_test_hook(leader_epoch: u64) {
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ScannerCycleScheduleStatus {
|
||||
execution_role: &'static str,
|
||||
effective_interval_available: bool,
|
||||
effective_interval_seconds: u64,
|
||||
clean_idle_backoff_enabled: bool,
|
||||
clean_idle_backoff_multiplier: u64,
|
||||
@@ -187,6 +196,8 @@ pub struct ScannerCycleScheduleStatus {
|
||||
impl Default for ScannerCycleScheduleStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
execution_role: "unknown",
|
||||
effective_interval_available: false,
|
||||
effective_interval_seconds: 0,
|
||||
clean_idle_backoff_enabled: false,
|
||||
clean_idle_backoff_multiplier: 1,
|
||||
@@ -223,6 +234,8 @@ fn record_scanner_cycle_schedule(
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*schedule = ScannerCycleScheduleStatus {
|
||||
execution_role: "leader",
|
||||
effective_interval_available: true,
|
||||
effective_interval_seconds,
|
||||
clean_idle_backoff_enabled,
|
||||
clean_idle_backoff_multiplier: clean_idle_backoff_multiplier.max(1),
|
||||
@@ -231,8 +244,30 @@ fn record_scanner_cycle_schedule(
|
||||
};
|
||||
}
|
||||
|
||||
fn record_scanner_cycle_schedule_role(execution_role: &'static str) {
|
||||
let mut schedule = SCANNER_CYCLE_SCHEDULE
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*schedule = ScannerCycleScheduleStatus {
|
||||
execution_role,
|
||||
..ScannerCycleScheduleStatus::default()
|
||||
};
|
||||
}
|
||||
|
||||
fn reset_scanner_cycle_schedule() {
|
||||
record_scanner_cycle_schedule(Duration::ZERO, false, 1, false, 0);
|
||||
record_scanner_cycle_schedule_role("unknown");
|
||||
}
|
||||
|
||||
enum ScannerLeaderLockFailure<'a> {
|
||||
Contended,
|
||||
Failed(&'a LockError),
|
||||
}
|
||||
|
||||
fn classify_scanner_leader_lock_failure(error: &LockError) -> ScannerLeaderLockFailure<'_> {
|
||||
match error {
|
||||
LockError::Timeout { .. } => ScannerLeaderLockFailure::Contended,
|
||||
error => ScannerLeaderLockFailure::Failed(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the base cycle interval.
|
||||
@@ -359,7 +394,8 @@ pub(super) fn data_usage_info_has_persisted_baseline_identity(info: &DataUsageIn
|
||||
// complete: a timestamp, a scanner cycle, and an exact bucket cardinality.
|
||||
// A current snapshot with only scanner_epoch/scanner_cycle (or an explicit
|
||||
// incomplete marker) is not evidence of a durable usage baseline.
|
||||
!info.usage_snapshot_complete
|
||||
!info.usage_snapshot_bootstrap_pending
|
||||
&& !info.usage_snapshot_complete
|
||||
&& info.scanner_epoch.is_none()
|
||||
&& info.usage_snapshot_converged != Some(false)
|
||||
&& info.last_update.is_some()
|
||||
@@ -367,6 +403,21 @@ pub(super) fn data_usage_info_has_persisted_baseline_identity(info: &DataUsageIn
|
||||
&& u64::try_from(info.buckets_usage.len()).ok() == Some(info.buckets_count)
|
||||
}
|
||||
|
||||
pub(super) fn data_usage_info_is_pristine_bootstrap_pending(info: &DataUsageInfo) -> bool {
|
||||
if info.last_update.is_none() || info.scanner_cycle.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let expected = DataUsageInfo {
|
||||
last_update: info.last_update,
|
||||
scanner_epoch: info.scanner_epoch,
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_bootstrap_pending: true,
|
||||
..Default::default()
|
||||
};
|
||||
info == &expected
|
||||
}
|
||||
|
||||
fn usage_cache_needs_prompt_scan(authoritative: &DataUsageInfo, observed: Option<&DataUsageInfo>) -> bool {
|
||||
data_usage_info_is_cold(authoritative)
|
||||
|| observed.is_some_and(|observed| observed_data_usage_is_newer(observed, authoritative))
|
||||
@@ -407,19 +458,24 @@ async fn sync_data_usage_backup_from_primary(
|
||||
ctx: &CancellationToken,
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
) -> Result<(), EcstoreError> {
|
||||
sync_data_usage_backup_from_primary_for_epoch(ctx, storeapi, None).await
|
||||
sync_data_usage_backup_from_primary_for_epoch_and_lease_and_fence(ctx, storeapi, None, None, None).await
|
||||
}
|
||||
|
||||
async fn sync_data_usage_backup_from_primary_for_epoch(
|
||||
async fn sync_data_usage_backup_from_primary_for_epoch_and_lease_and_fence(
|
||||
ctx: &CancellationToken,
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
expected_publication_epoch: Option<u64>,
|
||||
remote_lease_deadline: Option<std::time::Instant>,
|
||||
scanner_publication_lease_fence: Option<&str>,
|
||||
) -> Result<(), EcstoreError> {
|
||||
let backup_path = format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str());
|
||||
for retry in 0..=SCANNER_PERSIST_CAS_RETRIES {
|
||||
if ctx.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
if remote_lease_deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
|
||||
return Err(EcstoreError::other(SCANNER_PUBLICATION_EPOCH_CHANGED));
|
||||
}
|
||||
|
||||
let read_epoch = match expected_publication_epoch {
|
||||
Some(expected_epoch) => {
|
||||
@@ -446,6 +502,10 @@ async fn sync_data_usage_backup_from_primary_for_epoch(
|
||||
}
|
||||
let primary = Bytes::from(primary);
|
||||
|
||||
if remote_lease_deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
|
||||
return Err(EcstoreError::other(SCANNER_PUBLICATION_EPOCH_CHANGED));
|
||||
}
|
||||
|
||||
let (backup, revision) = read_config_with_revision(storeapi.clone(), &backup_path).await?;
|
||||
if backup.as_deref() == Some(primary.as_ref()) {
|
||||
if scanner_publication_admission_for_epoch(storeapi.clone(), read_epoch)
|
||||
@@ -462,18 +522,22 @@ async fn sync_data_usage_backup_from_primary_for_epoch(
|
||||
|
||||
let sha256hex = Some(hex_simd::encode_to_string(Sha256::digest(&primary), hex_simd::AsciiCase::Lower));
|
||||
let save_result = {
|
||||
if remote_lease_deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
|
||||
return Err(EcstoreError::other(SCANNER_PUBLICATION_EPOCH_CHANGED));
|
||||
}
|
||||
let Some(_publication_admission) = scanner_publication_admission_for_epoch(storeapi.clone(), read_epoch).await else {
|
||||
if retry < SCANNER_PERSIST_CAS_RETRIES {
|
||||
continue;
|
||||
}
|
||||
return Err(EcstoreError::other(SCANNER_PUBLICATION_EPOCH_CHANGED));
|
||||
};
|
||||
save_config_shared_with_preconditions(
|
||||
save_config_shared_with_preconditions_and_lease_fence(
|
||||
storeapi.clone(),
|
||||
&backup_path,
|
||||
primary.clone(),
|
||||
sha256hex,
|
||||
revision.preconditions(),
|
||||
scanner_publication_lease_fence,
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -617,6 +681,29 @@ async fn initial_scanner_startup_usage_state(storeapi: &Arc<ECStore>) -> (bool,
|
||||
(persisted_usage_cache_is_cold_for_startup(storeapi).await, has_buckets)
|
||||
}
|
||||
|
||||
fn scanner_cycle_state_is_pristine(
|
||||
cycle_info: &CurrentCycle,
|
||||
leader_epoch: u64,
|
||||
cycle_revision: &DataUsageCacheRevision,
|
||||
) -> bool {
|
||||
cycle_info.next == 0 && leader_epoch == 0 && matches!(cycle_revision, DataUsageCacheRevision::Missing)
|
||||
}
|
||||
|
||||
fn scanner_may_bootstrap_missing_usage_floor(
|
||||
cycle_info: &CurrentCycle,
|
||||
leader_epoch: u64,
|
||||
cycle_revision: &DataUsageCacheRevision,
|
||||
) -> bool {
|
||||
// The server becomes ready before the scanner starts, so a first bucket may
|
||||
// already exist. The bootstrap marker is non-authoritative; only prior
|
||||
// durable scanner progress must block its creation.
|
||||
scanner_cycle_state_is_pristine(cycle_info, leader_epoch, cycle_revision)
|
||||
}
|
||||
|
||||
fn scanner_may_resume_pristine_usage_bootstrap(cycle_info: &CurrentCycle) -> bool {
|
||||
cycle_info.next == 0
|
||||
}
|
||||
|
||||
pub async fn init_data_scanner(ctx: CancellationToken, storeapi: Arc<ECStore>) {
|
||||
let (startup_features, startup_maintenance_generation) = configure_scanner_defaults(&ctx, &storeapi).await;
|
||||
// Force init global sleeper so config is read once at startup.
|
||||
@@ -1133,7 +1220,7 @@ where
|
||||
LockLost: Future<Output = ()>,
|
||||
{
|
||||
let fence_ctx = ctx.child_token();
|
||||
let claim = claim_scanner_leadership(&fence_ctx, storeapi, cycle_info, cycle_revision, leader_epoch);
|
||||
let claim = claim_scanner_leadership(&fence_ctx, storeapi, cycle_info, cycle_revision, leader_epoch, false);
|
||||
tokio::pin!(claim);
|
||||
tokio::pin!(lock_lost);
|
||||
tokio::select! {
|
||||
@@ -1377,8 +1464,89 @@ async fn run_data_scanner_cycle_with_budget(
|
||||
};
|
||||
let publication_deferred = publication_defer_reason.is_some();
|
||||
let publication_epoch = scan_result.as_ref().ok().and_then(ScannerCycleResult::publication_epoch);
|
||||
let remote_publication_lease_targets = if publication_defer_reason.is_none() {
|
||||
scan_result
|
||||
.as_ref()
|
||||
.ok()
|
||||
.map(|result| result.remote_publication_lease_targets().to_vec())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut remote_publication_leases = None;
|
||||
let remote_lease_defer_reason = if remote_publication_lease_targets.is_empty() {
|
||||
None
|
||||
} else if usage_persist_timeout >= Duration::from_millis(SCANNER_PUBLICATION_LEASE_TTL_MS) {
|
||||
// The lease is intentionally fixed-duration and has no renewal path.
|
||||
// Refuse a persistence budget that could outlive it instead of
|
||||
// allowing the peer to admit movement while a local PUT is in flight.
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
} else if let Some(notification_system) = storeapi.notification_system() {
|
||||
match notification_system
|
||||
.acquire_scanner_publication_leases(remote_publication_lease_targets.clone())
|
||||
.await
|
||||
{
|
||||
Ok(grants) => {
|
||||
remote_publication_leases = Some((notification_system, grants));
|
||||
None
|
||||
}
|
||||
Err(_) => Some(ScannerCycleDeferReason::ActivityBaselineUnavailable),
|
||||
}
|
||||
} else {
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
};
|
||||
let remote_lease_deadline = remote_publication_leases
|
||||
.as_ref()
|
||||
.and_then(|(_, grants)| grants.iter().map(|grant| grant.lease.expires_at).min());
|
||||
// The transient fence is carried only to the SetDisks rename boundary;
|
||||
// it is never inserted into FileInfo metadata. Keep the representation
|
||||
// bounded and require one authenticated token per remote target so a
|
||||
// partial grant can never silently fall back to an unfenced rename.
|
||||
let remote_lease_fence = remote_publication_leases.as_ref().and_then(|(_, grants)| {
|
||||
if grants.len() != remote_publication_lease_targets.len() || grants.len() > SCANNER_PUBLICATION_LEASE_FENCE_MAX_ENTRIES {
|
||||
return None;
|
||||
}
|
||||
let mut fence = BTreeMap::new();
|
||||
for grant in grants {
|
||||
if grant.host.is_empty() || grant.host.len() > 1024 {
|
||||
return None;
|
||||
}
|
||||
if fence.insert(grant.host.clone(), grant.lease.token.to_string()).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if remote_publication_lease_targets
|
||||
.iter()
|
||||
.any(|(host, _, _)| !fence.contains_key(host))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
serde_json::to_string(&fence)
|
||||
.ok()
|
||||
.filter(|encoded| encoded.len() <= SCANNER_PUBLICATION_LEASE_FENCE_MAX_BYTES)
|
||||
});
|
||||
let remote_lease_fence_defer_reason = (remote_publication_lease_fence_targets_are_required(
|
||||
remote_publication_lease_targets.len(),
|
||||
remote_publication_leases.is_some(),
|
||||
remote_lease_fence.is_some(),
|
||||
))
|
||||
.then_some(ScannerCycleDeferReason::ActivityBaselineUnavailable);
|
||||
let remote_lease_covers_persistence = remote_lease_deadline.is_none_or(|deadline| {
|
||||
std::time::Instant::now()
|
||||
.checked_add(usage_persist_timeout)
|
||||
.is_some_and(|latest_finish| latest_finish < deadline)
|
||||
});
|
||||
let publication_defer_reason = publication_defer_reason
|
||||
.or(remote_lease_defer_reason)
|
||||
.or(remote_lease_fence_defer_reason);
|
||||
let publication_defer_reason = (!remote_lease_covers_persistence)
|
||||
.then_some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
.or(publication_defer_reason);
|
||||
let budget_elapsed = cycle_budget.budget_elapsed() && !ctx.is_cancelled();
|
||||
let usage_persist_outcome = match publication_defer_reason {
|
||||
let remote_lease_probe = remote_publication_leases
|
||||
.as_ref()
|
||||
.map(|(notification_system, grants)| (Arc::clone(notification_system), grants.clone()));
|
||||
let mut usage_persist_outcome = match publication_defer_reason {
|
||||
Some(reason) => {
|
||||
drop(receiver);
|
||||
DataUsagePersistOutcome::Deferred(reason)
|
||||
@@ -1390,17 +1558,33 @@ async fn run_data_scanner_cycle_with_budget(
|
||||
let storeapi_clone = storeapi.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let route_probe_store = storeapi.clone();
|
||||
let remote_lease_fence = remote_lease_fence.clone();
|
||||
let mut usage_persist_task = AbortOnDropHandle::new(tokio::spawn(async move {
|
||||
store_data_usage_in_backend_with_outcome_for_epoch_and_baseline_and_route_probe_for_publication_epoch(
|
||||
store_data_usage_in_backend_with_outcome_for_epoch_and_baseline_and_route_probe_for_publication_epoch_and_lease_fence(
|
||||
ctx_clone,
|
||||
storeapi_clone,
|
||||
receiver,
|
||||
Some(leader_epoch),
|
||||
Some(usage_persist_baseline),
|
||||
publication_epoch,
|
||||
ScannerPublicationFence::new(
|
||||
publication_epoch,
|
||||
remote_lease_deadline,
|
||||
remote_lease_fence,
|
||||
),
|
||||
move || {
|
||||
let storeapi = route_probe_store.clone();
|
||||
async move { storeapi.scanner_data_usage_publication_blocked().await }
|
||||
let remote_lease_probe = remote_lease_probe.clone();
|
||||
async move {
|
||||
if let Some((notification_system, grants)) = remote_lease_probe.as_ref()
|
||||
&& notification_system.validate_scanner_publication_leases(grants).await.is_err()
|
||||
{
|
||||
// A remote restart or movement flip invalidates
|
||||
// the token proof; usage_store interprets this
|
||||
// as a publication barrier and performs no PUT.
|
||||
return true;
|
||||
}
|
||||
storeapi.scanner_data_usage_publication_blocked().await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1448,6 +1632,22 @@ async fn run_data_scanner_cycle_with_budget(
|
||||
}
|
||||
}
|
||||
};
|
||||
let lease_expired = remote_publication_leases
|
||||
.as_ref()
|
||||
.is_some_and(|(_, grants)| grants.iter().any(|grant| !grant.lease.is_valid()));
|
||||
if let Some((notification_system, grants)) = remote_publication_leases.take() {
|
||||
let release_result = notification_system.release_scanner_publication_leases(grants).await;
|
||||
if lease_expired || release_result.is_err() {
|
||||
// A lease that expired or could not be released is never treated
|
||||
// as a successful authoritative publication. The peer may have
|
||||
// admitted movement immediately after the lease ended.
|
||||
usage_persist_outcome = if usage_persist_outcome == DataUsagePersistOutcome::Failed {
|
||||
DataUsagePersistOutcome::Failed
|
||||
} else {
|
||||
DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
};
|
||||
}
|
||||
}
|
||||
let unresolved_heal_work = global_metrics().current_scan_cycle_has_unresolved_heal_work();
|
||||
|
||||
let scan_cycle_result = match scan_result {
|
||||
@@ -1869,6 +2069,7 @@ async fn run_data_scanner_with_maintenance_state(
|
||||
let mut guard = match storeapi.new_ns_lock(RUSTFS_META_BUCKET, "leader.lock").await {
|
||||
Ok(ns_lock) => match ns_lock.get_write_lock_quiet(get_lock_acquire_timeout()).await {
|
||||
Ok(guard) => {
|
||||
record_scanner_cycle_schedule_role("leader");
|
||||
record_scanner_leader_lock_state("acquired");
|
||||
global_metrics().record_scanner_leader_liveness("acquired", true, "").await;
|
||||
debug!(
|
||||
@@ -1883,20 +2084,38 @@ async fn run_data_scanner_with_maintenance_state(
|
||||
guard
|
||||
}
|
||||
Err(e) => {
|
||||
record_scanner_leader_lock_state("contended");
|
||||
global_metrics()
|
||||
.record_scanner_leader_liveness("contended", false, e.to_string())
|
||||
.await;
|
||||
debug!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_LOCK_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
lock_name = "leader.lock",
|
||||
state = "contended",
|
||||
error = ?e,
|
||||
"Scanner leader lock contended"
|
||||
);
|
||||
match classify_scanner_leader_lock_failure(&e) {
|
||||
ScannerLeaderLockFailure::Contended => {
|
||||
record_scanner_cycle_schedule_role("follower");
|
||||
record_scanner_leader_lock_state("contended");
|
||||
global_metrics().record_scanner_leader_liveness("contended", false, "").await;
|
||||
debug!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_LOCK_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
lock_name = "leader.lock",
|
||||
state = "contended",
|
||||
"Scanner leader lock contended"
|
||||
);
|
||||
}
|
||||
ScannerLeaderLockFailure::Failed(error) => {
|
||||
record_scanner_leader_lock_state("acquire_failed");
|
||||
global_metrics()
|
||||
.record_scanner_leader_liveness("acquire_failed", false, error.to_string())
|
||||
.await;
|
||||
error!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_LOCK_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
lock_name = "leader.lock",
|
||||
state = "acquire_failed",
|
||||
error = %error,
|
||||
"Scanner leader lock acquisition failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
@@ -1967,24 +2186,81 @@ async fn run_data_scanner_with_maintenance_state(
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let usage_floor = match persisted_usage_floor(storeapi.clone()).await {
|
||||
Ok(floor) => floor,
|
||||
Err(err) => {
|
||||
error!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
path = %DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
state = "usage_floor_load_failed",
|
||||
error = %err,
|
||||
"Scanner stopped because the persisted usage floor could not be loaded"
|
||||
);
|
||||
global_metrics().set_cycle(None).await;
|
||||
return Ok(());
|
||||
let may_bootstrap_missing_usage_floor = scanner_may_bootstrap_missing_usage_floor(&cycle_info, leader_epoch, &cycle_revision);
|
||||
let (usage_floor, usage_floor_startup) =
|
||||
match persisted_usage_floor_for_startup(storeapi.clone(), may_bootstrap_missing_usage_floor).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
error!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
path = %DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
state = "usage_floor_load_failed",
|
||||
error = %err,
|
||||
"Scanner stopped because the persisted usage floor could not be loaded"
|
||||
);
|
||||
global_metrics().set_cycle(None).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if usage_floor_startup == PersistedUsageFloorStartup::BootstrapPending
|
||||
&& !scanner_may_resume_pristine_usage_bootstrap(&cycle_info)
|
||||
{
|
||||
error!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
path = %DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
state = "usage_floor_bootstrap_conflict",
|
||||
next_cycle = cycle_info.next,
|
||||
"Scanner stopped because a pristine usage bootstrap conflicts with persisted cycle progress"
|
||||
);
|
||||
global_metrics().set_cycle(None).await;
|
||||
return Ok(());
|
||||
}
|
||||
apply_persisted_usage_floor(&mut cycle_info, &mut leader_epoch, usage_floor);
|
||||
let allow_pristine_bootstrap_pending = match usage_floor_startup {
|
||||
PersistedUsageFloorStartup::Authoritative => false,
|
||||
PersistedUsageFloorStartup::BootstrapPending => true,
|
||||
PersistedUsageFloorStartup::Missing => {
|
||||
if !may_bootstrap_missing_usage_floor || ctx.is_cancelled() || guard.is_lock_lost() {
|
||||
global_metrics().set_cycle(None).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let bootstrap_ctx = ctx.child_token();
|
||||
match await_scanner_cycle_with_lock_fence(
|
||||
&bootstrap_ctx,
|
||||
initialize_pristine_usage_baseline(storeapi.clone()),
|
||||
guard.lock_lost_notified(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(err)) => {
|
||||
error!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
path = %DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
state = "usage_floor_bootstrap_failed",
|
||||
error = %err,
|
||||
"Scanner stopped because the pristine usage bootstrap could not be initialized"
|
||||
);
|
||||
global_metrics().set_cycle(None).await;
|
||||
return Ok(());
|
||||
}
|
||||
None => {
|
||||
global_metrics().set_cycle(None).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
apply_persisted_usage_floor(&mut cycle_info, &mut leader_epoch, usage_floor);
|
||||
|
||||
if ctx.is_cancelled() || guard.is_lock_lost() {
|
||||
global_metrics().set_cycle(None).await;
|
||||
@@ -1993,7 +2269,14 @@ async fn run_data_scanner_with_maintenance_state(
|
||||
let claim_ctx = ctx.child_token();
|
||||
let leadership_claimed = await_scanner_cycle_with_lock_fence(
|
||||
&claim_ctx,
|
||||
claim_scanner_leadership(&claim_ctx, storeapi.clone(), &mut cycle_info, &mut cycle_revision, &mut leader_epoch),
|
||||
claim_scanner_leadership(
|
||||
&claim_ctx,
|
||||
storeapi.clone(),
|
||||
&mut cycle_info,
|
||||
&mut cycle_revision,
|
||||
&mut leader_epoch,
|
||||
allow_pristine_bootstrap_pending,
|
||||
),
|
||||
guard.lock_lost_notified(),
|
||||
)
|
||||
.await
|
||||
@@ -2199,7 +2482,16 @@ async fn run_data_scanner_with_maintenance_state(
|
||||
);
|
||||
|
||||
let activity_poll_interval = backoff_enabled.then_some(runtime_config.cycle_interval.max(Duration::from_secs(1)));
|
||||
let wake_reason = wait_for_next_scanner_cycle_with_activity(
|
||||
let movement_generation_before_wait = storeapi.scanner_data_movement_generation();
|
||||
let movement_changed = storeapi.scanner_data_movement_changed();
|
||||
let movement_store = storeapi.clone();
|
||||
let movement = ScannerMovementWaitContext {
|
||||
movement_generation_seen: Some(movement_generation_before_wait),
|
||||
movement_changed,
|
||||
current_movement_generation: move || movement_store.scanner_data_movement_generation(),
|
||||
is_lock_lost: || guard.is_lock_lost(),
|
||||
};
|
||||
let wake_reason = wait_for_next_scanner_cycle_with_activity_and_movement(
|
||||
&ctx,
|
||||
wait_plan.delay,
|
||||
activity_poll_interval,
|
||||
@@ -2211,7 +2503,7 @@ async fn run_data_scanner_with_maintenance_state(
|
||||
runtime_config_generation_seen,
|
||||
maintenance_generation_before_wait,
|
||||
),
|
||||
|| guard.is_lock_lost(),
|
||||
movement,
|
||||
|| probe_scanner_activity(storeapi.as_ref(), distributed),
|
||||
)
|
||||
.await;
|
||||
@@ -2239,6 +2531,10 @@ async fn run_data_scanner_with_maintenance_state(
|
||||
ScannerCycleWakeReason::ClusterMaintenance => {
|
||||
clean_idle_backoff.reset();
|
||||
}
|
||||
ScannerCycleWakeReason::MovementGeneration => {
|
||||
scanner_activity_seen = None;
|
||||
clean_idle_backoff.reset();
|
||||
}
|
||||
ScannerCycleWakeReason::Timer
|
||||
| ScannerCycleWakeReason::DirtyUsage
|
||||
| ScannerCycleWakeReason::ClusterActivity
|
||||
@@ -2612,7 +2908,7 @@ use usage_store::*;
|
||||
pub use activity::scanner_topology_digest;
|
||||
pub(crate) use activity::{
|
||||
ScannerActivitySnapshot, ScannerDirtyUsageAcknowledgement, probe_scanner_activity, scanner_activity_allows_usage_publication,
|
||||
scanner_activity_snapshot_digest, scanner_dirty_usage_acknowledgements,
|
||||
scanner_activity_publication_lease_targets, scanner_activity_snapshot_digest, scanner_dirty_usage_acknowledgements,
|
||||
};
|
||||
pub(crate) use activity::{ScannerCycleOutcome, scanner_cycle_outcome_with_pending_maintenance};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
// limitations under the License.
|
||||
/// Cycle wake/backoff policy and scanner activity observation (probing, generations, topology digest).
|
||||
use super::*;
|
||||
use crate::storage_api::scan::SCANNER_ACTIVITY_V6_PROTOCOL_VERSION;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ScannerCycleWakeReason {
|
||||
Timer,
|
||||
DirtyUsage,
|
||||
MovementGeneration,
|
||||
ClusterActivity,
|
||||
ClusterMaintenance,
|
||||
ClusterActivityUnavailable,
|
||||
@@ -250,6 +252,17 @@ impl ScannerCycleObservedGenerations {
|
||||
}
|
||||
}
|
||||
|
||||
/// Movement state observed while a scanner waits for the next cycle.
|
||||
///
|
||||
/// Keeping the movement inputs together makes it harder for callers to pair a
|
||||
/// generation with the wrong notification or lock predicate.
|
||||
pub(super) struct ScannerMovementWaitContext<G, F> {
|
||||
pub(super) movement_generation_seen: Option<u64>,
|
||||
pub(super) movement_changed: Arc<Notify>,
|
||||
pub(super) current_movement_generation: G,
|
||||
pub(super) is_lock_lost: F,
|
||||
}
|
||||
|
||||
pub(super) const LOCAL_SCANNER_ACTIVITY_NODE: &str = "<local>";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -262,6 +275,8 @@ pub(crate) struct ScannerNodeActivity {
|
||||
pub(super) data_movement_active: bool,
|
||||
pub(super) dirty_usage_generation: u64,
|
||||
pub(super) dirty_usage_pending: bool,
|
||||
pub(super) movement_generation: u64,
|
||||
pub(super) publication_blocked: bool,
|
||||
}
|
||||
|
||||
pub(crate) type ScannerActivitySnapshot = BTreeMap<String, ScannerNodeActivity>;
|
||||
@@ -278,6 +293,14 @@ pub(super) enum ScannerActivityObservation {
|
||||
NotRequired,
|
||||
Unchanged,
|
||||
Changed,
|
||||
/// A storage-owned movement generation changed. This wake must bypass the
|
||||
/// ordinary deferred cluster-activity backoff so publication can retry
|
||||
/// after a transition reaches its terminal state.
|
||||
MovementChanged,
|
||||
/// A remote scanner process restarted. Publication leases are bound to the
|
||||
/// process instance, so this must bypass deferred cluster-activity backoff
|
||||
/// even when the restarted peer reports otherwise ordinary activity.
|
||||
RemoteRestarted,
|
||||
MaintenanceChanged,
|
||||
Unverified,
|
||||
}
|
||||
@@ -373,6 +396,8 @@ pub(super) fn scanner_activity_observed_work(observation: ScannerActivityObserva
|
||||
matches!(
|
||||
observation,
|
||||
ScannerActivityObservation::Changed
|
||||
| ScannerActivityObservation::MovementChanged
|
||||
| ScannerActivityObservation::RemoteRestarted
|
||||
| ScannerActivityObservation::MaintenanceChanged
|
||||
| ScannerActivityObservation::Unverified
|
||||
)
|
||||
@@ -386,6 +411,7 @@ pub(super) fn scanner_activity_backoff_blocked_after_wake(currently_blocked: boo
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn wait_for_next_scanner_cycle<F>(
|
||||
ctx: &CancellationToken,
|
||||
delay: Duration,
|
||||
@@ -396,6 +422,36 @@ pub(super) async fn wait_for_next_scanner_cycle<F>(
|
||||
) -> ScannerCycleWakeReason
|
||||
where
|
||||
F: Fn() -> bool,
|
||||
{
|
||||
let movement = ScannerMovementWaitContext {
|
||||
movement_generation_seen: None,
|
||||
movement_changed: Arc::new(Notify::new()),
|
||||
current_movement_generation: || 0,
|
||||
is_lock_lost,
|
||||
};
|
||||
wait_for_next_scanner_cycle_with_movement(
|
||||
ctx,
|
||||
delay,
|
||||
ScannerCycleObservedGenerations {
|
||||
dirty_usage: dirty_usage_generation_seen,
|
||||
runtime_config: runtime_config_generation,
|
||||
maintenance: maintenance_generation,
|
||||
defer_cluster_activity: false,
|
||||
},
|
||||
&movement,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_next_scanner_cycle_with_movement<G, F>(
|
||||
ctx: &CancellationToken,
|
||||
delay: Duration,
|
||||
generations: ScannerCycleObservedGenerations,
|
||||
movement: &ScannerMovementWaitContext<G, F>,
|
||||
) -> ScannerCycleWakeReason
|
||||
where
|
||||
F: Fn() -> bool,
|
||||
G: Fn() -> u64,
|
||||
{
|
||||
let sleep = tokio::time::sleep(delay);
|
||||
tokio::pin!(sleep);
|
||||
@@ -403,55 +459,86 @@ where
|
||||
tokio::pin!(lock_poll);
|
||||
|
||||
loop {
|
||||
if is_lock_lost() {
|
||||
if (movement.is_lock_lost)() {
|
||||
return ScannerCycleWakeReason::LeaderLockLost;
|
||||
}
|
||||
if scanner_runtime_config_generation() != runtime_config_generation {
|
||||
if scanner_runtime_config_generation() != generations.runtime_config {
|
||||
return ScannerCycleWakeReason::RuntimeConfig;
|
||||
}
|
||||
if scanner_maintenance_generation() != maintenance_generation {
|
||||
if scanner_maintenance_generation() != generations.maintenance {
|
||||
return ScannerCycleWakeReason::MaintenanceConfig;
|
||||
}
|
||||
if dirty_usage_generation_seen.is_some_and(|seen| dirty_usage_buckets_pending() && dirty_usage_generation() != seen) {
|
||||
if generations
|
||||
.dirty_usage
|
||||
.is_some_and(|seen| dirty_usage_buckets_pending() && dirty_usage_generation() != seen)
|
||||
{
|
||||
return ScannerCycleWakeReason::DirtyUsage;
|
||||
}
|
||||
if movement
|
||||
.movement_generation_seen
|
||||
.is_some_and(|seen| (movement.current_movement_generation)() != seen)
|
||||
{
|
||||
return ScannerCycleWakeReason::MovementGeneration;
|
||||
}
|
||||
|
||||
let movement_notification = movement.movement_changed.notified();
|
||||
tokio::pin!(movement_notification);
|
||||
movement_notification.as_mut().enable();
|
||||
// A transition may finish between the initial generation read and
|
||||
// registration with Notify. Re-check after `enable()` so that such a
|
||||
// transition cannot be lost when it used `notify_waiters()`.
|
||||
if movement
|
||||
.movement_generation_seen
|
||||
.is_some_and(|seen| (movement.current_movement_generation)() != seen)
|
||||
{
|
||||
return ScannerCycleWakeReason::MovementGeneration;
|
||||
}
|
||||
tokio::select! {
|
||||
_ = ctx.cancelled() => return ScannerCycleWakeReason::Cancelled,
|
||||
_ = &mut sleep => return ScannerCycleWakeReason::Timer,
|
||||
_ = &mut lock_poll => {
|
||||
if is_lock_lost() {
|
||||
if (movement.is_lock_lost)() {
|
||||
return ScannerCycleWakeReason::LeaderLockLost;
|
||||
}
|
||||
lock_poll.as_mut().reset(Instant::now() + SCANNER_LEADER_LOCK_POLL_INTERVAL);
|
||||
}
|
||||
_ = dirty_usage_bucket_notified() => {
|
||||
if scanner_runtime_config_generation() != runtime_config_generation {
|
||||
if scanner_runtime_config_generation() != generations.runtime_config {
|
||||
return ScannerCycleWakeReason::RuntimeConfig;
|
||||
}
|
||||
if scanner_maintenance_generation() != maintenance_generation {
|
||||
if scanner_maintenance_generation() != generations.maintenance {
|
||||
return ScannerCycleWakeReason::MaintenanceConfig;
|
||||
}
|
||||
if dirty_usage_generation_seen
|
||||
if generations
|
||||
.dirty_usage
|
||||
.is_some_and(|seen| dirty_usage_buckets_pending() && dirty_usage_generation() != seen)
|
||||
{
|
||||
return ScannerCycleWakeReason::DirtyUsage;
|
||||
}
|
||||
}
|
||||
_ = scanner_runtime_config_changed() => {
|
||||
if scanner_runtime_config_generation() != runtime_config_generation {
|
||||
if scanner_runtime_config_generation() != generations.runtime_config {
|
||||
return ScannerCycleWakeReason::RuntimeConfig;
|
||||
}
|
||||
}
|
||||
_ = scanner_maintenance_changed() => {
|
||||
if scanner_maintenance_generation() != maintenance_generation {
|
||||
if scanner_maintenance_generation() != generations.maintenance {
|
||||
return ScannerCycleWakeReason::MaintenanceConfig;
|
||||
}
|
||||
}
|
||||
_ = &mut movement_notification => {
|
||||
if movement
|
||||
.movement_generation_seen
|
||||
.is_some_and(|seen| (movement.current_movement_generation)() != seen)
|
||||
{
|
||||
return ScannerCycleWakeReason::MovementGeneration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn wait_for_next_scanner_cycle_with_activity<F, Probe, ProbeFuture>(
|
||||
ctx: &CancellationToken,
|
||||
delay: Duration,
|
||||
@@ -459,10 +546,43 @@ pub(super) async fn wait_for_next_scanner_cycle_with_activity<F, Probe, ProbeFut
|
||||
activity_seen: &mut Option<ScannerActivitySnapshot>,
|
||||
generations: ScannerCycleObservedGenerations,
|
||||
is_lock_lost: F,
|
||||
probe_activity: Probe,
|
||||
) -> ScannerCycleWakeReason
|
||||
where
|
||||
F: Fn() -> bool,
|
||||
Probe: FnMut() -> ProbeFuture,
|
||||
ProbeFuture: Future<Output = Result<ScannerActivitySnapshot, String>>,
|
||||
{
|
||||
let movement = ScannerMovementWaitContext {
|
||||
movement_generation_seen: None,
|
||||
movement_changed: Arc::new(Notify::new()),
|
||||
current_movement_generation: || 0,
|
||||
is_lock_lost,
|
||||
};
|
||||
wait_for_next_scanner_cycle_with_activity_and_movement(
|
||||
ctx,
|
||||
delay,
|
||||
activity_poll_interval,
|
||||
activity_seen,
|
||||
generations,
|
||||
movement,
|
||||
probe_activity,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_next_scanner_cycle_with_activity_and_movement<F, G, Probe, ProbeFuture>(
|
||||
ctx: &CancellationToken,
|
||||
delay: Duration,
|
||||
activity_poll_interval: Option<Duration>,
|
||||
activity_seen: &mut Option<ScannerActivitySnapshot>,
|
||||
generations: ScannerCycleObservedGenerations,
|
||||
movement: ScannerMovementWaitContext<G, F>,
|
||||
mut probe_activity: Probe,
|
||||
) -> ScannerCycleWakeReason
|
||||
where
|
||||
F: Fn() -> bool,
|
||||
G: Fn() -> u64,
|
||||
Probe: FnMut() -> ProbeFuture,
|
||||
ProbeFuture: Future<Output = Result<ScannerActivitySnapshot, String>>,
|
||||
{
|
||||
@@ -475,15 +595,7 @@ where
|
||||
let wait_slice = activity_poll_interval
|
||||
.map(|interval| interval.max(Duration::from_secs(1)).min(remaining))
|
||||
.unwrap_or(remaining);
|
||||
let wake_reason = wait_for_next_scanner_cycle(
|
||||
ctx,
|
||||
wait_slice,
|
||||
generations.dirty_usage,
|
||||
generations.runtime_config,
|
||||
generations.maintenance,
|
||||
&is_lock_lost,
|
||||
)
|
||||
.await;
|
||||
let wake_reason = wait_for_next_scanner_cycle_with_movement(ctx, wait_slice, generations, &movement).await;
|
||||
if wake_reason != ScannerCycleWakeReason::Timer || Instant::now() >= deadline {
|
||||
return wake_reason;
|
||||
}
|
||||
@@ -491,7 +603,7 @@ where
|
||||
let Some(_) = activity_poll_interval else {
|
||||
return ScannerCycleWakeReason::Timer;
|
||||
};
|
||||
if is_lock_lost() {
|
||||
if (movement.is_lock_lost)() {
|
||||
return ScannerCycleWakeReason::LeaderLockLost;
|
||||
}
|
||||
|
||||
@@ -500,7 +612,7 @@ where
|
||||
let lock_lost = async {
|
||||
loop {
|
||||
tokio::time::sleep(SCANNER_LEADER_LOCK_POLL_INTERVAL).await;
|
||||
if is_lock_lost() {
|
||||
if (movement.is_lock_lost)() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -519,6 +631,9 @@ where
|
||||
}
|
||||
match observation {
|
||||
ScannerActivityObservation::Unchanged | ScannerActivityObservation::NotRequired => {}
|
||||
ScannerActivityObservation::MovementChanged | ScannerActivityObservation::RemoteRestarted => {
|
||||
return ScannerCycleWakeReason::ClusterActivity;
|
||||
}
|
||||
ScannerActivityObservation::Changed if !generations.defer_cluster_activity => {
|
||||
return ScannerCycleWakeReason::ClusterActivity;
|
||||
}
|
||||
@@ -568,12 +683,23 @@ pub(super) fn compare_scanner_activity(
|
||||
let Some(previous_activity) = previous.get(host) else {
|
||||
continue;
|
||||
};
|
||||
if host != LOCAL_SCANNER_ACTIVITY_NODE && previous_activity.instance_id != current_activity.instance_id {
|
||||
return ScannerActivityObservation::RemoteRestarted;
|
||||
}
|
||||
if host != LOCAL_SCANNER_ACTIVITY_NODE
|
||||
&& previous_activity.instance_id == current_activity.instance_id
|
||||
&& previous_activity.maintenance_generation != current_activity.maintenance_generation
|
||||
{
|
||||
return ScannerActivityObservation::MaintenanceChanged;
|
||||
}
|
||||
|
||||
if previous_activity.instance_id == current_activity.instance_id
|
||||
&& (previous_activity.data_movement_active != current_activity.data_movement_active
|
||||
|| previous_activity.movement_generation != current_activity.movement_generation
|
||||
|| previous_activity.publication_blocked != current_activity.publication_blocked)
|
||||
{
|
||||
return ScannerActivityObservation::MovementChanged;
|
||||
}
|
||||
}
|
||||
|
||||
ScannerActivityObservation::Changed
|
||||
@@ -630,12 +756,28 @@ pub(crate) fn scanner_activity_snapshot_digest(snapshot: &ScannerActivitySnapsho
|
||||
hasher.update([u8::from(activity.data_movement_active)]);
|
||||
hasher.update(activity.dirty_usage_generation.to_be_bytes());
|
||||
hasher.update([u8::from(activity.dirty_usage_pending)]);
|
||||
hasher.update(activity.movement_generation.to_be_bytes());
|
||||
hasher.update([u8::from(activity.publication_blocked)]);
|
||||
}
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
pub(crate) fn scanner_activity_allows_usage_publication(snapshot: &ScannerActivitySnapshot) -> bool {
|
||||
snapshot.values().all(|activity| !activity.data_movement_active)
|
||||
!snapshot.is_empty()
|
||||
&& snapshot.values().all(|activity| {
|
||||
activity.protocol_version == SCANNER_ACTIVITY_PROTOCOL_VERSION
|
||||
&& activity.movement_generation != u64::MAX
|
||||
&& !activity.data_movement_active
|
||||
&& !activity.publication_blocked
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn scanner_activity_publication_lease_targets(snapshot: &ScannerActivitySnapshot) -> Vec<(String, String, u64)> {
|
||||
snapshot
|
||||
.iter()
|
||||
.filter(|(host, _)| host.as_str() != LOCAL_SCANNER_ACTIVITY_NODE)
|
||||
.map(|(host, activity)| (host.clone(), activity.instance_id.clone(), activity.movement_generation))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn scanner_dirty_usage_acknowledgements(snapshot: &ScannerActivitySnapshot) -> Vec<ScannerDirtyUsageAcknowledgement> {
|
||||
@@ -695,11 +837,15 @@ pub(super) fn record_scanner_activity_instance(
|
||||
|
||||
pub(crate) async fn probe_scanner_activity(storeapi: &ECStore, distributed: bool) -> Result<ScannerActivitySnapshot, String> {
|
||||
let topology_digest = scanner_topology_digest(storeapi);
|
||||
let data_movement_active = storeapi.scanner_data_movement_active().await;
|
||||
let (data_movement_active, publication_blocked, movement_generation) = storeapi.scanner_data_movement_activity().await;
|
||||
let namespace_generation = storeapi.scanner_namespace_mutation_generation();
|
||||
let maintenance_generation = scanner_maintenance_generation();
|
||||
let dirty_usage = scanner_dirty_usage_state();
|
||||
if namespace_generation == u64::MAX || maintenance_generation == u64::MAX || dirty_usage.generation == u64::MAX {
|
||||
if namespace_generation == u64::MAX
|
||||
|| maintenance_generation == u64::MAX
|
||||
|| dirty_usage.generation == u64::MAX
|
||||
|| movement_generation == u64::MAX
|
||||
{
|
||||
return Err("local scanner activity generation is exhausted".to_string());
|
||||
}
|
||||
let local_instance_id = crate::scanner_io::scanner_activity_epoch().to_string();
|
||||
@@ -715,6 +861,8 @@ pub(crate) async fn probe_scanner_activity(storeapi: &ECStore, distributed: bool
|
||||
data_movement_active,
|
||||
dirty_usage_generation: dirty_usage.generation,
|
||||
dirty_usage_pending: dirty_usage.pending,
|
||||
movement_generation,
|
||||
publication_blocked,
|
||||
},
|
||||
)]);
|
||||
if !distributed {
|
||||
@@ -732,39 +880,57 @@ pub(crate) async fn probe_scanner_activity(storeapi: &ECStore, distributed: bool
|
||||
if activity.namespace_generation == u64::MAX || activity.maintenance_generation == u64::MAX {
|
||||
return Err(format!("scanner activity peer {host} exhausted its activity generation"));
|
||||
}
|
||||
let (peer_topology_digest, peer_data_movement_active, peer_dirty_usage_generation, peer_dirty_usage_pending) =
|
||||
match activity.protocol_version {
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION => {
|
||||
return Err(format!("scanner activity peer {host} cannot verify data movement publication fencing"));
|
||||
}
|
||||
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION => {
|
||||
return Err(format!(
|
||||
"scanner activity peer {host} cannot safely share scanner cache locks with protocol {}",
|
||||
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION
|
||||
));
|
||||
}
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION => (
|
||||
activity
|
||||
.topology_digest
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its storage topology"))?,
|
||||
activity
|
||||
.data_movement_active
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its data movement state"))?,
|
||||
activity
|
||||
.dirty_usage_generation
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its dirty usage generation"))?,
|
||||
activity
|
||||
.dirty_usage_pending
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its dirty usage state"))?,
|
||||
),
|
||||
version => {
|
||||
return Err(format!(
|
||||
"scanner activity peer {host} uses protocol {version}, expected {}",
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION
|
||||
));
|
||||
}
|
||||
};
|
||||
if peer_dirty_usage_generation == u64::MAX {
|
||||
let (
|
||||
peer_topology_digest,
|
||||
peer_data_movement_active,
|
||||
peer_dirty_usage_generation,
|
||||
peer_dirty_usage_pending,
|
||||
peer_movement_generation,
|
||||
peer_publication_blocked,
|
||||
) = match activity.protocol_version {
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION => {
|
||||
return Err(format!("scanner activity peer {host} cannot verify data movement publication fencing"));
|
||||
}
|
||||
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION => {
|
||||
return Err(format!(
|
||||
"scanner activity peer {host} cannot safely share scanner cache locks with protocol {}",
|
||||
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION
|
||||
));
|
||||
}
|
||||
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION => {
|
||||
return Err(format!(
|
||||
"scanner activity peer {host} cannot verify terminal movement state with protocol {}",
|
||||
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION
|
||||
));
|
||||
}
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION => (
|
||||
activity
|
||||
.topology_digest
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its storage topology"))?,
|
||||
activity
|
||||
.data_movement_active
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its data movement state"))?,
|
||||
activity
|
||||
.dirty_usage_generation
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its dirty usage generation"))?,
|
||||
activity
|
||||
.dirty_usage_pending
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its dirty usage state"))?,
|
||||
activity
|
||||
.movement_generation
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its movement generation"))?,
|
||||
activity
|
||||
.publication_blocked
|
||||
.ok_or_else(|| format!("scanner activity peer {host} omitted its publication blocked state"))?,
|
||||
),
|
||||
version => {
|
||||
return Err(format!(
|
||||
"scanner activity peer {host} uses protocol {version}, expected {}",
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION
|
||||
));
|
||||
}
|
||||
};
|
||||
if peer_dirty_usage_generation == u64::MAX || peer_movement_generation == u64::MAX {
|
||||
return Err(format!("scanner activity peer {host} exhausted its dirty usage generation"));
|
||||
}
|
||||
if peer_topology_digest != topology_digest {
|
||||
@@ -783,6 +949,8 @@ pub(crate) async fn probe_scanner_activity(storeapi: &ECStore, distributed: bool
|
||||
data_movement_active: peer_data_movement_active,
|
||||
dirty_usage_generation: peer_dirty_usage_generation,
|
||||
dirty_usage_pending: peer_dirty_usage_pending,
|
||||
movement_generation: peer_movement_generation,
|
||||
publication_blocked: peer_publication_blocked,
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
|
||||
@@ -926,7 +926,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
"scanner leader lock was lost after fencing newer cycle state".to_string(),
|
||||
));
|
||||
}
|
||||
fence_scanner_usage_epoch_with_expected_epoch(&ctx, storeapi.clone(), fence_epoch, Some(reset_epoch))
|
||||
fence_scanner_usage_epoch_with_expected_epoch(&ctx, storeapi.clone(), fence_epoch, Some(reset_epoch), false)
|
||||
.await
|
||||
.map_err(|err| ScannerError::Other(format!("failed to fence preserved scanner usage epoch: {err}")))?;
|
||||
if guard.is_lock_lost() {
|
||||
@@ -1032,7 +1032,8 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
"scanner leader lock was lost after rebuilding cycle state".to_string(),
|
||||
));
|
||||
}
|
||||
if let Err(err) = fence_scanner_usage_epoch_with_expected_epoch(&ctx, storeapi.clone(), leader_epoch, Some(reset_epoch)).await
|
||||
if let Err(err) =
|
||||
fence_scanner_usage_epoch_with_expected_epoch(&ctx, storeapi.clone(), leader_epoch, Some(reset_epoch), false).await
|
||||
{
|
||||
set_scanner_cycle_recovery_status(ScannerCycleRecoveryStatus {
|
||||
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||
@@ -1179,6 +1180,13 @@ pub(super) struct PersistedUsageFloor {
|
||||
pub(super) leader_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum PersistedUsageFloorStartup {
|
||||
Authoritative,
|
||||
Missing,
|
||||
BootstrapPending,
|
||||
}
|
||||
|
||||
pub(super) fn encode_scanner_cycle_state(
|
||||
cycle_info: &CurrentCycle,
|
||||
leader_epoch: u64,
|
||||
@@ -1300,11 +1308,25 @@ pub(super) fn advance_scanner_cycle(cycle_info: &mut CurrentCycle) -> Result<(),
|
||||
pub(super) async fn persisted_usage_floor(
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
) -> Result<PersistedUsageFloor, ScannerError> {
|
||||
let (floor, state) = persisted_usage_floor_for_startup(storeapi, false).await?;
|
||||
if state != PersistedUsageFloorStartup::Authoritative {
|
||||
return Err(ScannerError::Other(
|
||||
"persisted scanner usage floor has no authoritative baseline".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(floor)
|
||||
}
|
||||
|
||||
pub(super) async fn persisted_usage_floor_for_startup(
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
allow_missing_for_pristine_startup: bool,
|
||||
) -> Result<(PersistedUsageFloor, PersistedUsageFloorStartup), ScannerError> {
|
||||
let Some(read_epoch) = scanner_publication_epoch(storeapi.clone()).await else {
|
||||
return Err(ScannerError::Other("scanner usage floor read is blocked by data movement".to_string()));
|
||||
};
|
||||
let mut floor = PersistedUsageFloor::default();
|
||||
let mut found_any = false;
|
||||
let mut bootstrap_pending = false;
|
||||
let update_floor = |floor: &mut PersistedUsageFloor, usage: &DataUsageInfo, path: &str| -> Result<(), ScannerError> {
|
||||
floor.leader_epoch = floor.leader_epoch.max(usage.scanner_epoch.unwrap_or_default());
|
||||
if let Some(completed_cycle) = usage.scanner_cycle {
|
||||
@@ -1318,21 +1340,31 @@ pub(super) async fn persisted_usage_floor(
|
||||
};
|
||||
for primary_path in [DATA_USAGE_OBJ_NAME_PATH.as_str(), LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()] {
|
||||
let backup_path = format!("{primary_path}.bkp");
|
||||
let primary_epoch = match read_config(storeapi.clone(), primary_path).await {
|
||||
Ok(data) => {
|
||||
let primary_epoch = match read_config_with_revision(storeapi.clone(), primary_path).await {
|
||||
Ok((Some(data), _)) => {
|
||||
let usage = serde_json::from_slice::<DataUsageInfo>(&data).map_err(|err| {
|
||||
ScannerError::Other(format!("failed to decode scanner usage floor from {primary_path}: {err}"))
|
||||
})?;
|
||||
if !data_usage_info_has_persisted_baseline_identity(&usage) {
|
||||
if data_usage_info_is_pristine_bootstrap_pending(&usage) && primary_path == DATA_USAGE_OBJ_NAME_PATH.as_str() {
|
||||
if bootstrap_pending {
|
||||
return Err(ScannerError::Other(
|
||||
"multiple pristine scanner usage bootstrap markers were found".to_string(),
|
||||
));
|
||||
}
|
||||
bootstrap_pending = true;
|
||||
update_floor(&mut floor, &usage, primary_path)?;
|
||||
None
|
||||
} else if !data_usage_info_has_persisted_baseline_identity(&usage) {
|
||||
return Err(ScannerError::Other(format!(
|
||||
"scanner usage floor from {primary_path} has no persisted baseline identity"
|
||||
)));
|
||||
} else {
|
||||
let epoch = usage.scanner_epoch.unwrap_or_default();
|
||||
update_floor(&mut floor, &usage, primary_path)?;
|
||||
Some(epoch)
|
||||
}
|
||||
let epoch = usage.scanner_epoch.unwrap_or_default();
|
||||
update_floor(&mut floor, &usage, primary_path)?;
|
||||
Some(epoch)
|
||||
}
|
||||
Err(EcstoreError::ConfigNotFound) => None,
|
||||
Ok((None, _)) => None,
|
||||
Err(err) => {
|
||||
return Err(ScannerError::Other(format!(
|
||||
"failed to read scanner usage epoch floor from {primary_path}: {err}"
|
||||
@@ -1340,8 +1372,13 @@ pub(super) async fn persisted_usage_floor(
|
||||
}
|
||||
};
|
||||
let mut any_found = primary_epoch.is_some();
|
||||
match read_config(storeapi.clone(), &backup_path).await {
|
||||
Ok(data) => {
|
||||
match read_config_with_revision(storeapi.clone(), &backup_path).await {
|
||||
Ok((Some(data), _)) => {
|
||||
if bootstrap_pending {
|
||||
return Err(ScannerError::Other(
|
||||
"pristine scanner usage bootstrap conflicts with a persisted backup".to_string(),
|
||||
));
|
||||
}
|
||||
any_found = true;
|
||||
let usage = serde_json::from_slice::<DataUsageInfo>(&data).map_err(|err| {
|
||||
ScannerError::Other(format!("failed to decode scanner usage floor from {backup_path}: {err}"))
|
||||
@@ -1359,7 +1396,7 @@ pub(super) async fn persisted_usage_floor(
|
||||
update_floor(&mut floor, &usage, &backup_path)?;
|
||||
}
|
||||
}
|
||||
Err(EcstoreError::ConfigNotFound) => {}
|
||||
Ok((None, _)) => {}
|
||||
Err(err) => {
|
||||
return Err(ScannerError::Other(format!(
|
||||
"failed to read scanner usage epoch floor from {backup_path}: {err}"
|
||||
@@ -1367,22 +1404,62 @@ pub(super) async fn persisted_usage_floor(
|
||||
}
|
||||
}
|
||||
if any_found {
|
||||
if bootstrap_pending {
|
||||
return Err(ScannerError::Other(
|
||||
"pristine scanner usage bootstrap conflicts with an authoritative usage floor".to_string(),
|
||||
));
|
||||
}
|
||||
found_any = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_any {
|
||||
return Err(ScannerError::Other(
|
||||
"persisted scanner usage floor has no authoritative baseline".to_string(),
|
||||
));
|
||||
if !found_any && !bootstrap_pending {
|
||||
if !allow_missing_for_pristine_startup {
|
||||
return Err(ScannerError::Other(
|
||||
"persisted scanner usage floor has no authoritative baseline".to_string(),
|
||||
));
|
||||
}
|
||||
let Some(publication_admission) = scanner_publication_admission_for_epoch(storeapi.clone(), read_epoch).await else {
|
||||
return Err(ScannerError::Other(
|
||||
"scanner usage floor changed before pristine state confirmation".to_string(),
|
||||
));
|
||||
};
|
||||
for path in [
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str().to_string(),
|
||||
format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||
LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str().to_string(),
|
||||
format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||
] {
|
||||
match read_config_with_revision(storeapi.clone(), &path).await {
|
||||
Ok((None, _)) => {}
|
||||
Ok((Some(_), _)) => {
|
||||
return Err(ScannerError::Other(format!(
|
||||
"scanner usage floor changed while confirming pristine state: {path} appeared"
|
||||
)));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(ScannerError::Other(format!(
|
||||
"failed to confirm pristine scanner usage floor at {path}: {err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(publication_admission);
|
||||
}
|
||||
let Some(_publication_admission) = scanner_publication_admission_for_epoch(storeapi, read_epoch).await else {
|
||||
return Err(ScannerError::Other(
|
||||
"scanner usage floor changed while its epoch proof was being confirmed".to_string(),
|
||||
));
|
||||
};
|
||||
Ok(floor)
|
||||
let state = if found_any {
|
||||
PersistedUsageFloorStartup::Authoritative
|
||||
} else if bootstrap_pending {
|
||||
PersistedUsageFloorStartup::BootstrapPending
|
||||
} else {
|
||||
PersistedUsageFloorStartup::Missing
|
||||
};
|
||||
Ok((floor, state))
|
||||
}
|
||||
|
||||
pub(super) fn apply_persisted_usage_floor(cycle_info: &mut CurrentCycle, leader_epoch: &mut u64, floor: PersistedUsageFloor) {
|
||||
|
||||
@@ -60,10 +60,18 @@ pub(super) async fn reconcile_scanner_leadership_claim(
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn decode_usage_snapshot_for_epoch_fence(data: &[u8], path: &str) -> Result<DataUsageInfo, ScannerError> {
|
||||
pub(super) fn decode_usage_snapshot_for_epoch_fence(
|
||||
data: &[u8],
|
||||
path: &str,
|
||||
allow_pristine_bootstrap_pending: bool,
|
||||
) -> Result<DataUsageInfo, ScannerError> {
|
||||
let usage: DataUsageInfo = serde_json::from_slice(data)
|
||||
.map_err(|err| ScannerError::Other(format!("failed to decode scanner usage epoch fence from {path}: {err}")))?;
|
||||
if !data_usage_info_has_persisted_baseline_identity(&usage) {
|
||||
if !data_usage_info_has_persisted_baseline_identity(&usage)
|
||||
&& !(allow_pristine_bootstrap_pending
|
||||
&& path == DATA_USAGE_OBJ_NAME_PATH.as_str()
|
||||
&& data_usage_info_is_pristine_bootstrap_pending(&usage))
|
||||
{
|
||||
return Err(ScannerError::Other(format!(
|
||||
"scanner usage epoch fence from {path} has no persisted baseline identity"
|
||||
)));
|
||||
@@ -74,9 +82,15 @@ pub(super) fn decode_usage_snapshot_for_epoch_fence(data: &[u8], path: &str) ->
|
||||
pub(super) async fn usage_snapshot_for_epoch_fence(
|
||||
storeapi: Arc<impl ScannerObjectIO>,
|
||||
primary: Option<&[u8]>,
|
||||
allow_pristine_bootstrap_pending: bool,
|
||||
) -> Result<Option<DataUsageInfo>, ScannerError> {
|
||||
if let Some(primary) = primary {
|
||||
return decode_usage_snapshot_for_epoch_fence(primary, DATA_USAGE_OBJ_NAME_PATH.as_str()).map(Some);
|
||||
return decode_usage_snapshot_for_epoch_fence(
|
||||
primary,
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
allow_pristine_bootstrap_pending,
|
||||
)
|
||||
.map(Some);
|
||||
}
|
||||
|
||||
let backup_path = format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str());
|
||||
@@ -84,7 +98,7 @@ pub(super) async fn usage_snapshot_for_epoch_fence(
|
||||
.await
|
||||
.map_err(|err| ScannerError::Other(format!("failed to read scanner usage epoch fence backup: {err}")))?;
|
||||
if let Some(backup) = backup.as_deref() {
|
||||
return decode_usage_snapshot_for_epoch_fence(backup, &backup_path).map(Some);
|
||||
return decode_usage_snapshot_for_epoch_fence(backup, &backup_path, false).map(Some);
|
||||
}
|
||||
|
||||
for path in [
|
||||
@@ -95,7 +109,7 @@ pub(super) async fn usage_snapshot_for_epoch_fence(
|
||||
.await
|
||||
.map_err(|err| ScannerError::Other(format!("failed to read legacy scanner usage epoch fence: {err}")))?;
|
||||
if let Some(legacy) = legacy.as_deref() {
|
||||
return decode_usage_snapshot_for_epoch_fence(legacy, &path).map(Some);
|
||||
return decode_usage_snapshot_for_epoch_fence(legacy, &path, false).map(Some);
|
||||
}
|
||||
}
|
||||
// A missing usage snapshot is an uninitialized state, not an empty
|
||||
@@ -104,11 +118,58 @@ pub(super) async fn usage_snapshot_for_epoch_fence(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) async fn initialize_pristine_usage_baseline(
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
) -> Result<(), ScannerError> {
|
||||
let Some(expected_epoch) = scanner_publication_epoch(storeapi.clone()).await else {
|
||||
return Err(ScannerError::Other(
|
||||
"pristine scanner usage baseline initialization is blocked by data movement".to_string(),
|
||||
));
|
||||
};
|
||||
let baseline = DataUsageInfo {
|
||||
last_update: Some(std::time::SystemTime::now()),
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_bootstrap_pending: true,
|
||||
..Default::default()
|
||||
};
|
||||
let data = serde_json::to_vec(&baseline)
|
||||
.map_err(|err| ScannerError::Other(format!("failed to encode pristine scanner usage baseline: {err}")))?;
|
||||
let save_result = save_config_with_publication_admission_for_epoch(
|
||||
storeapi.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
data.clone(),
|
||||
DataUsageCacheRevision::Missing.preconditions(),
|
||||
expected_epoch,
|
||||
)
|
||||
.await;
|
||||
if save_result
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|info| info.etag.as_deref())
|
||||
.is_some_and(|etag| !etag.is_empty())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (persisted, revision) = read_config_with_revision(storeapi, DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.map_err(|err| ScannerError::Other(format!("failed to reconcile pristine scanner usage bootstrap: {err}")))?;
|
||||
if persisted.as_deref() == Some(data.as_slice()) && matches!(revision, DataUsageCacheRevision::Etag(_)) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ScannerError::Other(match save_result {
|
||||
Ok(_) => "pristine scanner usage bootstrap returned no ETag and could not be confirmed".to_string(),
|
||||
Err(err) => format!("failed to persist pristine scanner usage bootstrap: {err}"),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn fence_scanner_usage_epoch_with_expected_epoch(
|
||||
ctx: &CancellationToken,
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
claimed_epoch: u64,
|
||||
expected_publication_epoch: Option<u64>,
|
||||
allow_pristine_bootstrap_pending: bool,
|
||||
) -> Result<(), ScannerError> {
|
||||
for retry in 0..=SCANNER_PERSIST_CAS_RETRIES {
|
||||
if ctx.is_cancelled() {
|
||||
@@ -131,7 +192,9 @@ pub(super) async fn fence_scanner_usage_epoch_with_expected_epoch(
|
||||
let (primary, revision) = read_config_with_revision(storeapi.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.map_err(|err| ScannerError::Other(format!("failed to read scanner usage epoch fence: {err}")))?;
|
||||
let Some(mut usage) = usage_snapshot_for_epoch_fence(storeapi.clone(), primary.as_deref()).await? else {
|
||||
let Some(mut usage) =
|
||||
usage_snapshot_for_epoch_fence(storeapi.clone(), primary.as_deref(), allow_pristine_bootstrap_pending).await?
|
||||
else {
|
||||
let Some(_publication_admission) = scanner_publication_admission_for_epoch(storeapi.clone(), read_epoch).await else {
|
||||
if retry < SCANNER_PERSIST_CAS_RETRIES {
|
||||
continue;
|
||||
@@ -180,7 +243,11 @@ pub(super) async fn fence_scanner_usage_epoch_with_expected_epoch(
|
||||
.await
|
||||
.map_err(|err| ScannerError::Other(format!("failed to reconcile scanner usage epoch fence: {err}")))?;
|
||||
if let Some(persisted) = persisted {
|
||||
let persisted = decode_usage_snapshot_for_epoch_fence(&persisted, DATA_USAGE_OBJ_NAME_PATH.as_str())?;
|
||||
let persisted = decode_usage_snapshot_for_epoch_fence(
|
||||
&persisted,
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
allow_pristine_bootstrap_pending,
|
||||
)?;
|
||||
match persisted.scanner_epoch {
|
||||
Some(epoch) if epoch == claimed_epoch => return Ok(()),
|
||||
Some(epoch) if epoch > claimed_epoch => {
|
||||
@@ -210,9 +277,16 @@ pub(super) async fn complete_scanner_leadership_claim(
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
claimed_epoch: u64,
|
||||
expected_publication_epoch: Option<u64>,
|
||||
allow_pristine_bootstrap_pending: bool,
|
||||
) -> bool {
|
||||
if let Err(err) =
|
||||
fence_scanner_usage_epoch_with_expected_epoch(ctx, storeapi, claimed_epoch, expected_publication_epoch).await
|
||||
if let Err(err) = fence_scanner_usage_epoch_with_expected_epoch(
|
||||
ctx,
|
||||
storeapi,
|
||||
claimed_epoch,
|
||||
expected_publication_epoch,
|
||||
allow_pristine_bootstrap_pending,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
target: "rustfs::scanner",
|
||||
@@ -236,6 +310,7 @@ pub(super) async fn claim_scanner_leadership(
|
||||
cycle_info: &mut CurrentCycle,
|
||||
revision: &mut DataUsageCacheRevision,
|
||||
persisted_epoch: &mut u64,
|
||||
allow_pristine_bootstrap_pending: bool,
|
||||
) -> bool {
|
||||
for retry in 0..=SCANNER_PERSIST_CAS_RETRIES {
|
||||
if ctx.is_cancelled() {
|
||||
@@ -290,7 +365,7 @@ pub(super) async fn claim_scanner_leadership(
|
||||
return false;
|
||||
}
|
||||
};
|
||||
match usage_snapshot_for_epoch_fence(storeapi.clone(), usage_primary.as_deref()).await {
|
||||
match usage_snapshot_for_epoch_fence(storeapi.clone(), usage_primary.as_deref(), allow_pristine_bootstrap_pending).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
@@ -333,7 +408,14 @@ pub(super) async fn claim_scanner_leadership(
|
||||
if let Some(etag) = object_info.etag.filter(|etag| !etag.is_empty()) {
|
||||
*revision = DataUsageCacheRevision::Etag(etag);
|
||||
*persisted_epoch = claimed_epoch;
|
||||
return complete_scanner_leadership_claim(ctx, storeapi, claimed_epoch, Some(read_epoch)).await;
|
||||
return complete_scanner_leadership_claim(
|
||||
ctx,
|
||||
storeapi,
|
||||
claimed_epoch,
|
||||
Some(read_epoch),
|
||||
allow_pristine_bootstrap_pending,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match reconcile_scanner_leadership_claim(
|
||||
@@ -348,7 +430,14 @@ pub(super) async fn claim_scanner_leadership(
|
||||
.await
|
||||
{
|
||||
Ok(ScannerLeadershipClaimReconcile::Durable) => {
|
||||
return complete_scanner_leadership_claim(ctx, storeapi, claimed_epoch, Some(read_epoch)).await;
|
||||
return complete_scanner_leadership_claim(
|
||||
ctx,
|
||||
storeapi,
|
||||
claimed_epoch,
|
||||
Some(read_epoch),
|
||||
allow_pristine_bootstrap_pending,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(ScannerLeadershipClaimReconcile::Changed) if retry < SCANNER_PERSIST_CAS_RETRIES => continue,
|
||||
Ok(ScannerLeadershipClaimReconcile::Changed | ScannerLeadershipClaimReconcile::Unchanged) => {
|
||||
@@ -392,7 +481,14 @@ pub(super) async fn claim_scanner_leadership(
|
||||
.await
|
||||
{
|
||||
Ok(ScannerLeadershipClaimReconcile::Durable) => {
|
||||
return complete_scanner_leadership_claim(ctx, storeapi, claimed_epoch, Some(read_epoch)).await;
|
||||
return complete_scanner_leadership_claim(
|
||||
ctx,
|
||||
storeapi,
|
||||
claimed_epoch,
|
||||
Some(read_epoch),
|
||||
allow_pristine_bootstrap_pending,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(ScannerLeadershipClaimReconcile::Changed)
|
||||
if retry < SCANNER_PERSIST_CAS_RETRIES && !ctx.is_cancelled() =>
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::{
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Cursor;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::task::Poll;
|
||||
use temp_env::{with_var, with_var_unset};
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -69,6 +69,14 @@ async fn setup_scanner_cycle_store() -> (tempfile::TempDir, Arc<ECStore>) {
|
||||
.await
|
||||
.expect("scanner cycle test ECStore should initialize");
|
||||
init_bucket_metadata_sys_for_scanner_tests(store.clone()).await;
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&complete_usage_with_bucket_count(Some(std::time::SystemTime::UNIX_EPOCH), 0))
|
||||
.expect("scanner cycle usage baseline should encode"),
|
||||
)
|
||||
.await
|
||||
.expect("scanner cycle usage baseline should persist");
|
||||
|
||||
(temp_dir, store)
|
||||
}
|
||||
@@ -336,6 +344,7 @@ impl Drop for ScannerDefaultCycleGuard {
|
||||
struct MemoryConfigStore {
|
||||
objects: Mutex<HashMap<String, Vec<u8>>>,
|
||||
revisions: Mutex<HashMap<String, u64>>,
|
||||
insert_after_gets: Mutex<HashMap<String, Vec<u8>>>,
|
||||
non_regular_objects: Mutex<HashSet<String>>,
|
||||
fail_put_number: Mutex<HashMap<String, usize>>,
|
||||
object_not_found_put_number: Mutex<HashMap<String, usize>>,
|
||||
@@ -346,12 +355,24 @@ struct MemoryConfigStore {
|
||||
replace_after_successful_puts: Mutex<HashMap<String, (usize, Vec<u8>)>>,
|
||||
put_counts: Mutex<HashMap<String, usize>>,
|
||||
publication_admission_blocked: AtomicBool,
|
||||
block_publication_after_admissions: AtomicUsize,
|
||||
}
|
||||
|
||||
fn memory_config_key(bucket: &str, object: &str) -> String {
|
||||
format!("{bucket}/{object}")
|
||||
}
|
||||
|
||||
async fn insert_usage_after_first_legacy_backup_read(store: &MemoryConfigStore) {
|
||||
let legacy_backup = format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str());
|
||||
let mut usage = complete_usage_with_bucket_count(Some(std::time::SystemTime::UNIX_EPOCH), 0);
|
||||
usage.scanner_epoch = Some(7);
|
||||
usage.scanner_cycle = Some(11);
|
||||
store.insert_after_gets.lock().await.insert(
|
||||
memory_config_key(RUSTFS_META_BUCKET, &legacy_backup),
|
||||
serde_json::to_vec(&usage).expect("usage snapshot should encode"),
|
||||
);
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::storage_api::scanner_io::ObjectIO for MemoryConfigStore {
|
||||
type Error = EcstoreError;
|
||||
@@ -371,13 +392,21 @@ impl crate::storage_api::scanner_io::ObjectIO for MemoryConfigStore {
|
||||
_opts: &ObjectOptions,
|
||||
) -> EcstoreResult<GetObjectReader> {
|
||||
let key = memory_config_key(bucket, object);
|
||||
let data = self
|
||||
.objects
|
||||
.lock()
|
||||
.await
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.ok_or(EcstoreError::FileNotFound)?;
|
||||
let inserted_data = self.insert_after_gets.lock().await.remove(&key);
|
||||
let data = {
|
||||
let mut objects = self.objects.lock().await;
|
||||
let data = objects.get(&key).cloned();
|
||||
if let Some(inserted_data) = inserted_data.as_ref() {
|
||||
objects.insert(key.clone(), inserted_data.clone());
|
||||
}
|
||||
data
|
||||
};
|
||||
if inserted_data.is_some() {
|
||||
let mut revisions = self.revisions.lock().await;
|
||||
let revision = revisions.get(&key).copied().unwrap_or(0) + 1;
|
||||
revisions.insert(key.clone(), revision);
|
||||
}
|
||||
let data = data.ok_or(EcstoreError::FileNotFound)?;
|
||||
let data_len = i64::try_from(data.len()).expect("memory test object length should fit in i64");
|
||||
let revision = *self.revisions.lock().await.entry(key.clone()).or_insert(1);
|
||||
let is_dir = self.non_regular_objects.lock().await.contains(&key);
|
||||
@@ -2033,8 +2062,6 @@ async fn scanner_startup_prefers_v2_over_legacy_usage() {
|
||||
#[tokio::test]
|
||||
async fn scanner_usage_floor_fails_closed_on_corrupt_or_exhausted_usage_state() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
assert!(persisted_usage_floor(store.clone()).await.is_err());
|
||||
|
||||
store.objects.lock().await.insert(
|
||||
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||
b"not-json".to_vec(),
|
||||
@@ -2051,6 +2078,21 @@ async fn scanner_usage_floor_fails_closed_on_corrupt_or_exhausted_usage_state()
|
||||
"a structurally incomplete usage snapshot must not be treated as an empty floor"
|
||||
);
|
||||
|
||||
store.objects.lock().await.insert(
|
||||
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||
serde_json::to_vec(&DataUsageInfo {
|
||||
last_update: Some(std::time::SystemTime::now()),
|
||||
scanner_cycle: Some(1),
|
||||
usage_snapshot_bootstrap_pending: true,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("pending usage marker should encode"),
|
||||
);
|
||||
assert!(
|
||||
persisted_usage_floor(store.clone()).await.is_err(),
|
||||
"a pending marker must never pass the legacy authoritative fallback"
|
||||
);
|
||||
|
||||
store.objects.lock().await.insert(
|
||||
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||
serde_json::to_vec(&DataUsageInfo {
|
||||
@@ -2062,6 +2104,167 @@ async fn scanner_usage_floor_fails_closed_on_corrupt_or_exhausted_usage_state()
|
||||
assert!(persisted_usage_floor(store).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_usage_floor_allows_only_explicit_pristine_bootstrap() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
let (floor, state) = persisted_usage_floor_for_startup(store.clone(), true)
|
||||
.await
|
||||
.expect("a verified pristine startup should use the empty floor");
|
||||
assert_eq!(floor, PersistedUsageFloor::default());
|
||||
assert_eq!(state, PersistedUsageFloorStartup::Missing);
|
||||
assert!(persisted_usage_floor_for_startup(store.clone(), false).await.is_err());
|
||||
|
||||
store.objects.lock().await.insert(
|
||||
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||
b"not-json".to_vec(),
|
||||
);
|
||||
assert!(
|
||||
persisted_usage_floor_for_startup(store, true).await.is_err(),
|
||||
"pristine bootstrap must not hide corrupt persisted state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_usage_floor_fails_closed_on_zero_byte_usage_objects() {
|
||||
for path in [
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str().to_string(),
|
||||
format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||
LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str().to_string(),
|
||||
format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||
] {
|
||||
let key = memory_config_key(RUSTFS_META_BUCKET, &path);
|
||||
let existing = Arc::new(MemoryConfigStore::default());
|
||||
existing.objects.lock().await.insert(key.clone(), Vec::new());
|
||||
|
||||
let err = persisted_usage_floor(existing)
|
||||
.await
|
||||
.expect_err("an empty usage object must not be treated as missing");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains(&format!("failed to decode scanner usage floor from {path}:")),
|
||||
"unexpected error for {path}: {err}"
|
||||
);
|
||||
|
||||
let appearing = Arc::new(MemoryConfigStore::default());
|
||||
appearing.insert_after_gets.lock().await.insert(key, Vec::new());
|
||||
|
||||
let err = persisted_usage_floor(appearing)
|
||||
.await
|
||||
.expect_err("an empty usage object appearing during confirmation must prevent pristine bootstrap");
|
||||
assert!(
|
||||
err.to_string().contains("changed while confirming pristine state"),
|
||||
"unexpected confirmation error for {path}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_usage_floor_requires_publication_admission_for_pristine_bootstrap() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
store.publication_admission_blocked.store(true, Ordering::Release);
|
||||
|
||||
assert!(persisted_usage_floor(store).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_usage_floor_fails_closed_when_usage_appears_during_pristine_confirmation() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
insert_usage_after_first_legacy_backup_read(store.as_ref()).await;
|
||||
|
||||
let err = persisted_usage_floor(store)
|
||||
.await
|
||||
.expect_err("an appearing usage snapshot must prevent pristine bootstrap");
|
||||
assert!(err.to_string().contains("changed while confirming pristine state"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_usage_floor_rejects_publication_change_during_pristine_confirmation() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
store.block_publication_after_admissions.store(2, Ordering::Release);
|
||||
|
||||
assert!(persisted_usage_floor(store).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_pristine_cycle_state_requires_no_durable_progress() {
|
||||
let cycle = CurrentCycle::default();
|
||||
assert!(scanner_cycle_state_is_pristine(&cycle, 0, &DataUsageCacheRevision::Missing));
|
||||
assert!(scanner_may_resume_pristine_usage_bootstrap(&cycle));
|
||||
assert!(!scanner_cycle_state_is_pristine(
|
||||
&CurrentCycle {
|
||||
next: 1,
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
&DataUsageCacheRevision::Missing
|
||||
));
|
||||
assert!(!scanner_may_resume_pristine_usage_bootstrap(&CurrentCycle {
|
||||
next: 1,
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(!scanner_cycle_state_is_pristine(&cycle, 1, &DataUsageCacheRevision::Missing));
|
||||
assert!(!scanner_cycle_state_is_pristine(
|
||||
&cycle,
|
||||
0,
|
||||
&DataUsageCacheRevision::Etag("etag".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_pristine_bootstrap_allows_first_bucket_to_win_startup() {
|
||||
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||
let cycle = CurrentCycle::default();
|
||||
let revision = DataUsageCacheRevision::Missing;
|
||||
|
||||
store
|
||||
.make_bucket("first-user-bucket", &crate::storage_api::scan::MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("test bucket should be created");
|
||||
|
||||
assert!(scanner_may_bootstrap_missing_usage_floor(&cycle, 0, &revision));
|
||||
assert_eq!(
|
||||
persisted_usage_floor_for_startup(store.clone(), true)
|
||||
.await
|
||||
.expect("first startup should still admit a non-authoritative bootstrap marker")
|
||||
.1,
|
||||
PersistedUsageFloorStartup::Missing
|
||||
);
|
||||
initialize_pristine_usage_baseline(store.clone())
|
||||
.await
|
||||
.expect("first startup should persist its pending marker");
|
||||
let pending = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("pending marker should be stored");
|
||||
let pending = serde_json::from_slice::<DataUsageInfo>(&pending).expect("pending marker should decode");
|
||||
assert!(data_usage_info_is_pristine_bootstrap_pending(&pending));
|
||||
assert!(!data_usage_info_has_persisted_baseline_identity(&pending));
|
||||
assert_eq!(
|
||||
persisted_usage_floor_for_startup(store.clone(), false)
|
||||
.await
|
||||
.expect("the pending marker should be resumable after restart")
|
||||
.1,
|
||||
PersistedUsageFloorStartup::BootstrapPending
|
||||
);
|
||||
|
||||
store
|
||||
.delete_bucket("first-user-bucket", &crate::storage_api::scan::DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("first user bucket should be deleted");
|
||||
assert!(
|
||||
read_config(store.clone(), &format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str()))
|
||||
.await
|
||||
.is_err(),
|
||||
"bucket deletion must not copy the pending marker into the backup slot"
|
||||
);
|
||||
assert_eq!(
|
||||
persisted_usage_floor_for_startup(store, false)
|
||||
.await
|
||||
.expect("the pending marker should remain resumable after bucket deletion")
|
||||
.1,
|
||||
PersistedUsageFloorStartup::BootstrapPending
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_usage_backup_uses_durable_cycle_cadence_across_tasks() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
@@ -2156,7 +2359,17 @@ impl crate::ScannerConfigObjectDelete for MemoryConfigStore {
|
||||
}
|
||||
|
||||
async fn scanner_data_usage_publication_admission(&self) -> Option<crate::ScannerDataUsagePublicationAdmission> {
|
||||
(!self.publication_admission_blocked.load(Ordering::Acquire)).then(crate::ScannerDataUsagePublicationAdmission::unfenced)
|
||||
if self.publication_admission_blocked.load(Ordering::Acquire) {
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.block_publication_after_admissions
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| remaining.checked_sub(1))
|
||||
== Ok(1)
|
||||
{
|
||||
self.publication_admission_blocked.store(true, Ordering::Release);
|
||||
}
|
||||
Some(crate::ScannerDataUsagePublicationAdmission::unfenced())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2334,7 +2547,7 @@ async fn test_leadership_claim_preserves_usage_epoch_floor_across_old_epoch_conf
|
||||
);
|
||||
|
||||
let mut persisted_epoch = 8;
|
||||
assert!(claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch,).await);
|
||||
assert!(claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, false).await);
|
||||
|
||||
let state = read_config(store.clone(), &DATA_USAGE_BLOOM_NAME_PATH)
|
||||
.await
|
||||
@@ -2357,13 +2570,13 @@ async fn test_leadership_claim_rejects_terminal_epoch() {
|
||||
};
|
||||
let mut persisted_epoch = u64::MAX - 1;
|
||||
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch).await);
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, false).await);
|
||||
assert_eq!(persisted_epoch, u64::MAX - 1);
|
||||
assert!(read_config(store, &DATA_USAGE_BLOOM_NAME_PATH).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leadership_claim_defers_without_usage_baseline_before_bloom_write() {
|
||||
async fn scanner_defers_leadership_when_usage_snapshots_are_stably_absent() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
let ctx = CancellationToken::new();
|
||||
let mut revision = DataUsageCacheRevision::Missing;
|
||||
@@ -2373,11 +2586,97 @@ async fn leadership_claim_defers_without_usage_baseline_before_bloom_write() {
|
||||
};
|
||||
let mut persisted_epoch = 0;
|
||||
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch,).await);
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, false).await);
|
||||
assert!(read_config(store.clone(), &DATA_USAGE_BLOOM_NAME_PATH).await.is_err());
|
||||
assert!(read_config(store, DATA_USAGE_OBJ_NAME_PATH.as_str()).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pristine_usage_bootstrap_pending_unblocks_first_leadership_claim() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
initialize_pristine_usage_baseline(store.clone())
|
||||
.await
|
||||
.expect("verified pristine startup should publish its pending marker");
|
||||
|
||||
let ctx = CancellationToken::new();
|
||||
let mut revision = DataUsageCacheRevision::Missing;
|
||||
let mut cycle = CurrentCycle::default();
|
||||
let mut persisted_epoch = 0;
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, false,).await);
|
||||
assert!(read_config(store.clone(), &DATA_USAGE_BLOOM_NAME_PATH).await.is_err());
|
||||
assert!(claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, true).await);
|
||||
|
||||
let usage = read_config(store, DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("leadership claim should fence the pristine baseline");
|
||||
let usage = serde_json::from_slice::<DataUsageInfo>(&usage).expect("pristine bootstrap marker should remain valid");
|
||||
assert!(data_usage_info_is_pristine_bootstrap_pending(&usage));
|
||||
assert!(!data_usage_info_has_persisted_baseline_identity(&usage));
|
||||
assert_eq!(usage.scanner_epoch, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_pristine_usage_bootstrap_is_resumed_after_restart() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
initialize_pristine_usage_baseline(store.clone())
|
||||
.await
|
||||
.expect("verified pristine startup should publish its pending marker");
|
||||
|
||||
let (floor, state) = persisted_usage_floor_for_startup(store.clone(), false)
|
||||
.await
|
||||
.expect("restart should recognize the pending pristine bootstrap");
|
||||
assert_eq!(floor, PersistedUsageFloor::default());
|
||||
assert_eq!(state, PersistedUsageFloorStartup::BootstrapPending);
|
||||
assert!(persisted_usage_floor(store).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pristine_usage_bootstrap_reconciles_post_commit_error() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
let key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str());
|
||||
store.error_after_commit_put_number.lock().await.insert(key, 1);
|
||||
|
||||
initialize_pristine_usage_baseline(store.clone())
|
||||
.await
|
||||
.expect("a committed pending marker should reconcile after a lost response");
|
||||
let usage = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("the reconciled pending marker should remain");
|
||||
let usage = serde_json::from_slice::<DataUsageInfo>(&usage).expect("pending marker should decode");
|
||||
assert!(data_usage_info_is_pristine_bootstrap_pending(&usage));
|
||||
assert!(!data_usage_info_has_persisted_baseline_identity(&usage));
|
||||
assert_eq!(
|
||||
persisted_usage_floor_for_startup(store.clone(), false)
|
||||
.await
|
||||
.expect("restart should resume a committed pending marker")
|
||||
.1,
|
||||
PersistedUsageFloorStartup::BootstrapPending
|
||||
);
|
||||
assert!(persisted_usage_floor(store).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pristine_usage_bootstrap_does_not_overwrite_concurrent_replacement() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
let key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str());
|
||||
let replacement = serde_json::to_vec(&complete_usage_with_bucket_count(None, 1)).expect("replacement should encode");
|
||||
store
|
||||
.replace_after_successful_puts
|
||||
.lock()
|
||||
.await
|
||||
.insert(key, (1, replacement.clone()));
|
||||
|
||||
initialize_pristine_usage_baseline(store.clone())
|
||||
.await
|
||||
.expect("the bootstrap write completed before the replacement");
|
||||
assert_eq!(
|
||||
read_config(store, DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("newer usage snapshot must remain"),
|
||||
replacement
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leadership_claim_defers_on_corrupt_usage_baseline_without_bloom_write() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
@@ -2393,7 +2692,7 @@ async fn leadership_claim_defers_on_corrupt_usage_baseline_without_bloom_write()
|
||||
};
|
||||
let mut persisted_epoch = 0;
|
||||
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch,).await);
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, false).await);
|
||||
assert!(read_config(store, &DATA_USAGE_BLOOM_NAME_PATH).await.is_err());
|
||||
}
|
||||
|
||||
@@ -2413,7 +2712,7 @@ async fn leadership_claim_defers_on_unidentified_usage_baseline_without_bloom_wr
|
||||
};
|
||||
let mut persisted_epoch = 0;
|
||||
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch,).await);
|
||||
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, false).await);
|
||||
assert!(read_config(store, &DATA_USAGE_BLOOM_NAME_PATH).await.is_err());
|
||||
}
|
||||
|
||||
@@ -2435,7 +2734,7 @@ async fn test_leadership_claim_confirms_commit_after_returned_error() {
|
||||
let mut persisted_epoch = 0;
|
||||
seed_usage_snapshot_for_leadership_claim(&store).await;
|
||||
|
||||
assert!(claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch).await);
|
||||
assert!(claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, false).await);
|
||||
|
||||
let state = read_config(store.clone(), &DATA_USAGE_BLOOM_NAME_PATH)
|
||||
.await
|
||||
@@ -2491,7 +2790,7 @@ async fn test_leadership_claim_usage_fence_rejects_old_inflight_writer() {
|
||||
..Default::default()
|
||||
};
|
||||
let mut persisted_epoch = 4;
|
||||
assert!(claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch).await);
|
||||
assert!(claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch, false).await);
|
||||
|
||||
let (fenced_data, fenced_revision) = read_config_with_revision(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
@@ -2556,6 +2855,7 @@ async fn cycle_budget_lease_takeover_rejects_old_generation() {
|
||||
&mut replacement_cycle,
|
||||
&mut replacement_revision,
|
||||
&mut replacement_epoch,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
);
|
||||
@@ -2753,6 +3053,47 @@ async fn test_usage_route_barrier_precedes_durable_reconciliation() {
|
||||
assert_eq!(store.put_counts.lock().await.get(&key), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinator_does_not_put_after_remote_generation_flip() {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
let key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str());
|
||||
let (sender, receiver) = mpsc::channel(1);
|
||||
sender
|
||||
.send(complete_usage_with_bucket_count(
|
||||
Some(std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(20)),
|
||||
1,
|
||||
))
|
||||
.await
|
||||
.expect("usage snapshot should enqueue");
|
||||
drop(sender);
|
||||
|
||||
let route_store = store.clone();
|
||||
let outcome = store_data_usage_in_backend_with_outcome_for_epoch_and_baseline_and_route_probe_for_publication_epoch(
|
||||
CancellationToken::new(),
|
||||
store.clone(),
|
||||
receiver,
|
||||
None,
|
||||
Some(DataUsagePersistBaseline {
|
||||
data: None,
|
||||
revision: DataUsageCacheRevision::Missing,
|
||||
}),
|
||||
ScannerPublicationFence::new(Some(0), None, None),
|
||||
move || {
|
||||
let route_store = route_store.clone();
|
||||
async move {
|
||||
// Model the remote lease holder flipping its movement generation
|
||||
// after the activity probe but before the coordinator's PUT.
|
||||
route_store.publication_admission_blocked.store(true, Ordering::Release);
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::DataMovement));
|
||||
assert_eq!(store.put_counts.lock().await.get(&key), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_deferred_usage_save_keeps_last_real_save_metric() {
|
||||
let metrics = global_metrics();
|
||||
@@ -4285,14 +4626,24 @@ fn scanner_cycle_schedule_status_reports_effective_backoff() {
|
||||
|
||||
let status = scanner_cycle_schedule_status();
|
||||
|
||||
assert_eq!(status.execution_role, "leader");
|
||||
assert!(status.effective_interval_available);
|
||||
assert_eq!(status.effective_interval_seconds, 86_401);
|
||||
assert!(status.clean_idle_backoff_enabled);
|
||||
assert_eq!(status.clean_idle_backoff_multiplier, 2_048);
|
||||
assert!(status.superseded_retry_backoff_enabled);
|
||||
assert_eq!(status.superseded_cycles, 7);
|
||||
|
||||
record_scanner_cycle_schedule_role("follower");
|
||||
let status = scanner_cycle_schedule_status();
|
||||
assert_eq!(status.execution_role, "follower");
|
||||
assert!(!status.effective_interval_available);
|
||||
assert_eq!(status.effective_interval_seconds, 0);
|
||||
|
||||
reset_scanner_cycle_schedule();
|
||||
let status = scanner_cycle_schedule_status();
|
||||
assert_eq!(status.execution_role, "unknown");
|
||||
assert!(!status.effective_interval_available);
|
||||
assert_eq!(status.effective_interval_seconds, 0);
|
||||
assert!(!status.clean_idle_backoff_enabled);
|
||||
assert_eq!(status.clean_idle_backoff_multiplier, 1);
|
||||
@@ -4300,6 +4651,33 @@ fn scanner_cycle_schedule_status_reports_effective_backoff() {
|
||||
assert_eq!(status.superseded_cycles, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_leader_lock_failure_classifies_only_timeout_as_expected_contention() {
|
||||
let timeout = LockError::timeout(".rustfs.sys/leader.lock@latest", Duration::from_secs(5));
|
||||
assert!(matches!(
|
||||
classify_scanner_leader_lock_failure(&timeout),
|
||||
ScannerLeaderLockFailure::Contended
|
||||
));
|
||||
|
||||
let failures = [
|
||||
LockError::internal("lock service unavailable"),
|
||||
LockError::network(
|
||||
"leader lock transport unavailable",
|
||||
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused"),
|
||||
),
|
||||
LockError::QuorumNotReached {
|
||||
required: 3,
|
||||
achieved: 1,
|
||||
},
|
||||
];
|
||||
for failure in &failures {
|
||||
assert!(matches!(
|
||||
classify_scanner_leader_lock_failure(failure),
|
||||
ScannerLeaderLockFailure::Failed(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_idle_backoff_resets_for_non_idle_work() {
|
||||
let base_interval = Duration::from_secs(60);
|
||||
@@ -4839,6 +5217,41 @@ async fn test_wait_for_next_scanner_cycle_stops_after_leader_lock_loss() {
|
||||
assert_eq!(reason, ScannerCycleWakeReason::LeaderLockLost);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn movement_generation_wakes_deferred_wait_without_dirty_bucket() {
|
||||
let ctx = CancellationToken::new();
|
||||
let movement_generation = Arc::new(AtomicU64::new(7));
|
||||
let movement_changed = Arc::new(Notify::new());
|
||||
let next_generation = Arc::clone(&movement_generation);
|
||||
let next_changed = Arc::clone(&movement_changed);
|
||||
tokio::spawn(async move {
|
||||
tokio::task::yield_now().await;
|
||||
next_generation.store(8, Ordering::Release);
|
||||
next_changed.notify_waiters();
|
||||
});
|
||||
|
||||
let movement = ScannerMovementWaitContext {
|
||||
movement_generation_seen: Some(7),
|
||||
movement_changed,
|
||||
current_movement_generation: move || movement_generation.load(Ordering::Acquire),
|
||||
is_lock_lost: || false,
|
||||
};
|
||||
let reason = wait_for_next_scanner_cycle_with_movement(
|
||||
&ctx,
|
||||
Duration::from_secs(60),
|
||||
ScannerCycleObservedGenerations {
|
||||
dirty_usage: None,
|
||||
runtime_config: crate::runtime_config::scanner_runtime_config_generation(),
|
||||
maintenance: crate::scanner_io::scanner_maintenance_generation(),
|
||||
defer_cluster_activity: false,
|
||||
},
|
||||
&movement,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(reason, ScannerCycleWakeReason::MovementGeneration);
|
||||
}
|
||||
|
||||
fn scanner_node_activity(epoch: &str, namespace_generation: u64, maintenance_generation: u64) -> ScannerNodeActivity {
|
||||
ScannerNodeActivity {
|
||||
instance_id: epoch.to_string(),
|
||||
@@ -4849,6 +5262,8 @@ fn scanner_node_activity(epoch: &str, namespace_generation: u64, maintenance_gen
|
||||
data_movement_active: false,
|
||||
dirty_usage_generation: 5,
|
||||
dirty_usage_pending: false,
|
||||
movement_generation: 9,
|
||||
publication_blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4889,6 +5304,7 @@ fn scanner_activity_snapshot_fences_data_movement() {
|
||||
let mut moving = idle.clone();
|
||||
moving.get_mut("node-2").expect("node should exist").data_movement_active = true;
|
||||
|
||||
assert!(!scanner_activity_allows_usage_publication(&BTreeMap::new()));
|
||||
assert!(scanner_activity_allows_usage_publication(&idle));
|
||||
assert!(!scanner_activity_allows_usage_publication(&moving));
|
||||
assert_ne!(scanner_activity_snapshot_digest(&idle), scanner_activity_snapshot_digest(&moving));
|
||||
@@ -4972,7 +5388,7 @@ fn scanner_activity_observation_requires_a_complete_baseline() {
|
||||
|
||||
let restarted = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-b", 8, 0))]);
|
||||
let (observation, error) = apply_scanner_activity_probe_result(&mut seen, Ok(restarted));
|
||||
assert_eq!(observation, ScannerActivityObservation::Changed);
|
||||
assert_eq!(observation, ScannerActivityObservation::RemoteRestarted);
|
||||
assert!(error.is_none());
|
||||
|
||||
let (observation, error) =
|
||||
@@ -5007,6 +5423,36 @@ fn remote_maintenance_change_is_distinct_from_namespace_activity() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_movement_generation_change_is_distinct_from_cluster_activity() {
|
||||
let previous = BTreeMap::from([("node-2".to_string(), scanner_node_activity("remote", 7, 3))]);
|
||||
let movement_changed = BTreeMap::from([(
|
||||
"node-2".to_string(),
|
||||
ScannerNodeActivity {
|
||||
movement_generation: 10,
|
||||
..scanner_node_activity("remote", 7, 3)
|
||||
},
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
compare_scanner_activity(&previous, &movement_changed),
|
||||
ScannerActivityObservation::MovementChanged
|
||||
);
|
||||
assert!(scanner_activity_observed_work(ScannerActivityObservation::MovementChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_restart_is_distinct_from_deferred_cluster_activity() {
|
||||
let previous = BTreeMap::from([("node-2".to_string(), scanner_node_activity("remote-a", 7, 3))]);
|
||||
let restarted = BTreeMap::from([("node-2".to_string(), scanner_node_activity("remote-b", 7, 3))]);
|
||||
|
||||
assert_eq!(
|
||||
compare_scanner_activity(&previous, &restarted),
|
||||
ScannerActivityObservation::RemoteRestarted
|
||||
);
|
||||
assert!(scanner_activity_observed_work(ScannerActivityObservation::RemoteRestarted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_maintenance_wakeup_releases_a_remote_maintenance_block() {
|
||||
let blocked = scanner_activity_backoff_blocked_after_wake(false, ScannerCycleWakeReason::ClusterMaintenance);
|
||||
@@ -5096,6 +5542,74 @@ async fn superseded_retry_wait_defers_dirty_cluster_activity_until_timer() {
|
||||
assert_eq!(seen, Some(changed));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn superseded_retry_wait_wakes_for_remote_movement_generation() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
let ctx = CancellationToken::new();
|
||||
let mut seen = Some(BTreeMap::from([("node-2".to_string(), scanner_node_activity("remote", 7, 3))]));
|
||||
let changed = BTreeMap::from([(
|
||||
"node-2".to_string(),
|
||||
ScannerNodeActivity {
|
||||
movement_generation: 10,
|
||||
..scanner_node_activity("remote", 7, 3)
|
||||
},
|
||||
)]);
|
||||
|
||||
let reason = wait_for_next_scanner_cycle_with_activity(
|
||||
&ctx,
|
||||
Duration::from_secs(120),
|
||||
Some(Duration::from_secs(60)),
|
||||
&mut seen,
|
||||
ScannerCycleObservedGenerations {
|
||||
dirty_usage: None,
|
||||
runtime_config: crate::runtime_config::scanner_runtime_config_generation(),
|
||||
maintenance: crate::scanner_io::scanner_maintenance_generation(),
|
||||
defer_cluster_activity: true,
|
||||
},
|
||||
|| false,
|
||||
|| std::future::ready(Ok(changed.clone())),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(reason, ScannerCycleWakeReason::ClusterActivity);
|
||||
assert_eq!(seen, Some(changed));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn superseded_retry_wait_wakes_when_remote_restart_clears_movement_state() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
let ctx = CancellationToken::new();
|
||||
let blocked = BTreeMap::from([(
|
||||
"node-2".to_string(),
|
||||
ScannerNodeActivity {
|
||||
data_movement_active: true,
|
||||
publication_blocked: true,
|
||||
..scanner_node_activity("remote-a", 7, 3)
|
||||
},
|
||||
)]);
|
||||
let restarted = BTreeMap::from([("node-2".to_string(), scanner_node_activity("remote-b", 7, 3))]);
|
||||
let mut seen = Some(blocked);
|
||||
|
||||
let reason = wait_for_next_scanner_cycle_with_activity(
|
||||
&ctx,
|
||||
Duration::from_secs(120),
|
||||
Some(Duration::from_secs(60)),
|
||||
&mut seen,
|
||||
ScannerCycleObservedGenerations {
|
||||
dirty_usage: None,
|
||||
runtime_config: crate::runtime_config::scanner_runtime_config_generation(),
|
||||
maintenance: crate::scanner_io::scanner_maintenance_generation(),
|
||||
defer_cluster_activity: true,
|
||||
},
|
||||
|| false,
|
||||
|| std::future::ready(Ok(restarted.clone())),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(reason, ScannerCycleWakeReason::ClusterActivity);
|
||||
assert_eq!(seen, Some(restarted));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn distributed_clean_idle_wait_blocks_backoff_for_unpropagated_maintenance() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
/// Data-usage snapshot persistence: CAS store pipeline, epoch baselines, and observed-snapshot cleanup.
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum DataUsagePersistOutcome {
|
||||
@@ -29,12 +30,40 @@ pub(super) enum DataUsagePersistOutcome {
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn remote_lease_expired(deadline: Option<std::time::Instant>) -> bool {
|
||||
deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct DataUsagePersistBaseline {
|
||||
pub(super) data: Option<Bytes>,
|
||||
pub(super) revision: DataUsageCacheRevision,
|
||||
}
|
||||
|
||||
/// Short-lived publication inputs captured for one usage persistence attempt.
|
||||
/// Keeping the movement epoch, lease deadline, and target fence together makes
|
||||
/// it explicit that they are one proof rather than independent options.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct ScannerPublicationFence {
|
||||
pub(super) expected_publication_epoch: Option<u64>,
|
||||
pub(super) remote_lease_deadline: Option<std::time::Instant>,
|
||||
pub(super) scanner_publication_lease_fence: Option<String>,
|
||||
}
|
||||
|
||||
impl ScannerPublicationFence {
|
||||
pub(super) fn new(
|
||||
expected_publication_epoch: Option<u64>,
|
||||
remote_lease_deadline: Option<std::time::Instant>,
|
||||
scanner_publication_lease_fence: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
expected_publication_epoch,
|
||||
remote_lease_deadline,
|
||||
scanner_publication_lease_fence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum DataUsagePersistTaskResult {
|
||||
Completed(DataUsagePersistOutcome),
|
||||
@@ -129,7 +158,7 @@ where
|
||||
receiver,
|
||||
leader_epoch,
|
||||
initial_baseline,
|
||||
None,
|
||||
ScannerPublicationFence::default(),
|
||||
route_probe,
|
||||
)
|
||||
.await
|
||||
@@ -141,16 +170,49 @@ pub(super) async fn store_data_usage_in_backend_with_outcome_for_epoch_and_basel
|
||||
>(
|
||||
ctx: CancellationToken,
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
mut receiver: mpsc::Receiver<DataUsageInfo>,
|
||||
receiver: mpsc::Receiver<DataUsageInfo>,
|
||||
leader_epoch: Option<u64>,
|
||||
initial_baseline: Option<DataUsagePersistBaseline>,
|
||||
expected_publication_epoch: Option<u64>,
|
||||
publication_fence: ScannerPublicationFence,
|
||||
route_probe: F,
|
||||
) -> DataUsagePersistOutcome
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync,
|
||||
Fut: Future<Output = bool> + Send,
|
||||
{
|
||||
store_data_usage_in_backend_with_outcome_for_epoch_and_baseline_and_route_probe_for_publication_epoch_and_lease_fence(
|
||||
ctx,
|
||||
storeapi,
|
||||
receiver,
|
||||
leader_epoch,
|
||||
initial_baseline,
|
||||
publication_fence,
|
||||
route_probe,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn store_data_usage_in_backend_with_outcome_for_epoch_and_baseline_and_route_probe_for_publication_epoch_and_lease_fence<
|
||||
F,
|
||||
Fut,
|
||||
>(
|
||||
ctx: CancellationToken,
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
mut receiver: mpsc::Receiver<DataUsageInfo>,
|
||||
leader_epoch: Option<u64>,
|
||||
initial_baseline: Option<DataUsagePersistBaseline>,
|
||||
publication_fence: ScannerPublicationFence,
|
||||
route_probe: F,
|
||||
) -> DataUsagePersistOutcome
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync,
|
||||
Fut: Future<Output = bool> + Send,
|
||||
{
|
||||
let ScannerPublicationFence {
|
||||
expected_publication_epoch,
|
||||
remote_lease_deadline,
|
||||
scanner_publication_lease_fence,
|
||||
} = publication_fence;
|
||||
let mut outcome = DataUsagePersistOutcome::NoUpdate;
|
||||
let mut next_baseline = initial_baseline;
|
||||
|
||||
@@ -162,6 +224,10 @@ where
|
||||
if let Some(leader_epoch) = leader_epoch {
|
||||
data_usage_info.scanner_epoch = Some(leader_epoch);
|
||||
}
|
||||
if remote_lease_expired(remote_lease_deadline) {
|
||||
outcome = DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable);
|
||||
break 'updates;
|
||||
}
|
||||
if let Some(expected_epoch) = expected_publication_epoch
|
||||
&& scanner_publication_admission_for_epoch(storeapi.clone(), expected_epoch)
|
||||
.await
|
||||
@@ -430,6 +496,9 @@ where
|
||||
);
|
||||
break DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
||||
}
|
||||
if remote_lease_expired(remote_lease_deadline) {
|
||||
break DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable);
|
||||
}
|
||||
|
||||
let done_save = Metrics::time(Metric::SaveUsage);
|
||||
let save_result = {
|
||||
@@ -439,12 +508,17 @@ where
|
||||
done_save();
|
||||
break DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
||||
};
|
||||
save_config_shared_with_preconditions(
|
||||
if remote_lease_expired(remote_lease_deadline) {
|
||||
done_save();
|
||||
break DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable);
|
||||
}
|
||||
save_config_shared_with_preconditions_and_lease_fence(
|
||||
storeapi.clone(),
|
||||
target_path,
|
||||
data.clone(),
|
||||
sha256hex.clone(),
|
||||
revision.preconditions(),
|
||||
scanner_publication_lease_fence.as_deref(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -538,10 +612,12 @@ where
|
||||
if observational {
|
||||
invalidate_admin_data_usage_snapshot_cache().await;
|
||||
} else {
|
||||
let cleanup_ok = cleanup_observed_data_usage_snapshot_for_epoch(
|
||||
let cleanup_ok = cleanup_observed_data_usage_snapshot_for_epoch_and_lease(
|
||||
storeapi.clone(),
|
||||
&data_usage_info,
|
||||
expected_publication_epoch,
|
||||
remote_lease_deadline,
|
||||
scanner_publication_lease_fence.as_deref(),
|
||||
)
|
||||
.await;
|
||||
if expected_publication_epoch.is_some() && !cleanup_ok {
|
||||
@@ -559,10 +635,12 @@ where
|
||||
if observational {
|
||||
invalidate_admin_data_usage_snapshot_cache().await;
|
||||
} else {
|
||||
let cleanup_ok = cleanup_observed_data_usage_snapshot_for_epoch(
|
||||
let cleanup_ok = cleanup_observed_data_usage_snapshot_for_epoch_and_lease(
|
||||
storeapi.clone(),
|
||||
&data_usage_info,
|
||||
expected_publication_epoch,
|
||||
remote_lease_deadline,
|
||||
scanner_publication_lease_fence.as_deref(),
|
||||
)
|
||||
.await;
|
||||
if expected_publication_epoch.is_some() && !cleanup_ok {
|
||||
@@ -599,10 +677,12 @@ where
|
||||
if observational {
|
||||
invalidate_admin_data_usage_snapshot_cache().await;
|
||||
} else {
|
||||
let cleanup_ok = cleanup_observed_data_usage_snapshot_for_epoch(
|
||||
let cleanup_ok = cleanup_observed_data_usage_snapshot_for_epoch_and_lease(
|
||||
storeapi.clone(),
|
||||
&data_usage_info,
|
||||
expected_publication_epoch,
|
||||
remote_lease_deadline,
|
||||
scanner_publication_lease_fence.as_deref(),
|
||||
)
|
||||
.await;
|
||||
if expected_publication_epoch.is_some() && !cleanup_ok {
|
||||
@@ -620,8 +700,14 @@ where
|
||||
|
||||
if backup_due {
|
||||
let done_save = Metrics::time(Metric::SaveUsage);
|
||||
let backup_result =
|
||||
sync_data_usage_backup_from_primary_for_epoch(&ctx, storeapi.clone(), expected_publication_epoch).await;
|
||||
let backup_result = sync_data_usage_backup_from_primary_for_epoch_and_lease_and_fence(
|
||||
&ctx,
|
||||
storeapi.clone(),
|
||||
expected_publication_epoch,
|
||||
remote_lease_deadline,
|
||||
scanner_publication_lease_fence.as_deref(),
|
||||
)
|
||||
.await;
|
||||
done_save();
|
||||
if let Err(e) = backup_result {
|
||||
warn!(
|
||||
@@ -647,11 +733,16 @@ where
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(super) async fn cleanup_observed_data_usage_snapshot_for_epoch(
|
||||
async fn cleanup_observed_data_usage_snapshot_for_epoch_and_lease(
|
||||
storeapi: Arc<impl ScannerObjectIO + ScannerConfigObjectDelete>,
|
||||
authoritative: &DataUsageInfo,
|
||||
expected_publication_epoch: Option<u64>,
|
||||
remote_lease_deadline: Option<std::time::Instant>,
|
||||
scanner_publication_lease_fence: Option<&str>,
|
||||
) -> bool {
|
||||
if remote_lease_expired(remote_lease_deadline) {
|
||||
return false;
|
||||
}
|
||||
let read_epoch = match expected_publication_epoch {
|
||||
Some(expected_epoch) => {
|
||||
if scanner_publication_admission_for_epoch(storeapi.clone(), expected_epoch)
|
||||
@@ -667,10 +758,11 @@ pub(super) async fn cleanup_observed_data_usage_snapshot_for_epoch(
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
if expected_publication_epoch.is_some()
|
||||
&& scanner_publication_admission_for_epoch(storeapi.clone(), read_epoch)
|
||||
.await
|
||||
.is_none()
|
||||
if remote_lease_expired(remote_lease_deadline)
|
||||
|| expected_publication_epoch.is_some()
|
||||
&& scanner_publication_admission_for_epoch(storeapi.clone(), read_epoch)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -711,6 +803,9 @@ pub(super) async fn cleanup_observed_data_usage_snapshot_for_epoch(
|
||||
if observed_data_usage_is_newer(&observed, authoritative) {
|
||||
return true;
|
||||
}
|
||||
if remote_lease_expired(remote_lease_deadline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = delete_config_with_publication_admission_for_epoch(
|
||||
storeapi,
|
||||
@@ -720,6 +815,14 @@ pub(super) async fn cleanup_observed_data_usage_snapshot_for_epoch(
|
||||
delete_prefix: true,
|
||||
delete_prefix_object: true,
|
||||
http_preconditions: Some(revision.preconditions()),
|
||||
user_defined: scanner_publication_lease_fence
|
||||
.map(|fence| {
|
||||
HashMap::from([(
|
||||
crate::storage_api::owner::SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY.to_string(),
|
||||
fence.to_string(),
|
||||
)])
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
read_epoch,
|
||||
|
||||
@@ -33,7 +33,13 @@ use crate::scanner_io::{
|
||||
SCANNER_SKIP_FILE_ERROR, ScannerIODisk as _, is_scanner_metadata_corrupt_error, is_scanner_metadata_transient_error,
|
||||
};
|
||||
use crate::sleeper::DynamicSleeper;
|
||||
use crate::storage_api::owner::{EcstoreEventArgs, ecstore_send_event};
|
||||
use crate::storage_api::owner::{
|
||||
EcstoreBucketLifecycleConfiguration as BucketLifecycleConfiguration, EcstoreEventArgs,
|
||||
EcstoreLifecycleRuleFilter as LifecycleRuleFilter, EcstoreObjectLockConfiguration as ObjectLockConfiguration,
|
||||
EcstoreVersioningConfiguration as VersioningConfiguration, ecstore_send_event,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::storage_api::owner::{EcstoreExpirationStatus as ExpirationStatus, EcstoreLifecycleRule as LifecycleRule};
|
||||
use metrics::{counter, describe_counter};
|
||||
use rustfs_common::heal_channel::{
|
||||
HEAL_DELETE_DANGLING, HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest,
|
||||
@@ -49,7 +55,6 @@ use rustfs_filemeta::{
|
||||
MetaCacheHealCandidateKind,
|
||||
};
|
||||
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, VersioningConfiguration};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -251,7 +251,7 @@ fn resolve_sizes(object_infos: &[ObjectInfo]) -> Vec<SizeResolution> {
|
||||
}
|
||||
|
||||
fn lifecycle_rule_has_size_filter(lifecycle: &BucketLifecycleConfiguration, rule_id: &str) -> bool {
|
||||
let filter_has_size = |filter: &s3s::dto::LifecycleRuleFilter| {
|
||||
let filter_has_size = |filter: &LifecycleRuleFilter| {
|
||||
filter.object_size_greater_than.is_some()
|
||||
|| filter.object_size_less_than.is_some()
|
||||
|| filter
|
||||
@@ -1637,13 +1637,13 @@ mod tests {
|
||||
#[test]
|
||||
fn malformed_size_blocks_size_dependent_transition_but_allows_time_only_expiry() {
|
||||
let size_filtered = BucketLifecycleConfiguration {
|
||||
rules: vec![s3s::dto::LifecycleRule {
|
||||
status: s3s::dto::ExpirationStatus::from_static(s3s::dto::ExpirationStatus::ENABLED),
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: None,
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
id: Some("size".to_string()),
|
||||
filter: Some(s3s::dto::LifecycleRuleFilter {
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
object_size_greater_than: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -1676,8 +1676,8 @@ mod tests {
|
||||
let mixed_filters = BucketLifecycleConfiguration {
|
||||
rules: vec![
|
||||
size_filtered.rules[0].clone(),
|
||||
s3s::dto::LifecycleRule {
|
||||
status: s3s::dto::ExpirationStatus::from_static(s3s::dto::ExpirationStatus::ENABLED),
|
||||
LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: None,
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
@@ -1733,13 +1733,13 @@ mod tests {
|
||||
));
|
||||
assert!(lifecycle_rule_has_size_filter(
|
||||
&BucketLifecycleConfiguration {
|
||||
rules: vec![s3s::dto::LifecycleRule {
|
||||
status: s3s::dto::ExpirationStatus::from_static(s3s::dto::ExpirationStatus::ENABLED),
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: None,
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
id: None,
|
||||
filter: Some(s3s::dto::LifecycleRuleFilter {
|
||||
filter: Some(LifecycleRuleFilter {
|
||||
object_size_greater_than: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
|
||||
@@ -425,7 +425,9 @@ async fn malformed_size_reconciliation_replays_after_restart() {
|
||||
summary.record_size_reconciliation(entry.clone());
|
||||
summary.record_reconciliation_scope("b", "object");
|
||||
scanner.apply_size_reconciliation(&summary);
|
||||
scanner.finish_size_reconciliation_batch();
|
||||
scanner.apply_size_reconciliation(&summary);
|
||||
scanner.finish_size_reconciliation_batch();
|
||||
|
||||
assert_eq!(scanner.new_cache.info.size_reconciliation.len(), 1);
|
||||
assert_eq!(scanner.update_cache.info.size_reconciliation.len(), 1);
|
||||
@@ -440,6 +442,7 @@ async fn malformed_size_reconciliation_replays_after_restart() {
|
||||
let mut resolved = SizeSummary::default();
|
||||
resolved.record_reconciliation_scope("b", "object");
|
||||
scanner.apply_size_reconciliation(&resolved);
|
||||
scanner.finish_size_reconciliation_batch();
|
||||
assert!(scanner.new_cache.info.size_reconciliation.is_empty());
|
||||
assert!(scanner.update_cache.info.size_reconciliation.is_empty());
|
||||
}
|
||||
@@ -453,18 +456,20 @@ async fn malformed_size_reconciliation_clears_bounded_long_object_scope() {
|
||||
let entry = SizeReconciliationEntry {
|
||||
key: "long-object-key".to_string(),
|
||||
bucket: "b".to_string(),
|
||||
object: bounded_object,
|
||||
object: bounded_object.clone(),
|
||||
reason: "invalid_declared_size".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut summary = SizeSummary::default();
|
||||
summary.record_size_reconciliation(entry);
|
||||
scanner.apply_size_reconciliation(&summary);
|
||||
scanner.finish_size_reconciliation_batch();
|
||||
assert_eq!(scanner.new_cache.info.size_reconciliation.len(), 1);
|
||||
|
||||
let mut resolved = SizeSummary::default();
|
||||
resolved.record_reconciliation_scope("b", &long_object);
|
||||
resolved.record_reconciliation_scope("b", &bounded_object);
|
||||
scanner.apply_size_reconciliation(&resolved);
|
||||
scanner.finish_size_reconciliation_batch();
|
||||
assert!(scanner.new_cache.info.size_reconciliation.is_empty());
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user