Compare commits

..

49 Commits

Author SHA1 Message Date
overtrue 5cbe4a8465 fix(ecstore): preserve restore fileinfo errors 2026-08-24 17:57:41 +08:00
Zhengchao An 8bd6d8c4db fix(build): restore mimalloc workspace dependency 2026-08-24 17:26:54 +08:00
Zhengchao An 8f2b91f79b fix(ci): refresh e2e full selection 2026-08-24 17:25:45 +08:00
cxymds c2b2b4ffd4 fix(ci): repair post-merge test gates 2026-08-24 17:01:29 +08:00
Zhengchao An 4ceed58be4 docs(operations): document rebalance impact assessment 2026-08-24 17:00:19 +08:00
Zhengchao An 507faf3a6a fix(ci): restore post-merge test gates 2026-08-24 16:42:03 +08:00
Henry Guo 1607e9a376 fix(table-catalog): return 503 when commit authority is unavailable 2026-08-24 16:12:57 +08:00
Henry Guo f06b004f2d fix(scanner): clarify follower status 2026-08-24 16:12:29 +08:00
cxymds 41546dee5d test(ilm): isolate multipart compensation stack 2026-08-24 16:11:29 +08:00
Zhengchao An 7d3f5545e7 fix(ci): repair post-merge build gates 2026-08-24 16:10:24 +08:00
Zhengchao An d293ed71e5 test(e2e): require authorization denial codes (#6497) 2026-08-24 14:35:28 +08:00
houseme 114bb4acec perf(ecstore): add bucket existence cache and allocator feature flags (#6496)
## Bucket existence cache
- Add BucketExistenceCache in crates/ecstore/src/disk/fs.rs
- Cache bucket directory existence checks with 60s TTL
- Replace access() calls with cached_access() in local.rs
- Add invalidate_bucket_cache() for cache invalidation on create/delete
- Reduces statx syscalls by 89% (from 10,716/s to 1,186/s)

## Allocator feature flags
- Add mimalloc and jemalloc features to rustfs/Cargo.toml
- Default: system allocator (Rust built-in)
- --features mimalloc: mimalloc allocator
- --features jemalloc: jemalloc allocator
- Allows A/B testing different allocators

## Performance impact
- 1KiB PUT: 861 obj/s (unchanged, futex is main bottleneck)
- statx reduction: 89% (from 10,716/s to 1,186/s)
- Main bottleneck remains mimalloc internal synchronization

Ref: rustfs/backlog#2005
Ref: microsoft/mimalloc#1372

Co-authored-by: hector <hetor@rustfs.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-24 14:35:04 +08:00
Zhengchao An 29272480bd test(e2e): refresh Linux full-suite selection (#6495) 2026-08-24 14:32:19 +08:00
Zhengchao An e136e95a20 test(e2e): fail closed on missing socket oracle (#6493) 2026-08-24 14:32:11 +08:00
Zhengchao An f4ce1a8b3a test(e2e): fail closed on compression disk probes (#6492) 2026-08-24 14:32:02 +08:00
Zhengchao An 9681f19bec test(ci): require dependency-aware readiness (#6491)
* test(e2e): fail closed on runner readiness

* test(ci): require dependency-aware readiness
2026-08-24 14:31:38 +08:00
Zhengchao An 20a9c12f86 test(e2e): fail closed on runner readiness (#6490) 2026-08-24 14:31:21 +08:00
Zhengchao An 86e969f63c fix(ecstore): complete rename tails after quorum ack (#6489) 2026-08-24 14:30:50 +08:00
Zhengchao An 51272d34dd fix(app): restore stable Rust 1.98 builds (#6487)
* chore(scanner): narrow s3s DTO references

* fix(app): scope usage overlay import to tests

* fix(app): bound object-lock lookup future

* fix(log-analyzer): follow decommission migration logs
2026-08-24 14:30:25 +08:00
Zhengchao An cd1363d519 fix(scanner): restore s3s footprint baseline (#6486)
chore(scanner): narrow s3s DTO references
2026-08-24 14:30:16 +08:00
Zhengchao An 5142775387 test(e2e): require 404 absence oracles (#6485) 2026-08-24 14:30:05 +08:00
dependabot[bot] 2ed08c8bad chore(deps): bump p256 from 0.13.2 to 0.14.0 in the dependencies group (#6481)
* chore(deps): bump p256 from 0.13.2 to 0.14.0 in the dependencies group

Bumps the dependencies group with 1 update: [p256](https://github.com/RustCrypto/elliptic-curves).


Updates `p256` from 0.13.2 to 0.14.0
- [Commits](https://github.com/RustCrypto/elliptic-curves/compare/p256/v0.13.2...p256/v0.14.0)

---
updated-dependencies:
- dependency-name: p256
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* update crate version and remove rustfs-mimalloc-sys crate

* fix(connect): adapt p256 signing APIs

Use the p256 0.14 Generate trait for device key generation and update low-S normalization calls for ecdsa 0.17.

Remove an unused object usecase import so warning-deny builds stay clean.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(connect): update p256 canonical signature checks

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-24 14:29:56 +08:00
Henry Guo 60a0b1d6e7 fix(ecstore): avoid nested prefix listing amplification (#6473)
* fix(ecstore): avoid nested prefix listing probes

* fix(app): scope usage overlay import to tests

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-08-24 14:29:35 +08:00
Zhengchao An 170a4c7640 fix(scanner): bootstrap pristine usage baseline (#6471)
* fix(ecstore): fence pool metadata replica updates

* fix(ecstore): block decommission on unsafe pool metadata

* fix(ecstore): block writes after pool metadata save errors

* fix(ecstore): latch pool metadata writes before await

* fix(scanner): bootstrap pristine usage baseline
2026-08-24 14:29:09 +08:00
唐小鸭 fc98dbb654 fix(replication): tolerate orphaned resync intents at startup (#6470)
* fix(replication): tolerate orphaned resync intents at startup

Since #5215 (1.0.0-beta.12) startup reconciles every pending/started
resync intent in resync.bin against the bucket's configured targets and
aborts the whole server when an intent has no matching target ARN. A
resync whose remote target was later removed leaves exactly such an
orphan on disk, so every later start fails with "accepted replication
resync target ... is not configured" regardless of the binary version.

Skip orphaned intents with a warning instead of failing startup; the
resync routine already settles them to ResyncFailed. Cancel the intent
when its remote target is removed so the orphan is not created again.

Fixes #4784

* fix(replication): cancel removed-target resync under the admission lock

Canceling through this node's cached whole-bucket status map could
persist a map that predates another node's admission, erasing that
node's durable restart intent. Reload resync.bin under the bucket
admission lock, publish the fresh map, and only then mark the removed
target's intent canceled. Two-node regression covers the clobber.

* fix(replication): persist resync status via ETag CAS merge

mark_status, the periodic saver, admission, and removed-target
cancellation all persisted their node's cached whole-bucket map, so any
one node's stale cache could resurrect states another node had already
finalized (a canceled intent flipping back to Pending, an admission
vanishing). All resync.bin writers now go through update_resync_status_cas:
load the freshest document with its ETag, apply a per-target mutation
with staleness and canceled-is-terminal guards re-checked against the
persisted entry, and save conditionally, retrying on concurrent writes.
The periodic saver merges per target, letting terminal states and newer
admissions recorded elsewhere win. Cache convergence stays per-target so
locally running resyncs keep their authoritative progress counters.

Regressions: stale_peer_status_write_cannot_resurrect_canceled_intent
(node B's pre-cancel cache marking its own run Started must not revive
node A's canceled intent) plus unit coverage for the periodic-save merge.

* test(ecstore): rename resync test helper off the guarded contract name

fn resync_target is on the architecture guard's reserved list for
crates/replication operation contracts; the merge-test helper now reads
resync_target_state.

* fix(replication): serialize resync status updates

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-08-24 14:25:44 +08:00
Zhengchao An e091a7e702 fix(ecstore): fence pool metadata replica updates (#6466)
* fix(ecstore): fence pool metadata replica updates

* fix(ecstore): block decommission on unsafe pool metadata

* fix(ecstore): block writes after pool metadata save errors

* fix(ecstore): latch pool metadata writes before await
2026-08-24 14:25:19 +08:00
cxymds eec0e0e056 fix(scanner): fence movement generation publication (#6461)
* feat(scanner): add movement generation fencing

* fix(scanner): prioritize unverified cycle deferral

* feat(ecstore): add scanner publication lease fence

* feat(rpc): add scanner publication lease protocol

* feat(scanner): hold remote leases through usage publish

* test(scanner): cover publication lease fencing

* fix(scanner): fence remote leases across restart and delay

* feat(rpc): fence scanner publication rename writes

* fix(scanner): fence observed cleanup deletes

* fix(proto): qualify lease release test types

* fix(scanner): pin movement notifications

* fix(scanner): clean publication imports

* fix(ecstore): satisfy scanner fence clippy

* refactor(scanner): group wait and publication options

* fix(scanner): satisfy final lint and facade guards

* fix(rpc): resolve facade export conflicts

* fix(ci): remove unused decommission and healing facades

* fix(ci): cfg-gate test-only usage overlay import

* fix(scanner): wake on remote scanner restart
2026-08-24 14:17:35 +08:00
cxymds 1c5c28842a fix(scanner): reject duplicate usage updates (#6445)
* fix(scanner): reject duplicate usage updates

* style: format decommission test imports

* fix(ci): remove unused decommission and healing facades

* fix(ci): cfg-gate test-only usage overlay import

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-24 14:17:11 +08:00
Zhengchao An 16ca65ccab test(e2e): activate S3 Select regressions (#6442)
* test(e2e): activate S3 Select regressions

* test(e2e): bind S3 Select Linux selection
2026-08-24 14:16:48 +08:00
Zhengchao An ecefb5644b fix(e2e): honor custom Cargo target directory (#6434)
* fix(e2e): honor custom Cargo target directory

* test(e2e): bind target-dir selection digests

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-24 14:16:26 +08:00
Zhengchao An f473b6dbf8 test(e2e): activate configured Vault coverage (#6430)
* test(e2e): activate configured Vault roundtrip

* test(e2e): bind Vault selection to Linux listing

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-24 14:14:27 +08:00
Zhengchao An af6545b689 test(e2e): activate data usage regressions (#6429)
* test(e2e): activate data usage regressions

* test(e2e): bind data usage selection to Linux listing

* test(e2e): refresh data usage Darwin selection

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-24 14:14:02 +08:00
Zhengchao An a66b568ba0 test(e2e): require zero KMS concurrency failures (#6428)
* test(e2e): require zero KMS upload failures

* test(e2e): bind KMS selection to Linux listing

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-24 14:13:36 +08:00
Zhengchao An 232190a205 test(e2e): activate policy variable coverage (#6427)
* test(e2e): activate policy variable coverage

* test(e2e): update policy suite membership

* test(e2e): bind policy selection to Linux listing

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-24 14:11:36 +08:00
Zhengchao An f52a389652 fix(ecstore): persist unresolved decommission entries (#6415)
* fix(ecstore): persist unresolved decommission entries

* fix(ecstore): type decommission completion result

* fix(ecstore): allow intentional decommission listing signatures under strict clippy

The sftp/swift feature-matrix clippy gates run with -D warnings and
flag the unresolved-entry resolver (large Err payload by design, 8
context parameters) and the decommission listing driver (9 args).
Document why and align with the existing decommission_entry precedent.
2026-08-24 14:05:08 +08:00
Zhengchao An 73cd1b5be2 fix(ecstore): fence rebalance and decommission activation (#6400)
* fix(ecstore): fence rebalance and decommission activation

* fix(ecstore): fence lost activation locks

* fix(ecstore): bind rebalance workers to activation id

* fix(ecstore): close rebalance activation races

* test(ecstore): exercise lost rebalance commit fence

* fix(ecstore): repair rebalance fence test wiring

* test(ecstore): reuse rebalance metadata fixture

* fix(ecstore): satisfy rebalance activation clippy checks

* fix(ecstore): fence stale rebalance workers

* fix(ecstore): commit rebalance activation after persistence

* fix(ecstore): fence rebalance commits and unblock stop

* test(ecstore): exercise real rebalance fences

* fix(rebalance): cancel admin stop before activation wait

* fix(ecstore): fence multipart staging on rebalance lock loss

* fix(ecstore): adopt activations after durable commit

* fix(rebalance): preserve committed activation recovery

* fix(rebalance): make prepared stop terminal-safe

* fix(ecstore): repair rebalance test imports

* fix(ecstore): repair rebalance entry runtime failures

* test(ecstore): fix activation fence synchronization

* test(ecstore): scope rebalance disk trait import

* test(ecstore): observe decommission lock attempt

* fix(ecstore): align activation fence test imports

* fix(ecstore): remove duplicate activation test import

* fix(ecstore): resolve CI clippy failures

* fix: satisfy activation merge lint gates

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-24 14:03:21 +08:00
Zhengchao An 40bf9f0425 fix(ecstore): prefer active pool reads during decommission (#6475) 2026-08-24 09:36:35 +08:00
Zhengchao An eb0384c225 test(app): distinguish usage overlay from quota floor (#6479)
test(app): preserve delete quota floor assertion
2026-08-24 09:33:27 +08:00
Zhengchao An 0e015360cc test(scanner): repair post-fence fixtures (#6478) 2026-08-24 09:33:21 +08:00
Zhengchao An 2bd1df3075 fix(scanner): preserve default usage cache wire format (#6477) 2026-08-24 09:33:16 +08:00
Zhengchao An 9935911e93 fix(ecstore): drop unused decommission bucket runner and refresh e2e linux digest (#6476)
fix(rustfs): drop sftp-dead scanner capability import and unwired heal_bucket trait method

The sftp feature matrix compiles fewer call sites: the plain
sign_ns_scanner_capability re-export had no remaining user, and
StoragePeerS3ClientExt::heal_bucket was superseded by
heal_bucket_with_fence when #6416 wired the fenced path.
2026-08-24 09:33:10 +08:00
Zhengchao An 99fb77b164 fix(ci): restore main checks (#6469)
* fix(ci): restore main checks

* fix(scanner): bootstrap pristine usage state

* fix(scanner): reject empty usage snapshots
2026-08-24 09:33:00 +08:00
Zhengchao An ebff02304d fix(connect): make registration bootstrap retry durable (#6468)
* fix(connect): make registration state durability retry-safe

* fix(connect): reject parent state paths

* fix(connect): harden state directory creation

* fix(connect): bound bootstrap directory syncs

* fix(connect): require durable state parent

* fix(connect): close bootstrap marker race

* test(connect): cover marker sync failure
2026-08-24 09:32:54 +08:00
Zhengchao An 57eaa8228d fix(ecstore): migrate tier free versions during decommission (#6393) 2026-08-24 09:21:53 +08:00
Zhengchao An aa0a374aa4 style: rustfmt decommission import groups on main (#6474)
cargo fmt --check has been failing since #6410 landed: the merged
decommission import groups in core/pools.rs were not canonical
rustfmt output. Apply formatting verbatim, no code changes.
2026-08-24 03:07:49 +08:00
Zhengchao An 1ec1a8d90d fix(ecstore): run metadata decommission before buckets (#6410)
* fix(ecstore): run decommission metadata first

* fix(ecstore): clear clippy warnings

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-24 00:22:46 +08:00
Zhengchao An 28b3ecf547 fix(ci): restore main checks (#6467) 2026-08-24 00:15:58 +08:00
Zhengchao An 33341d5fcf fix(ecstore): unify decommission resume queue (#6403)
* fix(ecstore): unify decommission resume queue

* fix(ecstore): clear swift clippy warnings

* fix(ecstore): serialize decommission recovery

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-23 23:51:33 +08:00
Zhengchao An 2a43e021c9 fix(ecstore): fence bucket heal during decommission (#6416)
* fix(ecstore): fence bucket heal during decommission

* fix(ecstore): preserve unfenced heal compatibility

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-08-23 23:38:16 +08:00
148 changed files with 18542 additions and 2528 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=ae412c27e2e43f3fda3e0c21ef616b48a6403354faab8db8d4154202e3c4eec5
sha256-linux=ef3be856bd3257c2c369428f66a48ad073dad8187229fe95840a600a81edf22b
sha256-darwin=88ee9684ece0e27294f2b3f0c9c8fe62890feff76aa47279d42dab0af3196fe2
sha256-linux=d13337936af6778b1d2b2b255ae7fd350fdec94034be46daf738bd577653f799
+5 -4
View File
@@ -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'
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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" }
+19
View File
@@ -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");
}
}
+8 -2
View File
@@ -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()
+1 -1
View File
@@ -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
+10 -4
View File
@@ -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();
+32 -14
View File
@@ -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(
+24 -4
View File
@@ -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}");
}
+38 -12
View File
@@ -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"));
+59 -29
View File
@@ -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)"
+4 -14
View File
@@ -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();
+24 -6
View File
@@ -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(())
}
+12 -2
View File
@@ -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");
+5 -14
View File
@@ -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.
-2
View File
@@ -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");
-100
View File
@@ -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);
}
}
}
-230
View File
@@ -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(())
}
+13 -2
View File
@@ -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");
+13 -5
View File
@@ -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(())
+67 -82
View File
@@ -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(())
}
+12 -2
View File
@@ -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();
+19 -11
View File
@@ -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(&current, 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(&current, oi) {
return false;
}
enqueue_expiry_rule_with_incarnation(event, src, oi, bucket_incarnation_id).await
+65
View File
@@ -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(());
+5 -2
View File
@@ -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)
+562 -24
View File
@@ -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 {
+92 -43
View File
@@ -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:?}"),
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -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,
+82 -26
View File
@@ -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, &current, 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, &current, &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, &current, &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");
+33 -2
View File
@@ -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());
+76 -2
View File
@@ -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::*;
+132 -12
View File
@@ -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, &current, -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";
+62 -1
View File
@@ -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() {
+74
View File
@@ -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) {
+154 -1
View File
@@ -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,
+2 -2
View File
@@ -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))
+171 -5
View File
@@ -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
+12 -5
View File
@@ -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(),
})
+233 -43
View File
@@ -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(&current.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
+414 -5
View File
@@ -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];
+26 -15
View File
@@ -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());
+5 -4
View File
@@ -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
+3
View File
@@ -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;
+407 -51
View File
@@ -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,
&quota_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,
};
}
+65
View File
@@ -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() {
+298 -7
View File
@@ -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(),
};
+697 -42
View File
@@ -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() {
+29 -1
View File
@@ -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
View File
@@ -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]
+1 -1
View File
@@ -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(),
}
+146 -25
View File
@@ -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(&current.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(),
}
+163 -9
View File
@@ -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() {
+21 -2
View File
@@ -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);
+52
View File
@@ -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
+9
View File
@@ -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));
+2
View File
@@ -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;
+2
View File
@@ -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
+2 -1
View File
@@ -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"),
+1 -1
View File
@@ -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
View File
@@ -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)]
+55
View File
@@ -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
+1 -1
View File
@@ -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>,
+23 -7
View File
@@ -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 {
+11 -2
View File
@@ -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
View File
@@ -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)]
+225 -57
View File
@@ -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()
+94 -17
View File
@@ -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) {
+109 -13
View File
@@ -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() =>
+534 -20
View File
@@ -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();
+117 -14
View File
@@ -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,
+7 -2
View File
@@ -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()
}),
+7 -2
View File
@@ -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