mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 040720aa58 | |||
| 124cbfc00a | |||
| 3aa7964c58 | |||
| 42d376c6b7 | |||
| 017b85a5bf | |||
| 55023ecd86 | |||
| b2006b78e3 | |||
| 1123029eda | |||
| 529b440f67 | |||
| 4ed33f68a8 | |||
| 28fbe69909 | |||
| 1cd62c4249 | |||
| 0f24511535 | |||
| 9517db6cf7 | |||
| 3bcba78ccb | |||
| af4ce32528 | |||
| 32c4593587 | |||
| 2f90fb19f5 | |||
| 17aa75a175 | |||
| d0f8475924 | |||
| 7130f25af5 | |||
| 900d082764 | |||
| b23723a51f | |||
| 07b7d4fc18 | |||
| d60ef4873a | |||
| aaeedcd1d7 | |||
| 8ebec9e71c | |||
| cced213b66 | |||
| 882217221e | |||
| f279cefa9c | |||
| 8aba025bd6 | |||
| 40837f67f8 | |||
| 042f6ba3aa | |||
| 63eac7a387 | |||
| e13336a116 | |||
| 8d5af5981d | |||
| f53c5d41d4 | |||
| 4096d05ca7 | |||
| d2c1434a02 | |||
| c8f14dc337 | |||
| 90c5050a0f | |||
| 334e8d9d3e | |||
| d8e8f6bfa4 | |||
| 4e64e86a29 | |||
| d229c79e38 | |||
| 0c8b05585e | |||
| 911ea265c5 | |||
| 8c7b3996cb | |||
| 68a5b2c89c | |||
| 7f1795f275 | |||
| 3092792c3c | |||
| e542e62f07 | |||
| d90419f4dc | |||
| bbe2f5a3d3 | |||
| b5f275ca3f | |||
| c2e6b974e1 | |||
| b39fcb2e85 | |||
| 301e3a9f34 | |||
| 6535c28f1a | |||
| 76f4342ae2 | |||
| 128634f102 | |||
| abe14e2f97 | |||
| 7d6172e666 | |||
| 3254e79efd | |||
| da7dc74147 | |||
| db55098aa6 | |||
| 47e1850d7e | |||
| 4e88ebcf30 | |||
| cfee2ffaba | |||
| 623601d2f9 | |||
| 2474adc50f | |||
| c8cb1d86d3 | |||
| 607393e6fa | |||
| daa9529fc6 | |||
| 1820a4c40b | |||
| 631cd0ceca | |||
| 5778384b68 | |||
| 44a73b0bef | |||
| 91007466c9 | |||
| 6e3484916f | |||
| e1e710cb5d | |||
| d9ab8760ee | |||
| 67442a5ba3 | |||
| df10dc7864 | |||
| 5c463ff8d3 | |||
| 1e36e18265 | |||
| 0b25604503 | |||
| 89c3282ae2 | |||
| 0d0e6d8497 | |||
| f1a0263ab1 | |||
| bea4be68a2 | |||
| 90a39a5083 | |||
| 11381c260f | |||
| 89b98d65c2 | |||
| 335612f452 | |||
| 0bade0ca3e | |||
| 5749752217 | |||
| 03c65bcba8 | |||
| 2feb09b8eb | |||
| be8357f815 | |||
| 8eb67ac38c | |||
| a47649fb60 | |||
| c0f3f9a108 | |||
| 956395a61b | |||
| 4597bb91a8 | |||
| 87caf45cfe | |||
| 15b7040e78 | |||
| ffd94cfe1b | |||
| 831029e5b6 | |||
| 1e817524e8 | |||
| 66bf2c0a75 | |||
| f06ae3dc34 | |||
| 8af4070d19 | |||
| c1de42af2c | |||
| 179e58d5ef | |||
| 5f5485e332 | |||
| 65f0599c70 | |||
| d5e037faac | |||
| 97e600ae69 | |||
| 84e96512bd | |||
| 62aafc6b95 | |||
| b06923beaf | |||
| 023f2e7258 | |||
| 9fad6104c4 | |||
| d2eda195e6 | |||
| 311f27baa1 | |||
| 1ac35917c2 | |||
| 73ff9a87e8 | |||
| 102cc18767 | |||
| 77a36d92a3 | |||
| 444eb5ddba | |||
| bd7b7321a2 | |||
| d122e8e6bc | |||
| 30c1dffe48 | |||
| 2eeaca4cd0 | |||
| 001e92226e | |||
| 65a721d896 | |||
| f1850f54d4 | |||
| 2c55d9c8f0 | |||
| 5efa6c2173 | |||
| 2110348e58 | |||
| 2253ba9009 | |||
| 72d5c5bf2f | |||
| 13c37bf8b9 | |||
| 38595c1909 | |||
| 9b41835580 | |||
| a5ae294f42 | |||
| 4dba635d48 | |||
| 7cc4a9dbd0 | |||
| 928a369445 | |||
| 5313b72baa | |||
| f98d268f84 | |||
| f4f1743d04 | |||
| 8acfde3842 | |||
| f320f791a4 | |||
| 4d2e362e23 | |||
| a55f2f671b | |||
| e7e53f07b2 | |||
| da7fde0e62 | |||
| 6127deaa83 | |||
| ea596b2e2d | |||
| 10a9b158d7 |
@@ -36,6 +36,21 @@ changed lesson.
|
||||
|
||||
## Finding Standard
|
||||
|
||||
Each finding includes severity, `file:line`, attacker prerequisites, concrete
|
||||
input/path, impact, smallest safe fix, and a regression check. Do not exaggerate
|
||||
unauthenticated impact when the actual issue requires authenticated low privilege.
|
||||
Every candidate names the lower-trust principal, the boundary it crosses, the
|
||||
affected resource, and the observable result. A best-practice gap or an impact
|
||||
confined to the caller's own principal is hardening, not a candidate.
|
||||
|
||||
Report each candidate under exactly one verdict:
|
||||
|
||||
- `confirmed`: a complete `file:line` source trace and a bounded observed
|
||||
result. Only confirmed findings carry severity; each also states attacker
|
||||
prerequisites, concrete input/path, impact, smallest safe fix, and a
|
||||
regression check.
|
||||
- `needs_validation`: the trace is blocked on one exact unresolved fact, such
|
||||
as deployment or infrastructure behavior. State that fact; assign no severity
|
||||
and do not substitute an assumption.
|
||||
- `rejected`: a disproved candidate, with the caller, invariant, or test that
|
||||
disproves it.
|
||||
|
||||
Do not exaggerate unauthenticated impact when the actual issue requires
|
||||
authenticated low privilege.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-linux=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
|
||||
sha256-darwin=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
|
||||
sha256-linux=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
|
||||
sha256-darwin=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=3a7f52dd884ed269746029e983006ce69b62f260db2ad1acdcffdb2196c65d11
|
||||
sha256-linux=4af4bb607919b96e63e2a3fb7a78bd18fcb08e265f4ca4ce6afb09a8be798305
|
||||
sha256-darwin=b53fe6d46cdffedfad6a83da0dfffa9aec10cd7c8ace53469f129587bc9f4d50
|
||||
sha256-linux=6be90f5c3886e9852ac27d51f2d47e6d2fa8bb424e0d03ee931268dcd0020db8
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=ce16185f87c6cd30aa0891ebd22be3352aedc10040d48cc5b00652d72f7be3b5
|
||||
sha256=002ec5d973f82288e50a096a2c8b3b38749f62a7a3e169775566874d8ee98b4a
|
||||
|
||||
+36
-2
@@ -83,12 +83,29 @@ threads-required = "num-test-threads"
|
||||
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These eight-disk decommission fixtures exercise concurrent retries,
|
||||
# free-version cleanup, and real capacity probes under production lock deadlines.
|
||||
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# This registration/backup lock probe uses an isolated eight-disk store.
|
||||
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
|
||||
# registration budget; retain the queued backup and deadlock assertions.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
|
||||
# Reserve capacity across test processes while preserving each scenario's fixed
|
||||
# budget, assertions, and internal concurrency.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.scripts]]
|
||||
@@ -349,12 +366,29 @@ threads-required = "num-test-threads"
|
||||
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These eight-disk decommission fixtures exercise concurrent retries,
|
||||
# free-version cleanup, and real capacity probes under production lock deadlines.
|
||||
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# This registration/backup lock probe uses an isolated eight-disk store.
|
||||
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
|
||||
# registration budget; retain the queued backup and deadlock assertions.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
|
||||
# Reserve capacity across test processes while preserving each scenario's fixed
|
||||
# budget, assertions, and internal concurrency.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
python3 scripts/e2e_binary.py build --bins
|
||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
- name: Verify distributed e2e membership
|
||||
env:
|
||||
@@ -170,9 +170,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
rm -f target/nextest/e2e-distributed/junit.xml
|
||||
if [ -n "${FILTER}" ]; then
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
else
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
fi
|
||||
test -s target/nextest/e2e-distributed/junit.xml
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ on:
|
||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
- "crates/heal/**"
|
||||
- "crates/kms/**"
|
||||
- "crates/storage-api/**"
|
||||
- "rustfs/**"
|
||||
@@ -160,6 +161,7 @@ jobs:
|
||||
)
|
||||
if [[ "$UPGRADE_SOURCE_VERSION" == "1.0.0-rc.5" ]]; then
|
||||
cases+=(odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration)
|
||||
cases+=(checkpoint-upgrade:rc5_checkpoint_upgrade_rewinds_signed_progress_without_losing_objects)
|
||||
else
|
||||
cases+=(legacy-delete-reservation:stable_upgrade_deletes_foreign_reservations_without_losing_live_objects)
|
||||
fi
|
||||
|
||||
@@ -279,7 +279,9 @@ jobs:
|
||||
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
|
||||
STEPS="$STEPS,7,8"
|
||||
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
|
||||
STEPS="$STEPS,9"
|
||||
# Step 10 asserts the surviving pools after the removal
|
||||
# (rustfs#8726), so it only runs together with step 9.
|
||||
STEPS="$STEPS,9,10"
|
||||
fi
|
||||
fi
|
||||
ARGS=(--steps "$STEPS" --with-warp -y \
|
||||
@@ -293,6 +295,10 @@ jobs:
|
||||
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
|
||||
ARGS+=(--proxy-endpoint "${RUSTFS_POOL_PROXY_ENDPOINT}")
|
||||
fi
|
||||
# Finalize the removal so step 10 can exercise the surviving
|
||||
# pools; passing it without step 9 selected would run step 10
|
||||
# against a topology where nothing was removed.
|
||||
case ",${STEPS}," in *,9,*) ARGS+=(--finalize-decommission) ;; esac
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
|
||||
@@ -19,12 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
|
||||
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
|
||||
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
|
||||
- A `set-remote-target?update=true&creds=true` request on a site-replication peer's bucket target is now rejected with `400 InvalidRequest`, naming the target and why it counts as peer-owned. It used to return success while keeping the stored endpoint, credentials, and TLS settings, so an endpoint edit from the Console's bucket replication form appeared to apply but did not. Change a peer's address with a site-replication edit instead; see "Changing a site's address" in `docs/operations/site-replication-operations.md`. The `sync`, `bandwidth`, `proxy`, and `path` update groups are unchanged.
|
||||
- Site-replication edits that omit the site name, such as `mc admin replicate update --deployment-id <ID> --endpoint <URL>`, now keep the stored name on the editing site and on every receiver instead of renaming the site after the new endpoint host. No persisted format changes.
|
||||
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
|
||||
|
||||
### Security
|
||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **`mc admin info` and `mc admin replicate resync` work again**: since the cluster snapshot view (#3803), `GET /v3/info` and `GET /v3/storageinfo` returned the madmin `InfoMessage` / `StorageInfo` nested under an `info` key next to `admin_discovery` (and, for `/info`, `bitrotSelftest`). madmin decodes those bodies directly, so `mc admin info` printed empty values and `mc admin replicate resync start/status` rejected every alias with "alias provided is not part of cluster replication" because the local `deploymentID` read as empty. Requests on the MinIO-compatible `/minio/admin/v3` prefix now receive the bare madmin body again; `/rustfs/admin/v3`, which rc and the console use, keeps the envelope unchanged.
|
||||
- **Replication status writebacks no longer invalidate scanner cycles**: every replication status writeback (`persist_replication_state_if_current` in `Update` mode, including the heals the scanner queues during its own walk) marked the bucket dirty on the namespace path, advanced the dirty-usage generation and woke the scanner, so a site-replication resync or any steadily replicated bucket ended every cycle `superseded`, the authoritative usage baseline was never certified, and the console reported usage freshness as stale for as long as replication ran (#8375). Those writebacks are now status class: they do not touch the dirty-usage generation, the activity snapshot, the dirty-usage wake or the durable dirty journal, and only the process-local producer coverage is recorded. They need no rescan queue, because a deployment with an active replication rule already walks every bucket on every cycle and that walk refreshes `replication_stats`. Tier-expiration cleanup, which removes data, stays on the namespace path. No persisted format, protocol, admin API or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
|
||||
- **A bucket written during a scanner walk no longer supersedes the whole cycle**: the post-walk cluster activity proof required the entire activity snapshot and the dirty-usage generation to be unchanged, so a single-node deployment with one bucket written every few seconds never reached a `Complete` cycle, `usage_snapshot_converged` never became true and the authoritative usage baseline was never certified (#8494). Dirty usage is now certified per bucket: a structurally complete walk that the guardrail did not veto is `Complete`, buckets whose dirty generation still matches the start snapshot are cleared, and buckets written during the walk stay dirty and are published as the pending set, so `usage_snapshot_converged` means "no bucket pending" and the next cycle is scoped to the pending buckets. Such a cycle writes the authoritative usage object; the observed object is now selected by an explicit observational mark that vetoed cycles carry. The guardrail itself vetoes only on the fields that change which namespace the walk described (peer set, process instance, protocol version, topology, maintenance generation, data movement, movement generation, publication blocked) and reports the field and host in the scanner log and in the new `usage_freshness` fields `guardrail_veto_total`, `last_guardrail_veto_unix_secs` and `last_guardrail_veto_reason`. In a distributed deployment a remote peer whose dirty usage moved during the walk still supersedes the cycle until scoped acknowledgements can name its buckets. `.usage.v2.json` gains the additive, default-when-absent fields `usage_snapshot_pending_buckets`, `usage_snapshot_pending_bucket_count` and `usage_snapshot_observational`; older readers ignore them and treat the snapshot as an authoritative but unconverged publication. Quota is unchanged: an uncertified bucket keeps its last usage value as the floor and live writes do not advance it. No protocol or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
|
||||
- **SSE-C TLS requirement now covers copy-source keys**: with `RUSTFS_SSE_C_REQUIRE_TLS=true`, a `CopyObject` or `UploadPartCopy` that carried only the `x-amz-copy-source-server-side-encryption-customer-*` headers (an SSE-C source copied to a destination that is not SSE-C) was accepted on a plaintext transport and was not counted in `rustfs_ssec_plaintext_requests_total`, although those headers carry the source object's customer key. They now count as SSE-C headers: such requests are counted, and refused with `400 InvalidRequest` when the switch is on. Requests over TLS are unaffected.
|
||||
|
||||
Generated
+381
-604
File diff suppressed because it is too large
Load Diff
+45
-29
@@ -146,7 +146,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37.1" }
|
||||
async-compression = { version = "0.4.50" }
|
||||
async-compression = { version = "0.4.51" }
|
||||
async-trait = "0.1.92"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
axum = "0.8.9"
|
||||
@@ -156,7 +156,7 @@ futures-lite = "2.6.1"
|
||||
futures-util = "0.3.34"
|
||||
pollster = "1.0.1"
|
||||
pulsar = { default-features = false, version = "=6.9.0" }
|
||||
lapin = { default-features = false, version = "4.12.2" }
|
||||
lapin = { default-features = false, version = "4.12.3" }
|
||||
hyper = { version = "1.12.0" }
|
||||
h2 = { version = "0.4.20" }
|
||||
hyper-rustls = { default-features = false, version = "0.27.10" }
|
||||
@@ -169,7 +169,7 @@ minlz = "1.2.3"
|
||||
reqwest = "0.13.5"
|
||||
rustfs-kafka-async = { version = "1.4.0" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.53.3" }
|
||||
tokio = { version = "1.54.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.6" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.6"
|
||||
@@ -243,22 +243,22 @@ tokio-postgres-rustls = "0.14.0"
|
||||
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.104"
|
||||
arc-swap = "1.9.2"
|
||||
arc-swap = "1.9.3"
|
||||
rustfs-tokio-tar = { version = "0.7.1" }
|
||||
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
|
||||
tar-codec = "0.0.15"
|
||||
tar-framing = "0.0.15"
|
||||
atoi = "3.1.0"
|
||||
atoi = "3.1.1"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.12.0" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.123.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.152.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.119.0" }
|
||||
aws-smithy-async = { version = "1.3.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.5.0" }
|
||||
aws-smithy-runtime-api = { version = "1.19.0" }
|
||||
aws-smithy-types = { version = "1.8.1" }
|
||||
aws-config = { version = "1.13.0" }
|
||||
aws-credential-types = { version = "1.3.1" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.126.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.155.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.122.0" }
|
||||
aws-smithy-async = { version = "1.3.1" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.5.1" }
|
||||
aws-smithy-runtime-api = { version = "1.19.1" }
|
||||
aws-smithy-types = { version = "1.9.0" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "9.0.0"
|
||||
clap = { version = "4.6.7" }
|
||||
@@ -272,12 +272,12 @@ crossbeam-utils = "0.8.23"
|
||||
datafusion = { default-features = false, version = "55.2.0" }
|
||||
derive_builder = "0.20.2"
|
||||
ed25519-dalek = "3.0.0"
|
||||
enumset = "1.1.14"
|
||||
enumset = "1.1.15"
|
||||
faster-hex = "1.0.0"
|
||||
flate2 = "1.1.10"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.20.0"
|
||||
google-cloud-auth = "1.17.0"
|
||||
google-cloud-storage = "1.21.0"
|
||||
google-cloud-auth = "1.18.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
|
||||
# every authenticator app expects). Already in the graph transitively.
|
||||
@@ -287,11 +287,11 @@ highway = { version = "1.3.0" }
|
||||
hostname = "0.4.2"
|
||||
ipnetwork = { version = "0.21.1" }
|
||||
lazy_static = "1.5.1"
|
||||
libc = "0.2.190"
|
||||
libc = "0.2.191"
|
||||
libsystemd = "0.7.2"
|
||||
local-ip-address = "0.6.13"
|
||||
log = "0.4"
|
||||
memmap2 = "0.9.11"
|
||||
memmap2 = "0.9.12"
|
||||
lz4 = "1.28.1"
|
||||
matchit = "0.9.2"
|
||||
md-5 = "0.11.0"
|
||||
@@ -301,7 +301,7 @@ netif = "0.1.6"
|
||||
num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.13.0"
|
||||
object = { version = "=0.40.0", default-features = false }
|
||||
parking_lot = "0.12.5"
|
||||
parking_lot = "0.13.0"
|
||||
path-absolutize = "4.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||
@@ -317,7 +317,7 @@ rustfs-erasure-codec = { version = "9.0.0" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||
redis = { version = "1.7.1" }
|
||||
redis = { version = "1.8.0" }
|
||||
rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.5" }
|
||||
rust-embed = { version = "8.13.0" }
|
||||
@@ -327,7 +327,7 @@ s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fc
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.4"
|
||||
smallvec = { version = "1.16.3" }
|
||||
smallvec = { version = "1.16.4" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.4.0" }
|
||||
@@ -346,7 +346,7 @@ tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.28.0" }
|
||||
uuid = { version = "1.29.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
@@ -354,13 +354,13 @@ winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "9.0.3"
|
||||
zstd = "0.14.2"
|
||||
zip = "9.1.0"
|
||||
zstd = "0.14.3"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
dial9 = { version = "0.5.4", default-features = false }
|
||||
dial9 = { version = "0.5.5", default-features = false }
|
||||
opentelemetry = { version = "0.33.1" }
|
||||
opentelemetry-appender-tracing = { version = "0.33.1" }
|
||||
opentelemetry-otlp = { version = "0.33.1" }
|
||||
@@ -368,7 +368,7 @@ opentelemetry-proto = { version = "0.33.1", default-features = false, features =
|
||||
opentelemetry_sdk = { version = "0.33.1" }
|
||||
opentelemetry-semantic-conventions = { version = "0.33.1" }
|
||||
opentelemetry-stdout = { version = "0.33.1" }
|
||||
pyroscope = { version = "2.1.2" }
|
||||
pyroscope = { version = "2.1.5" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.1" }
|
||||
@@ -382,9 +382,9 @@ russh-sftp = "3.0.1"
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.6" }
|
||||
rustfs-mimalloc = { version = "0.7.2" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.28.6", default-features = false }
|
||||
hotpath = { version = "0.29.1", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
@@ -421,3 +421,19 @@ inherits = "release"
|
||||
inherits = "release"
|
||||
debug = true
|
||||
strip = "none"
|
||||
|
||||
# Pin upstream fixes until compatible crates.io releases are available.
|
||||
[patch.crates-io]
|
||||
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
|
||||
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
|
||||
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
|
||||
oauth2 = { git = "https://github.com/ramosbugs/oauth2-rs", rev = "89585f4a280afd7b285eaf1c9be0068874e03d22" }
|
||||
aws-sigv4 = { git = "https://github.com/smithy-lang/smithy-rs", rev = "5c1c4bca2ea7af97496e077c9f1a718eaac64bf8" }
|
||||
|
||||
# Keep the signer's path dependencies on the SDK's existing registry types.
|
||||
[patch."https://github.com/smithy-lang/smithy-rs"]
|
||||
aws-credential-types = { version = "=1.3.1" }
|
||||
aws-smithy-eventstream = { version = "=0.61.6" }
|
||||
aws-smithy-http = { version = "=0.64.2" }
|
||||
aws-smithy-runtime-api = { version = "=1.19.1" }
|
||||
aws-smithy-types = { version = "=1.9.0" }
|
||||
|
||||
@@ -29,6 +29,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-util = []
|
||||
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
|
||||
@@ -97,6 +97,45 @@ pub fn try_get_global_local_node_name() -> Option<String> {
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
/// Run a synchronous test callback while publishing a name under its writer lock.
|
||||
/// Call from a blocking worker; returning or unwinding releases the writer.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[doc(hidden)]
|
||||
pub fn with_local_node_name_write_lock_for_test<T>(name: &str, work: impl FnOnce() -> T) -> T {
|
||||
let mut writer = GLOBAL_LOCAL_NODE_NAME.blocking_write();
|
||||
*writer = name.to_string();
|
||||
let result = work();
|
||||
drop(writer);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod node_identity_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_node_name_nonblocking_read_recovers_after_writer_release() {
|
||||
let previous = get_global_local_node_name().await;
|
||||
let (unavailable, published, panicked) = tokio::task::spawn_blocking(|| {
|
||||
let unavailable = with_local_node_name_write_lock_for_test("published-node", try_get_global_local_node_name);
|
||||
let published = try_get_global_local_node_name();
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
with_local_node_name_write_lock_for_test("unwound-node", || panic!("identity callback failed"));
|
||||
});
|
||||
(unavailable, published, panicked)
|
||||
})
|
||||
.await
|
||||
.expect("identity writer worker must complete");
|
||||
let after_unwind = try_get_global_local_node_name();
|
||||
set_global_local_node_name(&previous).await;
|
||||
|
||||
assert!(unavailable.is_none(), "a held writer must make the nonblocking getter unavailable");
|
||||
assert_eq!(published.as_deref(), Some("published-node"));
|
||||
assert!(panicked.is_err(), "the callback panic must propagate after releasing the lock");
|
||||
assert_eq!(after_unwind.as_deref(), Some("unwound-node"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the global RustFS address used for gRPC connections.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -172,11 +172,41 @@ pub struct MrfUnverifiedLegacyEvent {
|
||||
pub anchor: MrfDurableRepairAnchor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ResolvedMrfRepairIdentity {
|
||||
version_id: Option<[u8; 16]>,
|
||||
scope: Option<MrfScope>,
|
||||
delete_marker_purge: Option<MrfDeleteMarkerPurgeIdentity>,
|
||||
lease: MrfIngressLease,
|
||||
bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
impl MrfDurableRepairAnchor {
|
||||
/// Build a dischargeable anchor only when the caller supplies the storage
|
||||
/// incarnation and the original ingress lease. Legacy replay records lack
|
||||
/// both pieces and therefore remain fail-closed.
|
||||
pub fn from_intent(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<Self> {
|
||||
let identity = Self::resolve_identity(intent, bucket_incarnation_id)?;
|
||||
Some(Self {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
version_id: identity.version_id,
|
||||
scope: identity.scope,
|
||||
delete_marker_purge: identity.delete_marker_purge,
|
||||
lease: identity.lease,
|
||||
bucket_incarnation_id: identity.bucket_incarnation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether the intent can form an anchor for this exact source,
|
||||
/// without retaining its bucket/object handles. This checks identity shape,
|
||||
/// not storage verification or retirement authority.
|
||||
pub fn is_eligible_for_source(intent: &MrfIntent, source: Uuid) -> bool {
|
||||
Self::resolve_identity(intent, source).is_some_and(|identity| identity.bucket_incarnation_id == source)
|
||||
}
|
||||
|
||||
fn resolve_identity(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<ResolvedMrfRepairIdentity> {
|
||||
let lease = intent.lease?;
|
||||
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
|
||||
let delete_marker_purge = canonical_delete_marker_purge_identity(intent)?;
|
||||
@@ -186,10 +216,7 @@ impl MrfDurableRepairAnchor {
|
||||
if bucket_incarnation_id.is_nil() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
Some(ResolvedMrfRepairIdentity {
|
||||
version_id,
|
||||
scope,
|
||||
delete_marker_purge,
|
||||
@@ -233,24 +260,32 @@ pub fn consume_recorded_verified_mrf_repair_events_for(bucket: &str, anchors: &m
|
||||
let Ok(mut events) = registry.lock() else {
|
||||
return 0;
|
||||
};
|
||||
consume_verified_mrf_repair_events_from_queue(bucket, anchors, &mut events)
|
||||
}
|
||||
|
||||
fn consume_verified_mrf_repair_events_from_queue(
|
||||
bucket: &str,
|
||||
anchors: &mut Vec<MrfDurableRepairAnchor>,
|
||||
events: &mut std::collections::VecDeque<MrfVerifiedRepairEvent>,
|
||||
) -> usize {
|
||||
let before = anchors.len();
|
||||
let mut retained = std::collections::VecDeque::with_capacity(events.len());
|
||||
while let Some(event) = events.pop_front() {
|
||||
let previous_event_count = events.len();
|
||||
events.retain(|event| {
|
||||
if event.bucket.as_ref() != bucket {
|
||||
retained.push_back(event);
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
let mut matched = false;
|
||||
anchors.retain(|anchor| {
|
||||
let proven = anchor.is_proven_by(&event);
|
||||
let proven = anchor.is_proven_by(event);
|
||||
matched |= proven;
|
||||
!proven
|
||||
});
|
||||
if !matched {
|
||||
retained.push_back(event);
|
||||
}
|
||||
}
|
||||
*events = retained;
|
||||
!matched
|
||||
});
|
||||
// The old queue rebuild reserved the pre-consumption event count. Reuse
|
||||
// that buffer when possible without extending its capacity lifetime:
|
||||
// an emptied queue releases its backing storage on the next consumption.
|
||||
events.shrink_to(previous_event_count);
|
||||
before.saturating_sub(anchors.len())
|
||||
}
|
||||
|
||||
@@ -821,6 +856,21 @@ static MRF_VERIFIED_REPAIR_EVENTS: OnceLock<std::sync::Mutex<std::collections::V
|
||||
static MRF_UNVERIFIED_LEGACY_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfUnverifiedLegacyEvent>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Whether durable repair events may need consumption. This is only a work
|
||||
/// hint: either queue can receive an event after its empty observation, and a
|
||||
/// bounded queue can evict that event before the next consumer invocation.
|
||||
/// An unavailable lock is unknown, so callers must keep their normal path.
|
||||
/// An allocated empty verified queue also needs the normal consumer pass to
|
||||
/// release its buffer; this hint must not extend that buffer's lifetime.
|
||||
pub fn mrf_durable_repair_events_may_be_pending() -> bool {
|
||||
MRF_VERIFIED_REPAIR_EVENTS
|
||||
.get()
|
||||
.is_some_and(|registry| registry.try_lock().map_or(true, |events| events.capacity() != 0))
|
||||
|| MRF_UNVERIFIED_LEGACY_EVENTS
|
||||
.get()
|
||||
.is_some_and(|registry| registry.try_lock().map_or(true, |events| !events.is_empty()))
|
||||
}
|
||||
|
||||
/// Record a legacy notification for compatibility. This is not an
|
||||
/// acknowledgement of storage verification or durable repair completion.
|
||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
||||
@@ -935,6 +985,15 @@ pub fn take_mrf_unverified_legacy_events_for(bucket: &str) -> Vec<MrfUnverifiedL
|
||||
taken
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod event_presence_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod proof_queue_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod repair_identity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn isolated(name: &str) -> bool {
|
||||
const CHILD: &str = "RUSTFS_MRF_EVENT_PRESENCE_TEST";
|
||||
let test = format!("mrf_channel::event_presence_tests::{name}");
|
||||
if let Ok(current) = std::env::var(CHILD) {
|
||||
assert_eq!(current, test, "only the selected registry test may run in the child");
|
||||
return false;
|
||||
}
|
||||
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args([&test, "--exact", "--test-threads=1"])
|
||||
.env(CHILD, &test)
|
||||
.output()
|
||||
.expect("run isolated global registry test");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("1 passed; 0 failed"),
|
||||
"registry child must execute exactly once: {stdout}{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_covers_both_queues_and_contention_without_consuming() {
|
||||
if isolated("event_presence_covers_both_queues_and_contention_without_consuming") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let anchor = MrfDurableRepairAnchor {
|
||||
kind: MrfKind::PartialWrite,
|
||||
bucket: Arc::from("presence-test"),
|
||||
object: Arc::from("object"),
|
||||
version_id: Some([7; 16]),
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
}),
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(21),
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
};
|
||||
let event = MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: None,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::VerifiedHealthy,
|
||||
};
|
||||
note_mrf_verified_repair(event.clone());
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "the hint must not consume a proof");
|
||||
assert_eq!(take_mrf_verified_repair_events_for(&anchor.bucket), vec![event]);
|
||||
assert!(
|
||||
mrf_durable_repair_events_may_be_pending(),
|
||||
"the empty allocated verified buffer still needs its original maintenance pass"
|
||||
);
|
||||
let mut retained = vec![anchor.clone()];
|
||||
assert_eq!(consume_recorded_verified_mrf_repair_events_for(&anchor.bucket, &mut retained), 0);
|
||||
assert_eq!(retained, vec![anchor.clone()], "buffer maintenance cannot discharge an anchor");
|
||||
assert_eq!(
|
||||
MRF_VERIFIED_REPAIR_EVENTS
|
||||
.get()
|
||||
.expect("verified registry")
|
||||
.lock()
|
||||
.expect("verified storage")
|
||||
.capacity(),
|
||||
0,
|
||||
"the actual consumer releases empty proof storage before subsequent idle passes can skip it"
|
||||
);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let legacy = MrfUnverifiedLegacyEvent { anchor };
|
||||
note_mrf_unverified_legacy(legacy.clone());
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "legacy-only work must not be skipped");
|
||||
assert_eq!(take_mrf_unverified_legacy_events_for(&legacy.anchor.bucket), vec![legacy]);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let verified = MRF_VERIFIED_REPAIR_EVENTS.get().expect("verified registry initialized");
|
||||
let guard = verified.lock().expect("hold verified registry");
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "contended verified is unknown");
|
||||
drop(guard);
|
||||
let legacy = MRF_UNVERIFIED_LEGACY_EVENTS.get().expect("legacy registry initialized");
|
||||
let guard = legacy.lock().expect("hold legacy registry");
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "contended legacy is unknown");
|
||||
drop(guard);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_keeps_poisoned_verified_unknown() {
|
||||
if isolated("event_presence_keeps_poisoned_verified_unknown") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let registry = MRF_VERIFIED_REPAIR_EVENTS.get_or_init(|| Mutex::new(Default::default()));
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _guard = registry.lock().expect("unpoisoned registry");
|
||||
panic!("poison the isolated verified registry");
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_keeps_poisoned_legacy_unknown() {
|
||||
if isolated("event_presence_keeps_poisoned_legacy_unknown") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let registry = MRF_UNVERIFIED_LEGACY_EVENTS.get_or_init(|| Mutex::new(Default::default()));
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _guard = registry.lock().expect("unpoisoned registry");
|
||||
panic!("poison the isolated legacy registry");
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
fn proof_fixture() -> (MrfDurableRepairAnchor, MrfVerifiedRepairEvent) {
|
||||
let anchor = MrfDurableRepairAnchor {
|
||||
kind: MrfKind::PartialWrite,
|
||||
bucket: Arc::from("proof-queue"),
|
||||
object: Arc::from("matched"),
|
||||
version_id: Some([7; 16]),
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 1,
|
||||
set_index: 2,
|
||||
}),
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(21),
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
};
|
||||
let event = MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: anchor.delete_marker_purge,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||
};
|
||||
(anchor, event)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_reuses_unmatched_storage_in_contiguous_and_wrapped_layouts() {
|
||||
for wrapped in [false, true] {
|
||||
for foreign in [false, true] {
|
||||
let (anchor, mut event) = proof_fixture();
|
||||
if foreign {
|
||||
event.bucket = Arc::from("another-bucket");
|
||||
} else {
|
||||
event.lease = None;
|
||||
}
|
||||
let mut events = VecDeque::with_capacity(8);
|
||||
events.extend(std::iter::repeat_n(event, events.capacity()));
|
||||
if wrapped {
|
||||
let first = events.pop_front().expect("full local proof queue");
|
||||
events.push_back(first);
|
||||
assert!(!events.as_slices().1.is_empty(), "exercise a wrapped producer queue");
|
||||
}
|
||||
let capacity = events.capacity();
|
||||
let storage = events
|
||||
.iter()
|
||||
.map(std::ptr::from_ref)
|
||||
.min()
|
||||
.expect("nonempty local proof storage");
|
||||
let expected = events.clone();
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
for _ in 0..4 {
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(events, expected);
|
||||
assert_eq!(anchors, vec![anchor.clone()]);
|
||||
assert_eq!(events.capacity(), capacity);
|
||||
assert_eq!(
|
||||
events.iter().map(std::ptr::from_ref).min(),
|
||||
Some(storage),
|
||||
"a full unmatched queue must reuse its buffer instead of rebuilding it for every bucket"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_consumes_all_exact_anchor_copies_and_preserves_duplicate_proof_order() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let waiting = MrfDurableRepairAnchor {
|
||||
object: Arc::from("waiting"),
|
||||
..anchor.clone()
|
||||
};
|
||||
let foreign = MrfVerifiedRepairEvent {
|
||||
bucket: Arc::from("foreign"),
|
||||
..event.clone()
|
||||
};
|
||||
let unleased = MrfVerifiedRepairEvent {
|
||||
lease: None,
|
||||
..event.clone()
|
||||
};
|
||||
let future = MrfVerifiedRepairEvent {
|
||||
object: Arc::from("future"),
|
||||
..event.clone()
|
||||
};
|
||||
let mut events = VecDeque::from(vec![
|
||||
foreign.clone(),
|
||||
event.clone(),
|
||||
unleased.clone(),
|
||||
event.clone(),
|
||||
future.clone(),
|
||||
]);
|
||||
let first = events.pop_front().expect("foreign proof starts the queue");
|
||||
events.push_back(first);
|
||||
assert!(!events.as_slices().1.is_empty(), "match and retain proofs across the wrap boundary");
|
||||
let mut anchors = vec![anchor.clone(), waiting.clone(), anchor.clone()];
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
2
|
||||
);
|
||||
assert_eq!(anchors, vec![waiting]);
|
||||
assert_eq!(
|
||||
events,
|
||||
VecDeque::from(vec![unleased, event, future, foreign]),
|
||||
"the later duplicate proof remains available after the first proof consumes every exact anchor"
|
||||
);
|
||||
let retained = events.clone();
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(events, retained, "a later consumer must still be able to match the retained proofs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_keeps_every_incomplete_or_mismatched_identity() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let different_purge = MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_identity: [3; 32],
|
||||
};
|
||||
for rejected in [
|
||||
MrfVerifiedRepairEvent {
|
||||
kind: MrfKind::DecodeFailure,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
bucket: Arc::from("foreign"),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
object: Arc::from("other"),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
version_id: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
version_id: Some([8; 16]),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 2,
|
||||
set_index: 2,
|
||||
}),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 1,
|
||||
set_index: 3,
|
||||
}),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
lease: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
lease: Some(MrfIngressLease::new(22)),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
delete_marker_purge: Some(different_purge),
|
||||
..event
|
||||
},
|
||||
] {
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
let mut events = VecDeque::from([rejected.clone()]);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(anchors, vec![anchor.clone()]);
|
||||
assert_eq!(events, VecDeque::from([rejected]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_requires_the_complete_delete_marker_purge_identity() {
|
||||
let (mut anchor, mut event) = proof_fixture();
|
||||
let purge = MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_identity: [3; 32],
|
||||
};
|
||||
anchor.kind = MrfKind::DeleteMarkerPurge;
|
||||
anchor.delete_marker_purge = Some(purge);
|
||||
event.kind = anchor.kind;
|
||||
event.delete_marker_purge = anchor.delete_marker_purge;
|
||||
for rejected in [
|
||||
None,
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
..purge
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
marker_incarnation_id: Uuid::new_v4(),
|
||||
..purge
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
marker_identity: [4; 32],
|
||||
..purge
|
||||
}),
|
||||
] {
|
||||
let rejected = MrfVerifiedRepairEvent {
|
||||
delete_marker_purge: rejected,
|
||||
..event.clone()
|
||||
};
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
let mut events = VecDeque::from([rejected.clone(), event.clone()]);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
1
|
||||
);
|
||||
assert!(anchors.is_empty());
|
||||
assert_eq!(events, VecDeque::from([rejected]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_capacity_follows_the_preconsumption_length_and_releases_on_the_next_empty_call() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let future_events = (0..3)
|
||||
.map(|index| MrfVerifiedRepairEvent {
|
||||
object: Arc::from(format!("future-{index}")),
|
||||
..event.clone()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut events = VecDeque::with_capacity(64);
|
||||
events.push_back(event);
|
||||
events.extend(future_events.iter().cloned());
|
||||
let previous_event_count = events.len();
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
1
|
||||
);
|
||||
assert_eq!(events.iter().cloned().collect::<Vec<_>>(), future_events);
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity(),
|
||||
"preserve the former queue rebuild's capacity lifetime instead of retaining the producer high water"
|
||||
);
|
||||
let previous_event_count = events.len();
|
||||
let mut anchors = future_events
|
||||
.iter()
|
||||
.map(|event| MrfDurableRepairAnchor {
|
||||
object: event.object.clone(),
|
||||
..anchor.clone()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
3
|
||||
);
|
||||
assert!(events.is_empty());
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity()
|
||||
);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
0,
|
||||
"the next empty consumption releases backing storage just as a rebuild did"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
|
||||
// Compatibility reference from a47649fb60c0492cc0fb02bff28439c5e357f8e4.
|
||||
// Keep this independent of the shared resolver and its canonical helpers.
|
||||
fn original_anchor(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<MrfDurableRepairAnchor> {
|
||||
let lease = intent.lease?;
|
||||
let version_id = intent.version_id.filter(|bytes| *bytes != [0; 16]);
|
||||
let (version_id, scope) = match intent.kind {
|
||||
MrfKind::MetadataCorruption => (None, None),
|
||||
MrfKind::DecodeFailure | MrfKind::PartialWrite | MrfKind::DeleteMarkerPurge => (version_id, intent.scope),
|
||||
};
|
||||
let delete_marker_purge = match intent.kind {
|
||||
MrfKind::DeleteMarkerPurge => intent
|
||||
.delete_marker_purge
|
||||
.as_ref()
|
||||
.filter(|payload| {
|
||||
!payload.bucket_incarnation_id.is_nil()
|
||||
&& payload.marker_incarnation_id == payload.bucket_incarnation_id
|
||||
&& !payload.marker.is_empty()
|
||||
&& payload.marker.len() <= MRF_MAX_DELETE_MARKER_BYTES
|
||||
})
|
||||
.map(|payload| {
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: payload.bucket_incarnation_id,
|
||||
marker_incarnation_id: payload.marker_incarnation_id,
|
||||
marker_identity: payload.marker_identity,
|
||||
})
|
||||
})?,
|
||||
_ if intent.delete_marker_purge.is_none() => None,
|
||||
_ => return None,
|
||||
};
|
||||
let bucket_incarnation_id = delete_marker_purge
|
||||
.map(|identity| identity.bucket_incarnation_id)
|
||||
.unwrap_or(bucket_incarnation_id);
|
||||
if bucket_incarnation_id.is_nil() {
|
||||
return None;
|
||||
}
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
version_id,
|
||||
scope,
|
||||
delete_marker_purge,
|
||||
lease,
|
||||
bucket_incarnation_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_repair_identity_matches_the_original_constructor_and_source_filter() {
|
||||
let source = Uuid::from_u128(1);
|
||||
let foreign = Uuid::from_u128(2);
|
||||
let purge = MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: source,
|
||||
marker_incarnation_id: source,
|
||||
marker_identity: [9; 32],
|
||||
marker: Arc::from([7_u8]),
|
||||
};
|
||||
let payloads = [
|
||||
None,
|
||||
Some(purge.clone()),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: foreign,
|
||||
marker_incarnation_id: foreign,
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from([]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES + 1]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker_incarnation_id: foreign,
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: Uuid::nil(),
|
||||
marker_incarnation_id: Uuid::nil(),
|
||||
..purge
|
||||
}),
|
||||
];
|
||||
let mut cases = 0;
|
||||
for kind in [
|
||||
MrfKind::DecodeFailure,
|
||||
MrfKind::MetadataCorruption,
|
||||
MrfKind::PartialWrite,
|
||||
MrfKind::DeleteMarkerPurge,
|
||||
] {
|
||||
for lease in [None, Some(MrfIngressLease::new(0)), Some(MrfIngressLease::new(7))] {
|
||||
for version_id in [None, Some([0; 16]), Some([7; 16])] {
|
||||
for scope in [
|
||||
None,
|
||||
Some(MrfScope {
|
||||
pool_index: u32::MAX,
|
||||
set_index: 3,
|
||||
}),
|
||||
] {
|
||||
for input_source in [Uuid::nil(), source, foreign] {
|
||||
for (payload_index, payload) in payloads.iter().enumerate() {
|
||||
let intent = MrfIntent {
|
||||
bucket: Arc::from("source-shape"),
|
||||
object: Arc::from("object"),
|
||||
version_id,
|
||||
kind,
|
||||
delete_marker_purge: payload.clone(),
|
||||
scope,
|
||||
lease,
|
||||
enqueued_at_ms: 0,
|
||||
attempts: 0,
|
||||
};
|
||||
let expected = original_anchor(&intent, input_source);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, input_source),
|
||||
expected,
|
||||
"kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
|
||||
);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::is_eligible_for_source(&intent, input_source),
|
||||
expected
|
||||
.as_ref()
|
||||
.is_some_and(|anchor| anchor.bucket_incarnation_id == input_source),
|
||||
"source eligibility must preserve the exact source: kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
|
||||
);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(cases, 1728, "exercise the complete explicit identity-shape matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_identity_preserves_literal_purge_source_lease_and_metadata_boundaries() {
|
||||
let source = Uuid::from_u128(1);
|
||||
let purge_source = Uuid::from_u128(2);
|
||||
let mut intent = MrfIntent {
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
kind: MrfKind::DeleteMarkerPurge,
|
||||
delete_marker_purge: Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: purge_source,
|
||||
marker_incarnation_id: purge_source,
|
||||
marker_identity: [9; 32],
|
||||
marker: Arc::from([7_u8]),
|
||||
}),
|
||||
scope: None,
|
||||
lease: Some(MrfIngressLease::new(17)),
|
||||
enqueued_at_ms: 0,
|
||||
attempts: 0,
|
||||
};
|
||||
assert!(
|
||||
!valid_intent_payload(&intent),
|
||||
"constructor compatibility is broader than durable admission"
|
||||
);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()),
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: MrfKind::DeleteMarkerPurge,
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
scope: None,
|
||||
delete_marker_purge: Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: purge_source,
|
||||
marker_incarnation_id: purge_source,
|
||||
marker_identity: [9; 32],
|
||||
}),
|
||||
lease: MrfIngressLease::new(17),
|
||||
bucket_incarnation_id: purge_source,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
|
||||
"a purge cannot borrow a conflicting entry source"
|
||||
);
|
||||
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, Uuid::nil()));
|
||||
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
|
||||
intent.lease = None;
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
|
||||
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
|
||||
intent.lease = Some(MrfIngressLease::new(17));
|
||||
intent
|
||||
.delete_marker_purge
|
||||
.as_mut()
|
||||
.expect("purge fixture")
|
||||
.marker_incarnation_id = Uuid::nil();
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
|
||||
intent
|
||||
.delete_marker_purge
|
||||
.as_mut()
|
||||
.expect("purge fixture")
|
||||
.bucket_incarnation_id = Uuid::nil();
|
||||
assert!(
|
||||
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
|
||||
"nil marker identity cannot borrow a non-nil source"
|
||||
);
|
||||
intent.kind = MrfKind::PartialWrite;
|
||||
assert!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source).is_none(),
|
||||
"ordinary intents cannot carry a purge payload"
|
||||
);
|
||||
intent.delete_marker_purge = None;
|
||||
intent.kind = MrfKind::MetadataCorruption;
|
||||
intent.version_id = Some([7; 16]);
|
||||
intent.scope = Some(MrfScope {
|
||||
pool_index: 8,
|
||||
set_index: 9,
|
||||
});
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source),
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: MrfKind::MetadataCorruption,
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
scope: None,
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(17),
|
||||
bucket_incarnation_id: source,
|
||||
})
|
||||
);
|
||||
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, source));
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()).is_none());
|
||||
intent.lease = Some(MrfIngressLease::new(0));
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source)
|
||||
.expect("preserve the existing opaque lease shape")
|
||||
.lease,
|
||||
MrfIngressLease::new(0)
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ rustfs-io-core = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread"] }
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time"] }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,957 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
//! Mutable admission capacity without replacing or revoking held permits.
|
||||
|
||||
use std::num::NonZeroU16;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Notify, Semaphore};
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// A resource owner publishes a capacity; running units retain their permits
|
||||
/// after a reduction. Lease slots are reserved once, with no per-unit Arc
|
||||
/// allocation. Tokio wait queues may lazily initialize platform mutex storage
|
||||
/// on first contention. It does not discover devices, start
|
||||
/// workers, or decide resource policy. Cancellation drops the acquire future.
|
||||
///
|
||||
/// Uncontended admission is atomic. Parked work takes a FIFO admission turn,
|
||||
/// so repeated fast acquisitions cannot starve an older waiter. Source/age
|
||||
/// priority still belongs to the caller's scheduler, before joining this queue.
|
||||
pub struct AdaptiveAdmission {
|
||||
// One CAS observes both capacity and active count, so a concurrent shrink
|
||||
// cannot admit a new unit using an already superseded capacity.
|
||||
state: AtomicU64,
|
||||
origin: Instant,
|
||||
valid_until_millis: AtomicU64,
|
||||
// Only the FIFO turn holder subscribes. A stored notification is harmless:
|
||||
// capacity and freshness are always checked again after waking.
|
||||
changed: Notify,
|
||||
queued: AtomicUsize,
|
||||
// FIFO admission turn, not a mutable-state lock. No storage lock or
|
||||
// admitted permit may be held while joining. This private semaphore is
|
||||
// never closed; release and resize do not acquire it.
|
||||
turn: Semaphore,
|
||||
maximum: u32,
|
||||
slots: Box<[PermitSlots]>,
|
||||
next_chunk: AtomicUsize,
|
||||
}
|
||||
|
||||
struct PermitSlots {
|
||||
free: AtomicU64,
|
||||
references: [AtomicUsize; 64],
|
||||
}
|
||||
|
||||
struct QueuedAdmission<'a>(&'a AdaptiveAdmission);
|
||||
|
||||
impl Drop for QueuedAdmission<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.queued.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// One active unit. Clones share that unit without allocating; the final drop
|
||||
/// returns it to the same owner, including after resize or waiter cancellation.
|
||||
#[must_use = "hold the permit for the admitted unit's complete lifetime"]
|
||||
pub struct AdaptivePermit {
|
||||
owner: Arc<AdaptiveAdmission>,
|
||||
slot: usize,
|
||||
}
|
||||
|
||||
impl Clone for AdaptivePermit {
|
||||
fn clone(&self) -> Self {
|
||||
// Every slot reference owns a strong owner reference. Clone the Arc
|
||||
// first, so its overflow protection also bounds the slot refcount.
|
||||
let owner = Arc::clone(&self.owner);
|
||||
owner.slots[self.slot / 64].references[self.slot % 64].fetch_add(1, Ordering::Relaxed);
|
||||
Self { owner, slot: self.slot }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdaptivePermit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AdaptivePermit")
|
||||
.field("slot", &self.slot)
|
||||
.field("owner", &self.owner.snapshot())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// One coherent view of configured capacity and actual active units.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AdaptiveAdmissionSnapshot {
|
||||
/// Current cap on new admissions. Zero pauses all new units.
|
||||
pub limit: u32,
|
||||
/// Existing units, which may temporarily exceed a reduced limit.
|
||||
pub active: u32,
|
||||
}
|
||||
|
||||
/// Node scheduling capacity, separate from physical I/O and buffer ownership.
|
||||
/// Its non-cloneable units end with the async operation; a native tail must
|
||||
/// retain its own physical-resource unit, never this scheduling unit.
|
||||
pub struct NodeAdmission {
|
||||
capacity: Arc<AdaptiveAdmission>,
|
||||
completed: AtomicU64,
|
||||
}
|
||||
|
||||
/// One async operation's scheduling unit. Dropping a cancelled or failed
|
||||
/// operation releases capacity without claiming completed work.
|
||||
#[must_use = "retain the unit until the async operation ends"]
|
||||
pub struct NodePermit {
|
||||
_capacity: AdaptivePermit,
|
||||
owner: Arc<NodeAdmission>,
|
||||
}
|
||||
|
||||
impl NodeAdmission {
|
||||
/// Construct one node owner with no capacity until a resource sample arrives.
|
||||
pub fn new(maximum: NonZeroU16) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
capacity: AdaptiveAdmission::new(maximum, 0),
|
||||
completed: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish feedback with this owner's actual completed-work counter.
|
||||
pub fn update(&self, controller: &mut FeedbackController, observation: Option<FeedbackObservation>) -> FeedbackDecision {
|
||||
let observation = observation.map(|mut observation| {
|
||||
observation.completed_units = self.completed();
|
||||
observation
|
||||
});
|
||||
controller.update(&self.capacity, observation)
|
||||
}
|
||||
|
||||
/// Current leased scheduling cap and held async units.
|
||||
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
|
||||
self.capacity.snapshot()
|
||||
}
|
||||
|
||||
/// Completed operations, or unknown after the counter exhausts its range.
|
||||
pub fn completed(&self) -> Option<u64> {
|
||||
let value = self.completed.load(Ordering::Acquire);
|
||||
(value != u64::MAX).then_some(value)
|
||||
}
|
||||
|
||||
/// Try without waiting, including when the caller already owns a lock.
|
||||
pub fn try_acquire(self: &Arc<Self>) -> Option<NodePermit> {
|
||||
self.capacity.try_acquire().map(|capacity| NodePermit {
|
||||
_capacity: capacity,
|
||||
owner: self.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait in FIFO order. Callers must provide a deadline/cancellation and
|
||||
/// acquire before storage locks, not while retaining another node unit.
|
||||
pub async fn acquire(self: &Arc<Self>) -> NodePermit {
|
||||
NodePermit {
|
||||
_capacity: self.capacity.acquire().await,
|
||||
owner: self.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodePermit {
|
||||
/// Count a completed operation exactly once, then release its node unit.
|
||||
/// This counter is scheduling progress, not an authoritative heal receipt.
|
||||
pub fn complete(self) {
|
||||
let _ = self
|
||||
.owner
|
||||
.completed
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |value| value.checked_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Confidence-qualified pressure for one complete sampling window.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourcePressure {
|
||||
/// Missing, stale, reset, or insufficient observations; never evidence of idle.
|
||||
Unknown,
|
||||
/// A resource or foreground-protection threshold was reached.
|
||||
Busy,
|
||||
/// All required observations were fresh and below their low watermarks.
|
||||
Stable,
|
||||
}
|
||||
|
||||
/// Resource-owner input to the controller, after applying operator and kernel
|
||||
/// caps. This describes one owner; callers must not duplicate a device's budget
|
||||
/// for aliases, pools, or remote requesters.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FeedbackObservation {
|
||||
/// Identity of the resource/telemetry generation. Replacement resets probes.
|
||||
pub generation: u64,
|
||||
/// Upper bound imposed by resources and explicit configuration; zero blocks.
|
||||
pub max_concurrency: u32,
|
||||
/// Complete-window pressure, including the confidence of its sources.
|
||||
pub pressure: ResourcePressure,
|
||||
/// Cumulative completed work in this generation, not just scheduled work.
|
||||
pub completed_units: Option<u64>,
|
||||
}
|
||||
|
||||
/// Why the latest concurrency limit was selected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FeedbackReason {
|
||||
/// The resource cap is unavailable; no new unit can be bounded safely.
|
||||
Unavailable,
|
||||
/// A changed owner or reset work counter requires a new baseline.
|
||||
NewBaseline,
|
||||
/// The resource or operator cap reduced the permitted concurrency.
|
||||
CapReduced,
|
||||
/// Pressure caused an immediate multiplicative reduction.
|
||||
Pressure,
|
||||
/// Incomplete telemetry retains at most one maintenance unit.
|
||||
LowConfidence,
|
||||
/// Waiting for consecutive stable windows, work progress and cooldown.
|
||||
Recovering,
|
||||
/// One additional unit is being probed after a stable recovery period.
|
||||
Probe,
|
||||
}
|
||||
|
||||
/// One controller decision, suitable for metrics without dynamic labels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FeedbackDecision {
|
||||
/// New admissions use this cap; existing permits remain valid.
|
||||
pub limit: u32,
|
||||
/// Decision cause, distinct from a proof of work completion or storage health.
|
||||
pub reason: FeedbackReason,
|
||||
}
|
||||
|
||||
/// Single-owner additive probe/multiplicative decrease controller. It adjusts
|
||||
/// only concurrency. Every source must be fresh before declaring `Stable`.
|
||||
///
|
||||
/// The sampler runs every second. A gap over two seconds resets recovery; eight
|
||||
/// consecutive stable seconds and observed completed work allow one extra unit.
|
||||
/// Pressure or lower caps apply immediately. This policy is not a performance
|
||||
/// claim; its parameters require the same workload/foreground-SLO acceptance.
|
||||
pub struct FeedbackController {
|
||||
previous: Option<(Instant, FeedbackObservation)>,
|
||||
stable_since: Option<Instant>,
|
||||
last_change: Instant,
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
impl Default for FeedbackController {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FeedbackController {
|
||||
/// Start without a resource observation or an admission grant.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
previous: None,
|
||||
stable_since: None,
|
||||
last_change: Instant::now(),
|
||||
limit: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume one sample and publish a three-second lease to the same gate.
|
||||
/// If this function stops being called, admission expires independently.
|
||||
pub fn update(&mut self, gate: &AdaptiveAdmission, observation: Option<FeedbackObservation>) -> FeedbackDecision {
|
||||
let observation = observation.map(|mut observation| {
|
||||
observation.max_concurrency = observation.max_concurrency.min(gate.maximum);
|
||||
observation
|
||||
});
|
||||
let decision = self.decide(Instant::now(), observation);
|
||||
gate.set_sampled_limit(decision.limit, Duration::from_secs(3));
|
||||
decision
|
||||
}
|
||||
|
||||
fn decide(&mut self, now: Instant, observation: Option<FeedbackObservation>) -> FeedbackDecision {
|
||||
let Some(observation) = observation else {
|
||||
self.previous = None;
|
||||
self.stable_since = None;
|
||||
self.last_change = now;
|
||||
self.limit = 0;
|
||||
return FeedbackDecision {
|
||||
limit: 0,
|
||||
reason: FeedbackReason::Unavailable,
|
||||
};
|
||||
};
|
||||
let previous = self.previous.replace((now, observation));
|
||||
let contiguous = previous.is_some_and(|(at, old)| {
|
||||
now.saturating_duration_since(at) <= Duration::from_secs(2)
|
||||
&& old.generation == observation.generation
|
||||
&& matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b >= a)
|
||||
});
|
||||
let advanced = previous
|
||||
.is_some_and(|(_, old)| matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b > a));
|
||||
let old_limit = self.limit;
|
||||
let reason = if observation.max_concurrency == 0 {
|
||||
self.limit = 0;
|
||||
self.stable_since = None;
|
||||
FeedbackReason::CapReduced
|
||||
} else if observation.pressure == ResourcePressure::Busy {
|
||||
self.limit = (self.limit / 2).max(1).min(observation.max_concurrency);
|
||||
self.stable_since = None;
|
||||
// Repeated busy windows restart cooldown, even at the minimum cap.
|
||||
self.last_change = now;
|
||||
FeedbackReason::Pressure
|
||||
} else if observation.max_concurrency < self.limit {
|
||||
self.limit = observation.max_concurrency;
|
||||
self.stable_since = None;
|
||||
FeedbackReason::CapReduced
|
||||
} else if observation.pressure == ResourcePressure::Unknown || observation.completed_units.is_none() {
|
||||
self.limit = 1.min(observation.max_concurrency);
|
||||
self.stable_since = None;
|
||||
FeedbackReason::LowConfidence
|
||||
} else if !contiguous || self.limit == 0 {
|
||||
self.limit = 1.min(observation.max_concurrency);
|
||||
self.stable_since = None;
|
||||
self.last_change = now;
|
||||
FeedbackReason::NewBaseline
|
||||
} else {
|
||||
let since = *self.stable_since.get_or_insert(now);
|
||||
if advanced
|
||||
&& now.saturating_duration_since(since) >= Duration::from_secs(8)
|
||||
&& now.saturating_duration_since(self.last_change) >= Duration::from_secs(8)
|
||||
&& self.limit < observation.max_concurrency
|
||||
{
|
||||
self.limit += 1;
|
||||
FeedbackReason::Probe
|
||||
} else {
|
||||
FeedbackReason::Recovering
|
||||
}
|
||||
};
|
||||
if old_limit != self.limit {
|
||||
self.last_change = now;
|
||||
}
|
||||
FeedbackDecision {
|
||||
limit: self.limit,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdaptiveAdmission {
|
||||
/// Construct a bounded owner, with zero initial capacity allowed for unknown
|
||||
/// input. The immutable maximum reserves at most 65,535 lease slots once;
|
||||
/// acquiring or cloning a permit does not allocate a per-unit Arc.
|
||||
pub fn new(maximum: NonZeroU16, limit: u32) -> Arc<Self> {
|
||||
let maximum_u32 = u32::from(maximum.get());
|
||||
let maximum = usize::from(maximum.get());
|
||||
let slots = (0..maximum.div_ceil(64))
|
||||
.map(|chunk| {
|
||||
let count = (maximum - chunk * 64).min(64);
|
||||
PermitSlots {
|
||||
free: AtomicU64::new(if count == 64 { u64::MAX } else { (1_u64 << count) - 1 }),
|
||||
references: std::array::from_fn(|_| AtomicUsize::new(0)),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice();
|
||||
Arc::new(Self {
|
||||
state: AtomicU64::new(u64::from(limit.min(maximum_u32)) << 32),
|
||||
origin: Instant::now(),
|
||||
valid_until_millis: AtomicU64::new(u64::MAX),
|
||||
changed: Notify::new(),
|
||||
queued: AtomicUsize::new(0),
|
||||
turn: Semaphore::new(1),
|
||||
maximum: maximum_u32,
|
||||
slots,
|
||||
next_chunk: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a cap bounded by the immutable maximum, without losing held work
|
||||
/// or changing owner identity.
|
||||
pub fn set_limit(&self, limit: u32) {
|
||||
let limit = limit.min(self.maximum);
|
||||
let _ = self.state.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
|
||||
Some((u64::from(limit) << 32) | (state & u64::from(u32::MAX)))
|
||||
});
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
/// Publish a cap with a finite observation lifetime. Expired observations
|
||||
/// block new admissions even if the sampling worker has stopped. Existing
|
||||
/// permits are retained. A single resource owner must serialize publishers.
|
||||
///
|
||||
/// Capacity is published before freshness, so a concurrent reader can
|
||||
/// temporarily reject a fresh update but cannot use an increased lifetime
|
||||
/// with the previous, potentially larger capacity.
|
||||
pub fn set_sampled_limit(&self, limit: u32, valid_for: Duration) {
|
||||
self.set_limit(limit);
|
||||
let deadline = self
|
||||
.elapsed_millis()
|
||||
.saturating_add(u64::try_from(valid_for.as_millis()).unwrap_or(u64::MAX));
|
||||
self.valid_until_millis.store(deadline, Ordering::Release);
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
fn elapsed_millis(&self) -> u64 {
|
||||
u64::try_from(self.origin.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn fresh(&self) -> bool {
|
||||
self.elapsed_millis() < self.valid_until_millis.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Read the cap and active count from the same atomic observation.
|
||||
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
|
||||
// Acquire freshness first: a newly published lease must not be paired
|
||||
// with capacity loaded before its preceding reduction.
|
||||
let fresh = self.fresh();
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
AdaptiveAdmissionSnapshot {
|
||||
limit: if fresh {
|
||||
u32::try_from(state >> 32).unwrap_or(u32::MAX)
|
||||
} else {
|
||||
0
|
||||
},
|
||||
active: u32::try_from(state & u64::from(u32::MAX)).unwrap_or(u32::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit immediately if capacity remains, without a per-unit allocation.
|
||||
/// The uncontended path uses atomics; refunding a racing acquisition may
|
||||
/// wake the FIFO head through Tokio's notification queue.
|
||||
pub fn try_acquire(self: &Arc<Self>) -> Option<AdaptivePermit> {
|
||||
if self.queued.load(Ordering::Acquire) != 0 {
|
||||
return None;
|
||||
}
|
||||
let permit = self.acquire_capacity()?;
|
||||
// A waiter can register between the first queue check and the capacity
|
||||
// CAS. Refund before exposing the permit rather than stealing its turn.
|
||||
(self.queued.load(Ordering::Acquire) == 0).then_some(permit)
|
||||
}
|
||||
|
||||
fn acquire_capacity(self: &Arc<Self>) -> Option<AdaptivePermit> {
|
||||
if !self.fresh() {
|
||||
return None;
|
||||
}
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
if state & u64::from(u32::MAX) >= state >> 32 {
|
||||
return None;
|
||||
}
|
||||
let slot = self.reserve_slot()?;
|
||||
if self
|
||||
.state
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
|
||||
let active = state & u64::from(u32::MAX);
|
||||
(active < state >> 32).then(|| state + 1)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
self.release_slot(slot);
|
||||
self.changed.notify_one();
|
||||
return None;
|
||||
}
|
||||
self.slots[slot / 64].references[slot % 64].store(1, Ordering::Release);
|
||||
let permit = AdaptivePermit {
|
||||
owner: self.clone(),
|
||||
slot,
|
||||
};
|
||||
// The capacity CAS can retry across an observation expiry. Recheck
|
||||
// before giving ownership to the caller; dropping refunds this unit.
|
||||
self.fresh().then_some(permit)
|
||||
}
|
||||
|
||||
fn reserve_slot(&self) -> Option<usize> {
|
||||
let first = self.next_chunk.fetch_add(1, Ordering::Relaxed) % self.slots.len();
|
||||
for offset in 0..self.slots.len() {
|
||||
let chunk_index = (first + offset) % self.slots.len();
|
||||
let free = &self.slots[chunk_index].free;
|
||||
if let Ok(bits) = free.try_update(Ordering::AcqRel, Ordering::Acquire, |bits| (bits != 0).then(|| bits & (bits - 1)))
|
||||
{
|
||||
// trailing_zeros is in 0..64 because the zero word is rejected.
|
||||
return Some(chunk_index * 64 + bits.trailing_zeros() as usize);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn release_slot(&self, slot: usize) {
|
||||
self.slots[slot / 64].free.fetch_or(1_u64 << (slot % 64), Ordering::Release);
|
||||
}
|
||||
|
||||
/// Wait for capacity. The future owns no permit until it returns; dropping
|
||||
/// a pending future leaves capacity untouched. Wrap in the caller's existing
|
||||
/// cancellation/deadline select before acquiring any storage commit locks.
|
||||
pub async fn acquire(self: &Arc<Self>) -> AdaptivePermit {
|
||||
if let Some(permit) = self.try_acquire() {
|
||||
return permit;
|
||||
}
|
||||
self.queued.fetch_add(1, Ordering::AcqRel);
|
||||
let _queued = QueuedAdmission(self);
|
||||
// Retain the successful permit inside its Result. No code can close
|
||||
// this private semaphore, so acquisition cannot return an error.
|
||||
let _turn = self.turn.acquire().await;
|
||||
loop {
|
||||
let notified = self.changed.notified();
|
||||
tokio::pin!(notified);
|
||||
// Register before checking capacity to avoid a lost release/resize.
|
||||
notified.as_mut().enable();
|
||||
if let Some(permit) = self.acquire_capacity() {
|
||||
return permit;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AdaptivePermit {
|
||||
fn drop(&mut self) {
|
||||
if self.owner.slots[self.slot / 64].references[self.slot % 64].fetch_sub(1, Ordering::AcqRel) != 1 {
|
||||
return;
|
||||
}
|
||||
// Publish the free slot before exposing another admission capacity.
|
||||
self.owner.release_slot(self.slot);
|
||||
self.owner.state.fetch_sub(1, Ordering::AcqRel);
|
||||
self.owner.changed.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_progress_requires_explicit_completion_and_never_wraps() {
|
||||
let node = NodeAdmission::new(NonZeroU16::new(2).expect("two slots"));
|
||||
node.capacity.set_limit(2);
|
||||
drop(node.acquire().await);
|
||||
assert_eq!(node.completed(), Some(0), "cancellation is not progress");
|
||||
node.acquire().await.complete();
|
||||
assert_eq!(node.completed(), Some(1));
|
||||
node.completed.store(u64::MAX - 1, Ordering::Release);
|
||||
node.acquire().await.complete();
|
||||
node.acquire().await.complete();
|
||||
assert_eq!(node.completed(), None, "exhausted progress stays unknown");
|
||||
assert_eq!(node.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_node_work_does_not_reserve_capacity_for_unrelated_native_tail() {
|
||||
let node = NodeAdmission::new(NonZeroU16::new(1).expect("one slot"));
|
||||
node.capacity.set_limit(1);
|
||||
let physical = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one disk unit"), 1);
|
||||
let scheduling = node.acquire().await;
|
||||
let native_tail = physical.acquire().await;
|
||||
drop(scheduling);
|
||||
assert_eq!(node.completed(), Some(0));
|
||||
assert!(node.try_acquire().is_some(), "healthy resources can still be scheduled");
|
||||
assert!(physical.try_acquire().is_none(), "the stalled disk remains charged");
|
||||
drop(native_tail);
|
||||
assert!(physical.try_acquire().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn node_feedback_expires_without_revoking_held_work_or_fabricating_progress() {
|
||||
let node = NodeAdmission::new(NonZeroU16::new(2).expect("two slots"));
|
||||
let mut controller = FeedbackController::new();
|
||||
let input = FeedbackObservation {
|
||||
generation: 1,
|
||||
max_concurrency: 2,
|
||||
pressure: ResourcePressure::Stable,
|
||||
completed_units: Some(99),
|
||||
};
|
||||
node.update(&mut controller, Some(input));
|
||||
let held = node.acquire().await;
|
||||
assert_eq!(node.completed(), Some(0));
|
||||
tokio::time::advance(Duration::from_secs(4)).await;
|
||||
assert_eq!(node.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
|
||||
assert!(node.try_acquire().is_none());
|
||||
held.complete();
|
||||
assert_eq!(node.completed(), Some(1));
|
||||
assert_eq!(node.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_unit_stays_admitted_until_native_worker_releases_its_last_clone() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let outer = gate.acquire().await;
|
||||
let native = outer.clone();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let worker = tokio::task::spawn_blocking(move || {
|
||||
started_tx.send(()).expect("worker started");
|
||||
release_rx.blocking_recv().expect("release native worker");
|
||||
drop(native);
|
||||
});
|
||||
started_rx.await.expect("native worker is live");
|
||||
drop(outer);
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
assert!(gate.try_acquire().is_none());
|
||||
gate.set_limit(0);
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
release_tx.send(()).expect("release tail");
|
||||
worker.await.expect("native tail completed");
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
gate.set_limit(1);
|
||||
assert!(gate.try_acquire().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_slots_cross_word_boundaries_and_recycle_only_after_final_drop() {
|
||||
let maximum = NonZeroU16::new(65).expect("partial second chunk");
|
||||
let gate = AdaptiveAdmission::new(maximum, u32::MAX);
|
||||
assert_eq!(gate.snapshot().limit, 65);
|
||||
for _ in 0..4 {
|
||||
let units: Vec<_> = (0..65).map(|_| gate.try_acquire().expect("free slot")).collect();
|
||||
let tails = units.clone();
|
||||
assert_eq!(gate.snapshot().active, 65);
|
||||
assert!(gate.try_acquire().is_none());
|
||||
drop(units);
|
||||
assert_eq!(gate.snapshot().active, 65);
|
||||
gate.set_limit(0);
|
||||
for (remaining, tail) in (0..65).rev().zip(tails) {
|
||||
drop(tail);
|
||||
assert_eq!(gate.snapshot().active, remaining);
|
||||
}
|
||||
gate.set_limit(u32::MAX);
|
||||
assert_eq!(gate.snapshot().limit, 65);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn shared_units_survive_concurrent_clone_drop_reuse_and_resize() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded owner"), 8);
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let gate = gate.clone();
|
||||
workers.push(tokio::spawn(async move {
|
||||
for _ in 0..64 {
|
||||
let root = gate.acquire().await;
|
||||
let one = root.clone();
|
||||
let two = one.clone();
|
||||
drop(root);
|
||||
tokio::task::yield_now().await;
|
||||
drop(two);
|
||||
tokio::task::yield_now().await;
|
||||
drop(one);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for limit in [0, 1, 8, 2, 0, 8] {
|
||||
gate.set_limit(limit);
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
for worker in workers {
|
||||
worker.await.expect("lease worker");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all lease workers complete");
|
||||
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
|
||||
let full: Vec<_> = (0..8).map(|_| gate.try_acquire().expect("all slots returned")).collect();
|
||||
assert!(gate.try_acquire().is_none());
|
||||
drop(full);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_work_cannot_be_starved_by_new_fast_acquisitions() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 1);
|
||||
let held = gate.acquire().await;
|
||||
let mut first = Box::pin(gate.acquire());
|
||||
let mut second = Box::pin(gate.acquire());
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(first.as_mut().poll(&mut context).is_pending());
|
||||
assert!(second.as_mut().poll(&mut context).is_pending());
|
||||
drop(held);
|
||||
assert!(gate.try_acquire().is_none(), "new work must not steal a parked worker's turn");
|
||||
assert!(second.as_mut().poll(&mut context).is_pending(), "later work cannot pass the queue head");
|
||||
let Poll::Ready(first_permit) = first.as_mut().poll(&mut context) else {
|
||||
panic!("queue head must run")
|
||||
};
|
||||
drop(first_permit);
|
||||
let Poll::Ready(second_permit) = second.as_mut().poll(&mut context) else {
|
||||
panic!("second waiter must run")
|
||||
};
|
||||
drop(second_permit);
|
||||
assert!(gate.try_acquire().is_some(), "drained queue restores the atomic fast path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_queued_work_preserves_fifo_capacity_and_fast_path() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut head = Box::pin(gate.acquire());
|
||||
let mut middle = Box::pin(gate.acquire());
|
||||
let mut tail = Box::pin(gate.acquire());
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(head.as_mut().poll(&mut context).is_pending());
|
||||
assert!(middle.as_mut().poll(&mut context).is_pending());
|
||||
assert!(tail.as_mut().poll(&mut context).is_pending());
|
||||
drop(middle);
|
||||
drop(head);
|
||||
gate.set_limit(1);
|
||||
assert!(
|
||||
gate.try_acquire().is_none(),
|
||||
"cancelled head must not expose the surviving waiter's capacity"
|
||||
);
|
||||
let Poll::Ready(permit) = tail.as_mut().poll(&mut context) else {
|
||||
panic!("surviving waiter must run")
|
||||
};
|
||||
drop(permit);
|
||||
assert!(gate.try_acquire().is_some());
|
||||
|
||||
gate.set_limit(0);
|
||||
let mut cancelled = Box::pin(gate.acquire());
|
||||
assert!(cancelled.as_mut().poll(&mut context).is_pending());
|
||||
drop(cancelled);
|
||||
gate.set_limit(1);
|
||||
assert!(
|
||||
gate.try_acquire().is_some(),
|
||||
"cancelling the final waiter must not strand an admission turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_a_notified_head_hands_capacity_to_the_next_waiter() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
|
||||
let mut head = Box::pin(gate.acquire());
|
||||
let mut next = Box::pin(gate.acquire());
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(head.as_mut().poll(&mut context).is_pending());
|
||||
assert!(next.as_mut().poll(&mut context).is_pending());
|
||||
gate.set_limit(1);
|
||||
// Cancel after notification, before the head consumes its capacity.
|
||||
drop(head);
|
||||
let Poll::Ready(permit) = next.as_mut().poll(&mut context) else {
|
||||
panic!("successor must observe capacity without another notification")
|
||||
};
|
||||
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
|
||||
drop(permit);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn one_resize_serves_multiple_fifo_turns_but_stored_wakes_cannot_bypass_expiry() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
|
||||
let mut first = Box::pin(gate.acquire());
|
||||
let mut second = Box::pin(gate.acquire());
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(first.as_mut().poll(&mut context).is_pending());
|
||||
assert!(second.as_mut().poll(&mut context).is_pending());
|
||||
gate.set_sampled_limit(2, Duration::from_secs(1));
|
||||
let Poll::Ready(one) = first.as_mut().poll(&mut context) else {
|
||||
panic!("first turn must observe resize")
|
||||
};
|
||||
let Poll::Ready(two) = second.as_mut().poll(&mut context) else {
|
||||
panic!("second turn must observe resize without releasing the first unit")
|
||||
};
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
drop(one);
|
||||
drop(two);
|
||||
let mut expired = Box::pin(gate.acquire());
|
||||
assert!(expired.as_mut().poll(&mut context).is_pending(), "stored wake is not a fresh lease");
|
||||
gate.set_sampled_limit(1, Duration::from_secs(1));
|
||||
let Poll::Ready(renewed) = expired.as_mut().poll(&mut context) else {
|
||||
panic!("fresh observation must wake expired admission")
|
||||
};
|
||||
drop(renewed);
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
use std::future::Future;
|
||||
use std::task::Poll;
|
||||
|
||||
#[tokio::test]
|
||||
async fn shrink_preserves_all_held_units_and_blocks_until_below_new_limit() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 3);
|
||||
let first = owner.acquire().await;
|
||||
let second = owner.acquire().await;
|
||||
let third = owner.acquire().await;
|
||||
owner.set_limit(1);
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 3 });
|
||||
drop(first);
|
||||
assert!(owner.try_acquire().is_none());
|
||||
drop(second);
|
||||
assert!(owner.try_acquire().is_none());
|
||||
drop(third);
|
||||
let fourth = owner.try_acquire().expect("last old permit returned to original owner");
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
|
||||
drop(fourth);
|
||||
assert_eq!(owner.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_capacity_and_cancelled_waiter_do_not_leak_or_bypass() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut waiting = Box::pin(owner.acquire());
|
||||
std::future::poll_fn(|cx| {
|
||||
assert!(waiting.as_mut().poll(cx).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
drop(waiting);
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 0 });
|
||||
assert!(owner.try_acquire().is_none());
|
||||
owner.set_limit(1);
|
||||
let permit = owner.acquire().await;
|
||||
assert!(owner.try_acquire().is_none());
|
||||
drop(permit);
|
||||
assert_eq!(owner.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_wakes_a_registered_waiter_without_an_unrelated_release() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut waiting = Box::pin(owner.acquire());
|
||||
std::future::poll_fn(|cx| {
|
||||
assert!(waiting.as_mut().poll(cx).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
owner.set_limit(1);
|
||||
let permit = tokio::time::timeout(std::time::Duration::from_secs(1), waiting)
|
||||
.await
|
||||
.expect("resize notification");
|
||||
assert_eq!(owner.snapshot().active, 1);
|
||||
drop(permit);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_resize_release_and_acquire_keep_the_owner_count_exact() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 8);
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let owner = owner.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
for _ in 0..100 {
|
||||
let permit = owner.acquire().await;
|
||||
tokio::task::yield_now().await;
|
||||
drop(permit);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for limit in [1, 0, 4, 2, 8, 0, u32::MAX, 1] {
|
||||
owner.set_limit(limit);
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
owner.set_limit(8);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
for task in tasks {
|
||||
task.await.expect("admission task");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all waiters make progress");
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn expired_observation_blocks_new_work_without_revoking_held_permits() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
owner.set_sampled_limit(2, Duration::from_secs(2));
|
||||
let held = owner.acquire().await;
|
||||
tokio::time::advance(Duration::from_secs(2)).await;
|
||||
assert!(owner.try_acquire().is_none());
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
|
||||
drop(held);
|
||||
assert_eq!(owner.snapshot().active, 0);
|
||||
owner.set_sampled_limit(1, Duration::from_secs(2));
|
||||
let fresh = owner.try_acquire().expect("fresh observation reopens same owner");
|
||||
owner.set_sampled_limit(8, Duration::ZERO);
|
||||
assert!(owner.try_acquire().is_none());
|
||||
drop(fresh);
|
||||
assert_eq!(owner.snapshot().active, 0);
|
||||
}
|
||||
|
||||
fn healthy(completed: u64) -> FeedbackObservation {
|
||||
FeedbackObservation {
|
||||
generation: 1,
|
||||
max_concurrency: 8,
|
||||
pressure: ResourcePressure::Stable,
|
||||
completed_units: Some(completed),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn controller_grows_one_unit_per_cooldown_and_cuts_pressure_immediately() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut controller = FeedbackController::new();
|
||||
assert_eq!(controller.update(&gate, Some(healthy(0))).limit, 1);
|
||||
for completed in 1..=25 {
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
let limit = controller.update(&gate, Some(healthy(completed))).limit;
|
||||
assert_eq!(
|
||||
limit,
|
||||
if completed < 9 {
|
||||
1
|
||||
} else if completed < 17 {
|
||||
2
|
||||
} else if completed < 25 {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
}
|
||||
);
|
||||
}
|
||||
let held = gate.try_acquire().expect("existing work");
|
||||
let mut pressure = healthy(26);
|
||||
pressure.pressure = ResourcePressure::Busy;
|
||||
assert_eq!(
|
||||
controller.update(&gate, Some(pressure)),
|
||||
FeedbackDecision {
|
||||
limit: 2,
|
||||
reason: FeedbackReason::Pressure
|
||||
}
|
||||
);
|
||||
pressure.max_concurrency = 0;
|
||||
assert_eq!(controller.update(&gate, Some(pressure)).limit, 0);
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
assert!(gate.try_acquire().is_none());
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn controller_missing_low_sample_stalled_or_changed_sources_never_expand() {
|
||||
for kind in 0..5 {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut controller = FeedbackController::new();
|
||||
for tick in 0..32 {
|
||||
let mut observation = healthy(tick);
|
||||
match kind {
|
||||
0 => observation.pressure = ResourcePressure::Unknown,
|
||||
1 => observation.completed_units = None,
|
||||
2 => observation.completed_units = Some(0),
|
||||
3 => observation.generation = tick,
|
||||
_ => {}
|
||||
}
|
||||
tokio::time::advance(Duration::from_secs(if kind == 4 { 3 } else { 1 })).await;
|
||||
assert!(controller.update(&gate, Some(observation)).limit <= 1, "kind {kind}");
|
||||
}
|
||||
assert_eq!(
|
||||
controller.update(&gate, None),
|
||||
FeedbackDecision {
|
||||
limit: 0,
|
||||
reason: FeedbackReason::Unavailable
|
||||
}
|
||||
);
|
||||
assert!(gate.try_acquire().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn controller_resumes_after_zero_cap_without_requiring_impossible_progress() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut controller = FeedbackController::new();
|
||||
let mut sample = healthy(0);
|
||||
sample.max_concurrency = 0;
|
||||
assert_eq!(controller.update(&gate, Some(sample)).limit, 0);
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
sample.max_concurrency = 8;
|
||||
assert_eq!(controller.update(&gate, Some(sample)).limit, 1);
|
||||
assert!(gate.try_acquire().is_some(), "minimum progress restarts inside the restored cap");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn controller_applies_pressure_reduction_and_lower_cap_in_the_same_window() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut controller = FeedbackController::new();
|
||||
for completed in 0..=25 {
|
||||
controller.update(&gate, Some(healthy(completed)));
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
}
|
||||
assert_eq!(gate.snapshot().limit, 4);
|
||||
let mut sample = healthy(26);
|
||||
sample.max_concurrency = 3;
|
||||
sample.pressure = ResourcePressure::Busy;
|
||||
assert_eq!(controller.update(&gate, Some(sample)).limit, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
//! Bounded foreground completion windows for background resource feedback.
|
||||
//! Recording uses fixed atomic storage, without an allocation, mutex or task
|
||||
//! per request. A single reader rotates banks and waits for the retired bank's
|
||||
//! writers before reading it. Delayed writers delay publication, not the
|
||||
//! recorded window boundary. Percentiles are intervals, never exact values.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SUB_BUCKETS: usize = 64;
|
||||
const FINITE_BUCKETS: usize = 27 * SUB_BUCKETS;
|
||||
const BUCKETS: usize = FINITE_BUCKETS + 1;
|
||||
const MAX_MICROS: u64 = u32::MAX as u64;
|
||||
|
||||
/// Bounded foreground categories; no bucket, object or request identifiers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ForegroundClass {
|
||||
/// Object body reads.
|
||||
Read,
|
||||
/// Object writes, including multipart and copy operations.
|
||||
Write,
|
||||
/// Metadata and listing operations.
|
||||
Metadata,
|
||||
/// Other operations, including rejection before typed dispatch.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ForegroundClass {
|
||||
/// Stable order shared by window summaries.
|
||||
pub const ALL: [Self; 4] = [Self::Read, Self::Write, Self::Metadata, Self::Other];
|
||||
|
||||
/// Fixed metric label.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "read",
|
||||
Self::Write => "write",
|
||||
Self::Metadata => "metadata",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
const fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Read => 0,
|
||||
Self::Write => 1,
|
||||
Self::Metadata => 2,
|
||||
Self::Other => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome at the observed response-body boundary, not a client ACK.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CompletionOutcome {
|
||||
/// Successful response body completed.
|
||||
Success,
|
||||
/// Error response, service error, or body-stream failure.
|
||||
Failed,
|
||||
/// The request/body was dropped before completion.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Inclusive bounds containing the observed percentile.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LatencyBounds {
|
||||
/// Inclusive lower bound in nanoseconds.
|
||||
pub lower_nanos: u64,
|
||||
/// Inclusive upper bound; absent for the overflow bucket.
|
||||
pub upper_nanos: Option<u64>,
|
||||
}
|
||||
|
||||
/// One class in a complete observation window.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ClassCompletions {
|
||||
/// All finishes, including errors and cancellation.
|
||||
pub finished: u64,
|
||||
/// Error responses, service errors and body-stream failures.
|
||||
pub failed: u64,
|
||||
/// Censored observations; these must not be treated as successful latency.
|
||||
pub cancelled: u64,
|
||||
/// P99 of non-cancelled finishes, when at least 100 samples exist.
|
||||
/// This count permits computing the rank; it is not statistical confidence.
|
||||
pub p99: Option<LatencyBounds>,
|
||||
}
|
||||
|
||||
/// Clock readings bracketing an atomic event-window cut. A scheduler pause
|
||||
/// between these readings is visible, rather than disguised as precise timing.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WindowBoundary {
|
||||
/// Read immediately before the event cut.
|
||||
pub earliest: Instant,
|
||||
/// Read immediately after the event cut.
|
||||
pub latest: Instant,
|
||||
}
|
||||
|
||||
/// Complete windows retain their actual cut brackets, even if publication waits.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ForegroundWindow {
|
||||
/// Start of this event window.
|
||||
pub start: WindowBoundary,
|
||||
/// Cut of the retired bank, possibly older than publication time.
|
||||
pub end: WindowBoundary,
|
||||
/// Request starts in this window, including pre-dispatch rejection.
|
||||
pub started: Option<u64>,
|
||||
/// Requests still alive at the cut; absent after accounting overflow.
|
||||
pub in_flight: Option<u64>,
|
||||
/// Per-class completions in ForegroundClass::ALL order.
|
||||
pub classes: [Option<ClassCompletions>; 4],
|
||||
/// False after a counter overflow or an inconsistent event count.
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
struct ClassBank {
|
||||
latencies: [AtomicU64; BUCKETS],
|
||||
failed: AtomicU64,
|
||||
cancelled: AtomicU64,
|
||||
}
|
||||
|
||||
impl ClassBank {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
latencies: std::array::from_fn(|_| AtomicU64::new(0)),
|
||||
failed: AtomicU64::new(0),
|
||||
cancelled: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Bank {
|
||||
writers: AtomicUsize,
|
||||
started: AtomicU64,
|
||||
overflowed: AtomicBool,
|
||||
classes: [ClassBank; 4],
|
||||
}
|
||||
|
||||
impl Bank {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
writers: AtomicUsize::new(0),
|
||||
started: AtomicU64::new(0),
|
||||
overflowed: AtomicBool::new(false),
|
||||
classes: std::array::from_fn(|_| ClassBank::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn increment(&self, counter: &AtomicU64) {
|
||||
if counter.fetch_add(1, Ordering::Relaxed) == u64::MAX {
|
||||
self.overflowed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Writer<'a>(&'a Bank);
|
||||
|
||||
impl Drop for Writer<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.writers.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// One process-wide recorder. Its fixed storage is allocated with the owner.
|
||||
/// A request holds only a reference, timestamps and bounded enum state.
|
||||
pub struct ForegroundRecorder {
|
||||
created_at: Instant,
|
||||
epoch: AtomicU64,
|
||||
reader_taken: AtomicBool,
|
||||
banks: [Bank; 2],
|
||||
}
|
||||
|
||||
impl Default for ForegroundRecorder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ForegroundRecorder {
|
||||
/// Construct empty accounting for one lifetime of the owning process.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
created_at: Instant::now(),
|
||||
epoch: AtomicU64::new(0),
|
||||
reader_taken: AtomicBool::new(false),
|
||||
banks: std::array::from_fn(|_| Bank::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn writer(&self) -> Writer<'_> {
|
||||
loop {
|
||||
let epoch = self.epoch.load(Ordering::SeqCst);
|
||||
let bank = &self.banks[usize::from(epoch & 1 != 0)];
|
||||
bank.writers.fetch_add(1, Ordering::SeqCst);
|
||||
let writer = Writer(bank);
|
||||
// Sequential ordering joins registration, the reader's cut, and
|
||||
// its zero-writer check. A late old-epoch registrant cannot write
|
||||
// into a bank that the reader has begun draining or reusing.
|
||||
if self.epoch.load(Ordering::SeqCst) == epoch {
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin at the external service-call boundary. Drop records cancellation
|
||||
/// unless finish has already recorded a response-body outcome.
|
||||
pub fn start(&self) -> ForegroundObservation<'_> {
|
||||
let started_at = Instant::now();
|
||||
let writer = self.writer();
|
||||
writer.0.increment(&writer.0.started);
|
||||
ForegroundObservation {
|
||||
recorder: self,
|
||||
started_at: Some(started_at),
|
||||
class: ForegroundClass::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self, class: ForegroundClass, elapsed: Duration, outcome: CompletionOutcome) {
|
||||
let writer = self.writer();
|
||||
let bank = writer.0;
|
||||
let class = &bank.classes[class.index()];
|
||||
if outcome == CompletionOutcome::Cancelled {
|
||||
bank.increment(&class.cancelled);
|
||||
return;
|
||||
}
|
||||
let bucket = latency_bucket(elapsed);
|
||||
bank.increment(&class.latencies[bucket]);
|
||||
if outcome == CompletionOutcome::Failed {
|
||||
bank.increment(&class.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim the sole reader. It cannot be replaced after being dropped:
|
||||
/// resetting its carried in-flight count would falsify later windows.
|
||||
pub fn take_reader(&self) -> Option<ForegroundReader<'_>> {
|
||||
self.reader_taken
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.ok()?;
|
||||
Some(ForegroundReader {
|
||||
recorder: self,
|
||||
start: WindowBoundary {
|
||||
earliest: self.created_at,
|
||||
latest: self.created_at,
|
||||
},
|
||||
retired: None,
|
||||
in_flight: Some(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-cloneable request ownership, moved from the service future to its body.
|
||||
pub struct ForegroundObservation<'a> {
|
||||
recorder: &'a ForegroundRecorder,
|
||||
started_at: Option<Instant>,
|
||||
class: ForegroundClass,
|
||||
}
|
||||
|
||||
impl ForegroundObservation<'_> {
|
||||
/// Set the first dispatched operation's bounded category.
|
||||
pub fn set_class(&mut self, class: ForegroundClass) {
|
||||
self.class = class;
|
||||
}
|
||||
|
||||
/// Finish once. Later calls or Drop do not produce another observation.
|
||||
pub fn finish(&mut self, outcome: CompletionOutcome) {
|
||||
if let Some(started_at) = self.started_at.take() {
|
||||
self.recorder.finish(self.class, started_at.elapsed(), outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ForegroundObservation<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.finish(CompletionOutcome::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-reader state; never acquired by a request path.
|
||||
pub struct ForegroundReader<'a> {
|
||||
recorder: &'a ForegroundRecorder,
|
||||
start: WindowBoundary,
|
||||
retired: Option<(usize, WindowBoundary)>,
|
||||
in_flight: Option<u64>,
|
||||
}
|
||||
|
||||
impl ForegroundReader<'_> {
|
||||
/// Cut a new window, or finish publishing an earlier cut. A live retired
|
||||
/// writer returns None without blocking, discarding data or moving its cut.
|
||||
pub fn sample(&mut self) -> Option<ForegroundWindow> {
|
||||
if self.retired.is_none() {
|
||||
let epoch = self.recorder.epoch.load(Ordering::SeqCst);
|
||||
let next = epoch.checked_add(1)?;
|
||||
let earliest = Instant::now();
|
||||
self.recorder.epoch.store(next, Ordering::SeqCst);
|
||||
let cut = WindowBoundary {
|
||||
earliest,
|
||||
latest: Instant::now(),
|
||||
};
|
||||
self.retired = Some((usize::from(epoch & 1 != 0), cut));
|
||||
}
|
||||
let (index, end) = self.retired?;
|
||||
let bank = &self.recorder.banks[index];
|
||||
if bank.writers.load(Ordering::SeqCst) != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let started = bank.started.swap(0, Ordering::Relaxed);
|
||||
let overflowed = bank.overflowed.swap(false, Ordering::Relaxed);
|
||||
let classes = std::array::from_fn(|index| {
|
||||
let class = &bank.classes[index];
|
||||
let failed = class.failed.swap(0, Ordering::Relaxed);
|
||||
let cancelled = class.cancelled.swap(0, Ordering::Relaxed);
|
||||
let buckets = std::array::from_fn::<_, BUCKETS, _>(|i| class.latencies[i].swap(0, Ordering::Relaxed));
|
||||
let non_cancelled = buckets.iter().try_fold(0_u64, |sum, count| sum.checked_add(*count));
|
||||
let finished = non_cancelled.and_then(|total| total.checked_add(cancelled));
|
||||
let finished = finished.filter(|_| !overflowed && non_cancelled.is_some_and(|total| failed <= total))?;
|
||||
Some(ClassCompletions {
|
||||
finished,
|
||||
failed,
|
||||
cancelled,
|
||||
p99: non_cancelled
|
||||
.filter(|total| *total >= 100)
|
||||
.and_then(|total| percentile_99(&buckets, total)),
|
||||
})
|
||||
});
|
||||
let started = (!overflowed).then_some(started);
|
||||
let finished = classes
|
||||
.iter()
|
||||
.try_fold(0_u64, |sum, class| sum.checked_add(class.as_ref()?.finished));
|
||||
self.in_flight = self
|
||||
.in_flight
|
||||
.zip(finished)
|
||||
.and_then(|(active, finished)| active.checked_add(started?)?.checked_sub(finished));
|
||||
let window = ForegroundWindow {
|
||||
start: self.start,
|
||||
end,
|
||||
started,
|
||||
in_flight: self.in_flight,
|
||||
classes,
|
||||
valid: self.in_flight.is_some(),
|
||||
};
|
||||
self.start = end;
|
||||
self.retired = None;
|
||||
Some(window)
|
||||
}
|
||||
}
|
||||
|
||||
fn latency_bucket(elapsed: Duration) -> usize {
|
||||
let micros = elapsed.as_nanos().div_ceil(1_000);
|
||||
let Ok(micros) = u64::try_from(micros) else {
|
||||
return FINITE_BUCKETS;
|
||||
};
|
||||
if micros > MAX_MICROS {
|
||||
return FINITE_BUCKETS;
|
||||
}
|
||||
if micros < SUB_BUCKETS as u64 {
|
||||
return usize::try_from(micros).unwrap_or(FINITE_BUCKETS);
|
||||
}
|
||||
let shift = 63 - micros.leading_zeros() - 6;
|
||||
usize::try_from((u64::from(shift) + 1) * SUB_BUCKETS as u64 + (micros >> shift) - SUB_BUCKETS as u64)
|
||||
.unwrap_or(FINITE_BUCKETS)
|
||||
}
|
||||
|
||||
fn bucket_bounds(index: usize) -> LatencyBounds {
|
||||
if index == FINITE_BUCKETS {
|
||||
return LatencyBounds {
|
||||
lower_nanos: MAX_MICROS * 1_000 + 1,
|
||||
upper_nanos: None,
|
||||
};
|
||||
}
|
||||
let (lower, upper) = if index < SUB_BUCKETS {
|
||||
(index as u64, index as u64)
|
||||
} else {
|
||||
let shift = index / SUB_BUCKETS - 1;
|
||||
let lower = (SUB_BUCKETS + index % SUB_BUCKETS) as u64 * (1_u64 << shift);
|
||||
(lower, lower + (1_u64 << shift) - 1)
|
||||
};
|
||||
LatencyBounds {
|
||||
lower_nanos: lower.saturating_sub(1) * 1_000 + u64::from(lower != 0),
|
||||
upper_nanos: Some(upper * 1_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn percentile_99(buckets: &[u64; BUCKETS], total: u64) -> Option<LatencyBounds> {
|
||||
let rank = (u128::from(total) * 99).div_ceil(100);
|
||||
let mut cumulative = 0_u128;
|
||||
for (index, count) in buckets.iter().enumerate() {
|
||||
cumulative += u128::from(*count);
|
||||
if cumulative >= rank {
|
||||
return Some(bucket_bounds(index));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn percentile_intervals_contain_each_bucket_edge_and_neighbour() {
|
||||
for index in 0..BUCKETS {
|
||||
let bounds = bucket_bounds(index);
|
||||
assert_eq!(latency_bucket(Duration::from_nanos(bounds.lower_nanos)), index);
|
||||
if let Some(upper) = bounds.upper_nanos {
|
||||
assert!(upper >= bounds.lower_nanos);
|
||||
assert_eq!(latency_bucket(Duration::from_nanos(upper)), index);
|
||||
assert_eq!(latency_bucket(Duration::from_nanos(upper + 1)), index + 1);
|
||||
}
|
||||
}
|
||||
assert_eq!(latency_bucket(Duration::MAX), FINITE_BUCKETS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_ownership_carries_in_flight_counts_across_windows() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
let mut request = recorder.start();
|
||||
request.set_class(ForegroundClass::Read);
|
||||
let first = reader.sample().expect("cut with a live request");
|
||||
assert!(first.valid);
|
||||
assert_eq!(first.started, Some(1));
|
||||
assert_eq!(first.in_flight, Some(1));
|
||||
assert_eq!(first.classes[0].expect("valid read count").finished, 0);
|
||||
|
||||
request.finish(CompletionOutcome::Success);
|
||||
request.finish(CompletionOutcome::Failed);
|
||||
drop(request);
|
||||
let second = reader.sample().expect("body finished");
|
||||
assert!(second.valid);
|
||||
assert_eq!(second.started, Some(0));
|
||||
assert_eq!(second.in_flight, Some(0));
|
||||
let read = second.classes[0].expect("valid read summary");
|
||||
assert_eq!((read.finished, read.failed, read.cancelled, read.p99), (1, 0, 0, None));
|
||||
assert_eq!(first.end.earliest, second.start.earliest);
|
||||
assert_eq!(first.end.latest, second.start.latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellations_are_counted_without_fabricating_success_latencies() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
for _ in 0..150 {
|
||||
let mut request = recorder.start();
|
||||
request.set_class(ForegroundClass::Write);
|
||||
}
|
||||
let window = reader.sample().expect("cancelled requests");
|
||||
let write = window.classes[1].expect("known write count");
|
||||
assert!(window.valid);
|
||||
assert_eq!(window.in_flight, Some(0));
|
||||
assert_eq!((write.finished, write.cancelled, write.failed, write.p99), (150, 150, 0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_retired_writer_preserves_the_original_cut_and_next_bank() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
let writer = recorder.writer();
|
||||
assert!(reader.sample().is_none(), "never read a live writer's bank");
|
||||
let original_cut = reader.retired.expect("pending cut").1;
|
||||
let request = recorder.start(); // a start after the cut belongs to the new bank
|
||||
writer.0.increment(&writer.0.started);
|
||||
drop(writer);
|
||||
let previous = reader.sample().expect("retired writer drained");
|
||||
assert_eq!(previous.end.earliest, original_cut.earliest);
|
||||
assert_eq!(previous.end.latest, original_cut.latest);
|
||||
assert_eq!(previous.started, Some(1));
|
||||
assert_eq!(previous.in_flight, Some(1));
|
||||
drop(request);
|
||||
let current = reader.sample().expect("next bank");
|
||||
assert_eq!(current.started, Some(1));
|
||||
assert_eq!(current.in_flight, Some(1));
|
||||
assert_eq!(current.classes[3].expect("unclassified cancellation").cancelled, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_percentile_uses_rank_and_never_turns_maximum_into_p99() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
for i in 0..100 {
|
||||
let writer = recorder.writer();
|
||||
writer.0.increment(&writer.0.started);
|
||||
drop(writer);
|
||||
recorder.finish(
|
||||
ForegroundClass::Read,
|
||||
if i == 99 {
|
||||
Duration::from_secs(60)
|
||||
} else {
|
||||
Duration::from_millis(2)
|
||||
},
|
||||
if i == 99 {
|
||||
CompletionOutcome::Failed
|
||||
} else {
|
||||
CompletionOutcome::Success
|
||||
},
|
||||
);
|
||||
}
|
||||
let window = reader.sample().expect("complete window");
|
||||
let read = window.classes[0].expect("read summary");
|
||||
assert_eq!((read.finished, read.failed, read.cancelled), (100, 1, 0));
|
||||
let p99 = read.p99.expect("100 non-cancelled samples");
|
||||
assert!(p99.lower_nanos <= 2_000_000);
|
||||
assert!(p99.upper_nanos.is_some_and(|upper| (2_000_000..3_000_000).contains(&upper)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_stays_unknown_and_reader_cannot_reset_event_accounting() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
{
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
recorder.banks[0].started.store(u64::MAX, Ordering::Relaxed);
|
||||
drop(recorder.start());
|
||||
let invalid = reader.sample().expect("overflow window");
|
||||
assert!(!invalid.valid);
|
||||
assert_eq!(invalid.started, None);
|
||||
assert_eq!(invalid.in_flight, None);
|
||||
assert_eq!(invalid.classes, [None; 4]);
|
||||
let later = reader.sample().expect("later empty window");
|
||||
assert!(!later.valid, "a lost carried count cannot recover by inventing zero active work");
|
||||
}
|
||||
assert!(recorder.take_reader().is_none(), "reader replacement would lose live request ownership");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_rotation_preserves_every_start_finish_and_cancellation() {
|
||||
let recorder = Arc::new(ForegroundRecorder::new());
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
let workers: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let recorder = Arc::clone(&recorder);
|
||||
std::thread::spawn(move || {
|
||||
for index in 0..10_000 {
|
||||
let mut request = recorder.start();
|
||||
request.set_class(ForegroundClass::Read);
|
||||
if index % 10 != 0 {
|
||||
request.finish(CompletionOutcome::Success);
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let (mut started, mut finished, mut cancelled) = (0, 0, 0);
|
||||
let mut add_window = |window: ForegroundWindow| {
|
||||
assert!(window.valid, "concurrent event cuts must stay balanced");
|
||||
started += window.started.expect("valid start count");
|
||||
let reads = window.classes[0].expect("valid read count");
|
||||
finished += reads.finished;
|
||||
cancelled += reads.cancelled;
|
||||
};
|
||||
while workers.iter().any(|worker| !worker.is_finished()) {
|
||||
if let Some(window) = reader.sample() {
|
||||
add_window(window);
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
for worker in workers {
|
||||
worker.join().expect("writer must finish");
|
||||
}
|
||||
add_window(reader.sample().expect("drain after writers join"));
|
||||
// A previously retired window may have been published by the last
|
||||
// sample; cut once more to include the current, still open bank.
|
||||
let final_window = reader.sample().expect("final open bank");
|
||||
assert_eq!(final_window.in_flight, Some(0));
|
||||
add_window(final_window);
|
||||
assert_eq!((started, finished, cancelled), (40_000, 40_000, 4_000));
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,10 @@
|
||||
// Re-exported io-core type used by downstream timeout implementations.
|
||||
pub use rustfs_io_core::OperationProgress;
|
||||
|
||||
pub mod adaptive;
|
||||
mod backpressure;
|
||||
mod deadlock;
|
||||
pub mod foreground;
|
||||
mod queue;
|
||||
pub mod workers;
|
||||
pub mod workload;
|
||||
|
||||
@@ -49,12 +49,28 @@ impl Workers {
|
||||
|
||||
/// Acquire a worker slot, waiting until one becomes available.
|
||||
pub async fn take(&self) {
|
||||
struct AcquireCancelGuard<'a> {
|
||||
workers: &'a Workers,
|
||||
completed: bool,
|
||||
}
|
||||
impl Drop for AcquireCancelGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed {
|
||||
self.workers.in_use.send_modify(|_| {});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut guard = AcquireCancelGuard {
|
||||
workers: self,
|
||||
completed: false,
|
||||
};
|
||||
let permit = match self.semaphore.acquire().await {
|
||||
Ok(permit) => permit,
|
||||
// The semaphore is owned by `self` and never closed.
|
||||
Err(_) => unreachable!("worker semaphore is never closed"),
|
||||
};
|
||||
permit.forget(); // returned manually via give()
|
||||
guard.completed = true;
|
||||
self.in_use.send_modify(|held| *held += 1);
|
||||
trace!(
|
||||
event = "worker_slot.acquire",
|
||||
@@ -82,6 +98,7 @@ impl Workers {
|
||||
});
|
||||
if released {
|
||||
self.semaphore.add_permits(1);
|
||||
self.in_use.send_modify(|_| {});
|
||||
}
|
||||
trace!(
|
||||
event = "worker_slot.release",
|
||||
@@ -99,7 +116,9 @@ impl Workers {
|
||||
pub async fn wait(&self) {
|
||||
let mut rx = self.in_use.subscribe();
|
||||
// The sender is owned by `self`, so it outlives this borrow.
|
||||
let _ = rx.wait_for(|&held| held == 0).await;
|
||||
let _ = rx
|
||||
.wait_for(|&held| held == 0 && self.semaphore.available_permits() == self.limit)
|
||||
.await;
|
||||
debug!(
|
||||
event = "worker_slot.wait",
|
||||
component = "concurrency",
|
||||
@@ -114,7 +133,9 @@ impl Workers {
|
||||
|
||||
/// Return the current number of available worker slots.
|
||||
pub async fn available(&self) -> usize {
|
||||
self.limit.saturating_sub(*self.in_use.borrow())
|
||||
self.limit
|
||||
.saturating_sub(*self.in_use.borrow())
|
||||
.min(self.semaphore.available_permits())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,4 +238,36 @@ mod tests {
|
||||
}
|
||||
assert_eq!(workers.available().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_does_not_complete_while_permit_is_in_transit_to_queued_taker() {
|
||||
use std::future::Future;
|
||||
use std::pin::pin;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
let workers = Workers::new(1).unwrap();
|
||||
workers.take().await;
|
||||
|
||||
let mut taker = pin!(workers.take());
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
let mut waiter = pin!(workers.wait());
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
// Hand off the slot to the queued taker before `taker` is polled again.
|
||||
workers.give().await;
|
||||
assert_eq!(workers.available().await, 0);
|
||||
assert!(
|
||||
matches!(waiter.as_mut().poll(&mut cx), Poll::Pending),
|
||||
"wait() must not complete while a permit is in transit to a woken taker"
|
||||
);
|
||||
|
||||
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Ready(())));
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
workers.give().await;
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Ready(())));
|
||||
assert_eq!(workers.available().await, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ when restoring `/rustfs/console` for a custom OEM build.
|
||||
- `RUSTFS_DATA_SCANNER_START_DELAY_SECS` (deprecated alias for compatibility)
|
||||
- `RUSTFS_SCANNER_IDLE_MODE` (canonical)
|
||||
- `RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS` (canonical)
|
||||
- `RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS` (canonical)
|
||||
- `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` (canonical)
|
||||
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
|
||||
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
|
||||
@@ -127,6 +128,12 @@ Scanner cycle budget controls:
|
||||
|
||||
- When `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` is unset, the finite default is 1800 seconds (30 minutes), matching the scanner benchmark guidance.
|
||||
- An explicit `0` preserves the compatibility behavior of an unbounded runtime budget. Object and directory budgets likewise remain unbounded when explicitly set to `0`.
|
||||
|
||||
Scanner recovery retry controls:
|
||||
|
||||
- `RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS` caps the wait between automatic startup recovery retries. The persistent admin key is `scanner.recovery_backoff_cap`; the environment takes precedence over the persistent value, and the unset default remains 600 seconds (10 minutes). Values must be positive integer seconds within the platform timer range; `0`, malformed values, and overflow are rejected. A valid environment override does not hide an invalid persistent value from `admin config set` validation.
|
||||
- The cap bounds the existing exponential retry schedule. Updating the persistent value applies to the next computed retry; it does not reschedule a wait already in progress. Retryable observations continue retrying after the short retry budget. Admin scanner status reports the effective value and source at `runtime_config.recovery_backoff_cap_seconds`.
|
||||
|
||||
Scanner walker controls:
|
||||
|
||||
- `RUSTFS_SCANNER_WALKERS_PER_DISK` sets how many bucket walkers each local disk of an erasure set runs; the default `1` walks one bucket per disk at a time, which is the right value for rotational media. On flash, `2` to `4` lets one disk walk several buckets concurrently, which matters most for single-disk deployments with many buckets, where the walk is otherwise fully serial. Values outside `1` through `16` are rejected by `admin config set`; an out-of-range or non-numeric environment value is logged as a scanner runtime config error (`state = "startup_apply_failed"`) and the scanner keeps the default. The walkers of one disk share that disk's scanner admission, so peers still see one scan session per physical disk, and `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` still caps the total.
|
||||
|
||||
@@ -276,6 +276,9 @@ pub const DEFAULT_INTERNODE_BULK_CHANNELS: usize = 2;
|
||||
pub const ENV_INTERNODE_HTTP_TUNING_PROFILE: &str = "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE";
|
||||
pub const DEFAULT_INTERNODE_HTTP_TUNING_PROFILE: &str = "legacy";
|
||||
|
||||
/// Internode HTTP data-plane protocol: `http1` (default) or `auto` (TLS ALPN).
|
||||
pub const ENV_INTERNODE_HTTP_PROTOCOL: &str = "RUSTFS_INTERNODE_HTTP_PROTOCOL";
|
||||
|
||||
/// Internode HTTP connection pool maximum idle connections per host.
|
||||
pub const ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST: &str = "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST";
|
||||
|
||||
@@ -287,8 +290,9 @@ pub const ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_HT
|
||||
/// NOTE (backlog#805-C3): all `RUSTFS_INTERNODE_HTTP2_*` tuning (window sizes,
|
||||
/// adaptive window, keepalive timeout) only takes effect when the internode
|
||||
/// connection actually negotiates HTTP/2, which happens via TLS-ALPN. Over
|
||||
/// plaintext internode transport the connection is HTTP/1.1 and these settings
|
||||
/// are silently inert — enable internode TLS to use them.
|
||||
/// plaintext internode transport or with the default `http1` protocol policy,
|
||||
/// these settings are inert. Select `RUSTFS_INTERNODE_HTTP_PROTOCOL=auto` and
|
||||
/// enable internode TLS to use them.
|
||||
pub const ENV_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE";
|
||||
|
||||
/// Internode HTTP/2 initial connection window size in bytes.
|
||||
|
||||
@@ -53,6 +53,9 @@ pub const SCANNER_IDLE_MODE: &str = "idle_mode";
|
||||
/// Scanner config key controlling scanner cache save timeout in seconds.
|
||||
pub const SCANNER_CACHE_SAVE_TIMEOUT: &str = "cache_save_timeout";
|
||||
|
||||
/// Scanner config key capping automatic recovery retry waits in seconds.
|
||||
pub const SCANNER_RECOVERY_BACKOFF_CAP: &str = "recovery_backoff_cap";
|
||||
|
||||
/// Scanner config key capping concurrent scanner set tasks.
|
||||
pub const SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "max_concurrent_set_scans";
|
||||
|
||||
@@ -93,6 +96,7 @@ pub const SCANNER_KEYS: &[&str] = &[
|
||||
SCANNER_BITROT_CYCLE,
|
||||
SCANNER_IDLE_MODE,
|
||||
SCANNER_CACHE_SAVE_TIMEOUT,
|
||||
SCANNER_RECOVERY_BACKOFF_CAP,
|
||||
SCANNER_MAX_CONCURRENT_SET_SCANS,
|
||||
SCANNER_MAX_CONCURRENT_DISK_SCANS,
|
||||
SCANNER_WALKERS_PER_DISK,
|
||||
@@ -216,6 +220,14 @@ pub const ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS: &str = "RUSTFS_SCANNER_CACHE_SAVE
|
||||
/// Default scanner cache save timeout in seconds.
|
||||
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 14;
|
||||
|
||||
/// Environment variable that caps automatic scanner recovery retry waits.
|
||||
/// The value must be a positive integer number of seconds within the timer range.
|
||||
/// - Example: `export RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS=600`
|
||||
pub const ENV_SCANNER_RECOVERY_BACKOFF_CAP_SECS: &str = "RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS";
|
||||
|
||||
/// Default maximum wait between automatic scanner recovery retries.
|
||||
pub const DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS: u64 = 10 * 60;
|
||||
|
||||
/// Environment variable that caps concurrent scanner set tasks.
|
||||
/// A value of `0` keeps the existing topology-based concurrency.
|
||||
/// - Example: `export RUSTFS_SCANNER_MAX_CONCURRENT_SET_SCANS=2`
|
||||
|
||||
@@ -107,7 +107,7 @@ flate2.workspace = true
|
||||
http.workspace = true
|
||||
http-body-util.workspace = true
|
||||
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "client-legacy", "tracing"] }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
|
||||
rustfs-signer.workspace = true
|
||||
|
||||
@@ -147,5 +147,6 @@ clap = { workspace = true, features = ["derive", "env"] }
|
||||
|
||||
[dev-dependencies]
|
||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
|
||||
rustfs-heal.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -200,10 +200,11 @@ python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run
|
||||
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
|
||||
# Distributed 4-node 4-disk lane uses the default server.
|
||||
# Distributed 4-node 4-disk lane uses the fault-test hooks server; the
|
||||
# scanner_heal_topology_ fault cases fail closed without it.
|
||||
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
|
||||
python3 scripts/e2e_binary.py build
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test
|
||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
|
||||
|
||||
# Replication nightly uses the default server; awscurl is required for STS.
|
||||
python3 scripts/e2e_binary.py build
|
||||
|
||||
+152
-14
@@ -34,7 +34,7 @@ use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde_json;
|
||||
use std::fs as stdfs;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::net::{Ipv6Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
@@ -1272,6 +1272,7 @@ pub struct RustFSTestClusterEnvironment {
|
||||
/// Optional socket proxies used for the corresponding node's volume
|
||||
/// endpoints. Proxies must be installed before [`Self::start`].
|
||||
volume_proxy_addresses: Vec<Option<SocketAddr>>,
|
||||
tls_ca_pem: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl RustFSTestClusterEnvironment {
|
||||
@@ -1312,7 +1313,10 @@ impl RustFSTestClusterEnvironment {
|
||||
pub async fn with_topology(topology: ClusterTopology) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
topology.validate()?;
|
||||
|
||||
let temp_dir = format!("/tmp/rustfs_cluster_test_{}", Uuid::new_v4());
|
||||
let temp_dir = std::env::temp_dir()
|
||||
.join(format!("rustfs_cluster_test_{}", Uuid::new_v4()))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
fs::create_dir_all(&temp_dir).await?;
|
||||
|
||||
// Map every node to its owning pool index.
|
||||
@@ -1374,9 +1378,35 @@ impl RustFSTestClusterEnvironment {
|
||||
node_capture_log_paths: vec![None; topology.node_count],
|
||||
topology,
|
||||
volume_proxy_addresses: vec![None; node_count],
|
||||
tls_ca_pem: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configure verified HTTPS for both volume endpoints and S3 clients.
|
||||
/// The bundle must be generated before any node starts.
|
||||
pub async fn enable_tls(&mut self, directory: &Path) -> std::io::Result<()> {
|
||||
if self.nodes.iter().any(|node| node.process.is_some()) {
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"configure cluster TLS before starting nodes",
|
||||
));
|
||||
}
|
||||
let directory = fs::canonicalize(directory).await?;
|
||||
let ca_pem = fs::read(directory.join("ca.crt")).await?;
|
||||
reqwest::Certificate::from_pem(&ca_pem).map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?;
|
||||
self.set_env(
|
||||
"RUSTFS_TLS_PATH",
|
||||
directory
|
||||
.to_str()
|
||||
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "TLS directory is not UTF-8"))?,
|
||||
);
|
||||
for node in &mut self.nodes {
|
||||
node.url = format!("https://{}", node.address);
|
||||
}
|
||||
self.tls_ca_pem = Some(ca_pem);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an extra environment variable applied to every cluster node process.
|
||||
pub fn set_env<K, V>(&mut self, key: K, value: V)
|
||||
where
|
||||
@@ -1467,6 +1497,29 @@ impl RustFSTestClusterEnvironment {
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
/// Route internode traffic through a method-aware HTTP proxy.
|
||||
pub(crate) async fn start_rpc_proxy_for_node(
|
||||
&mut self,
|
||||
node_idx: usize,
|
||||
) -> io::Result<crate::rpc_fault_proxy::RpcFaultProxy> {
|
||||
self.ensure_node_index(node_idx)
|
||||
.map_err(|error| io::Error::new(ErrorKind::InvalidInput, error))?;
|
||||
if self.volume_proxy_addresses[node_idx].is_some() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::AlreadyExists,
|
||||
format!("a volume proxy is already configured for node {node_idx}"),
|
||||
));
|
||||
}
|
||||
let target = self.nodes[node_idx]
|
||||
.address
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|error| io::Error::new(ErrorKind::InvalidInput, error))?;
|
||||
let listen = SocketAddr::from((Ipv6Addr::LOCALHOST, target.port()));
|
||||
let proxy = crate::rpc_fault_proxy::RpcFaultProxy::start_on(listen, target).await?;
|
||||
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
fn volume_address(&self, node_idx: usize) -> String {
|
||||
self.volume_proxy_addresses[node_idx]
|
||||
.map(|address| address.to_string())
|
||||
@@ -1475,6 +1528,7 @@ impl RustFSTestClusterEnvironment {
|
||||
|
||||
fn build_volumes_arg(&self) -> String {
|
||||
let pools = self.topology.normalized_pools();
|
||||
let scheme = if self.tls_ca_pem.is_some() { "https" } else { "http" };
|
||||
|
||||
if pools.len() <= 1 {
|
||||
// Single pool: explicit enumeration of every drive on every node.
|
||||
@@ -1484,7 +1538,7 @@ impl RustFSTestClusterEnvironment {
|
||||
.enumerate()
|
||||
.flat_map(|(node_idx, n)| {
|
||||
let address = self.volume_address(node_idx);
|
||||
n.data_dirs.iter().map(move |dir| format!("http://{}{}", address, dir))
|
||||
n.data_dirs.iter().map(move |dir| format!("{scheme}://{address}{dir}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
@@ -1504,7 +1558,7 @@ impl RustFSTestClusterEnvironment {
|
||||
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
|
||||
.unwrap_or(&node.data_dir);
|
||||
format!(
|
||||
"http://{}{}/drive{{0...{}}}",
|
||||
"{scheme}://{}{}/drive{{0...{}}}",
|
||||
self.volume_address(node_idx),
|
||||
base,
|
||||
self.topology.drives_per_node - 1
|
||||
@@ -1693,13 +1747,20 @@ impl RustFSTestClusterEnvironment {
|
||||
if node_idx >= self.nodes.len() {
|
||||
return Err("node_idx is invalid".into());
|
||||
}
|
||||
Ok(Client::from_conf(build_test_s3_config(
|
||||
&self.nodes[node_idx].url,
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
None,
|
||||
"cluster-test",
|
||||
)))
|
||||
let mut config =
|
||||
build_test_s3_config(&self.nodes[node_idx].url, &self.access_key, &self.secret_key, None, "cluster-test");
|
||||
if let Some(ca_pem) = &self.tls_ca_pem {
|
||||
use aws_smithy_http_client::tls::{Provider, TlsContext, TrustStore, rustls_provider::CryptoMode};
|
||||
let tls = TlsContext::builder()
|
||||
.with_trust_store(TrustStore::empty().with_pem_certificate(ca_pem.clone()))
|
||||
.build()?;
|
||||
let client = SmithyHttpClientBuilder::new()
|
||||
.tls_provider(Provider::Rustls(CryptoMode::AwsLc))
|
||||
.tls_context(tls)
|
||||
.build_https();
|
||||
config = config.to_builder().http_client(client).build();
|
||||
}
|
||||
Ok(Client::from_conf(config))
|
||||
}
|
||||
|
||||
/// Create S3 clients for all nodes in the RustFS cluster and collect them into a vector.
|
||||
@@ -1824,7 +1885,7 @@ impl RustFSTestClusterEnvironment {
|
||||
}
|
||||
|
||||
self.nodes.push(ClusterNode {
|
||||
url: format!("http://{address}"),
|
||||
url: format!("{}://{address}", if self.tls_ca_pem.is_some() { "https" } else { "http" }),
|
||||
address,
|
||||
data_dir: data_dirs[0].clone(),
|
||||
data_dirs,
|
||||
@@ -1846,6 +1907,47 @@ impl RustFSTestClusterEnvironment {
|
||||
Ok(new_idx)
|
||||
}
|
||||
|
||||
/// Remove retired single-node pools from a stopped multi-pool cluster.
|
||||
///
|
||||
/// A decommissioned pool must leave the server command line before the
|
||||
/// next start; the server refuses to start while it is still listed. The
|
||||
/// remaining pools keep their node order and are renumbered from zero.
|
||||
/// Data directories are left on disk for the owning harness to clean up.
|
||||
pub fn remove_single_node_pools(&mut self, retired: &[usize]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if self.nodes.iter().any(|node| node.process.is_some()) {
|
||||
return Err("stop the cluster before removing a pool".into());
|
||||
}
|
||||
let pools = self.topology.normalized_pools();
|
||||
if pools.iter().any(|nodes| nodes.len() != 1) {
|
||||
return Err("remove_single_node_pools requires one node per pool".into());
|
||||
}
|
||||
if retired.iter().any(|pool| *pool >= pools.len()) || retired.len() >= pools.len() {
|
||||
return Err(format!("cannot remove pools {retired:?} from {} pools", pools.len()).into());
|
||||
}
|
||||
let keep: Vec<usize> = (0..self.nodes.len())
|
||||
.filter(|node_idx| !retired.contains(&self.nodes[*node_idx].pool_idx))
|
||||
.collect();
|
||||
fn retain_indices<T>(values: &mut Vec<T>, keep: &[usize]) {
|
||||
let old = std::mem::take(values);
|
||||
*values = old
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| keep.contains(idx))
|
||||
.map(|(_, value)| value)
|
||||
.collect();
|
||||
}
|
||||
retain_indices(&mut self.nodes, &keep);
|
||||
retain_indices(&mut self.node_extra_env, &keep);
|
||||
retain_indices(&mut self.node_capture_log_paths, &keep);
|
||||
retain_indices(&mut self.volume_proxy_addresses, &keep);
|
||||
for (pool_idx, node) in self.nodes.iter_mut().enumerate() {
|
||||
node.pool_idx = pool_idx;
|
||||
}
|
||||
self.topology.node_count = self.nodes.len();
|
||||
self.topology.pools = (0..self.nodes.len()).map(|node_idx| vec![node_idx]).collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully stop one cluster node and wait for its process to exit.
|
||||
///
|
||||
/// This is intentionally separate from [`Self::stop_node`]: the latter is
|
||||
@@ -2397,6 +2499,7 @@ mod tests {
|
||||
node_capture_log_paths: vec![None; topology.node_count],
|
||||
topology,
|
||||
volume_proxy_addresses: vec![None; node_count],
|
||||
tls_ca_pem: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2414,8 +2517,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volumes_single_pool_multidrive_enumerates_every_drive() {
|
||||
#[tokio::test]
|
||||
async fn volumes_single_pool_multidrive_enumerates_every_drive() {
|
||||
// 4 nodes x 2 drives -> 8 explicit endpoints, one legacy pool. No
|
||||
// ellipses, so the server parser keeps this as a single DistErasure pool.
|
||||
let env = fake_cluster(ClusterTopology::single_pool_multidrive(4, 2));
|
||||
@@ -2427,6 +2530,25 @@ mod tests {
|
||||
.join(" ");
|
||||
assert_eq!(env.build_volumes_arg(), expected);
|
||||
assert_eq!(env.build_volumes_arg().split(' ').count(), 8);
|
||||
|
||||
let directory = tempfile::tempdir().expect("TLS fixture directory");
|
||||
crate::tls_gen::run(crate::tls_gen::Args {
|
||||
out_dir: directory.path().to_owned(),
|
||||
days: 1,
|
||||
force: false,
|
||||
})
|
||||
.expect("generate TLS bundle");
|
||||
for topology in [
|
||||
ClusterTopology::single_pool_multidrive(4, 4),
|
||||
ClusterTopology::per_node_pools(4, vec![vec![0], vec![1]]),
|
||||
] {
|
||||
let mut cluster = fake_cluster(topology);
|
||||
let plaintext_volumes = cluster.build_volumes_arg();
|
||||
cluster.enable_tls(directory.path()).await.expect("configure TLS");
|
||||
assert_eq!(cluster.build_volumes_arg(), plaintext_volumes.replace("http://", "https://"));
|
||||
assert!(cluster.nodes.iter().all(|node| node.url.starts_with("https://")));
|
||||
assert!(cluster.create_s3_client(0).is_ok(), "trusted SDK client should build");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2514,6 +2636,22 @@ mod tests {
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_data_dirs_use_os_temporary_directory() {
|
||||
let cluster = RustFSTestClusterEnvironment::new(1).await.expect("allocate isolated cluster");
|
||||
let root = PathBuf::from(&cluster.temp_dir);
|
||||
assert!(
|
||||
root.starts_with(std::env::temp_dir()),
|
||||
"cluster must honor the selected temporary filesystem"
|
||||
);
|
||||
for node in &cluster.nodes {
|
||||
assert!(Path::new(&node.data_dir).starts_with(&root));
|
||||
assert!(node.data_dirs.iter().all(|drive| Path::new(drive).starts_with(&root)));
|
||||
}
|
||||
drop(cluster);
|
||||
assert!(!root.exists(), "dropping the fixture must remove its owned directories");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_node_env_supports_per_node_overrides() {
|
||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression test for rustfs/backlog#629(b): region-aware `CreateBucket`.
|
||||
//! Region-aware bucket creation and location regressions: backlog#629(b), rustfs#8729.
|
||||
//!
|
||||
//! `minio-go`'s `MakeBucket(bucket, "us-east-1")` was reported to fail SigV4
|
||||
//! validation. The SigV4 signature is verified by `s3s` over the raw request
|
||||
@@ -23,7 +23,13 @@
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use crate::common::{
|
||||
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
|
||||
build_test_s3_config, build_test_sts_client, init_logging, local_http_client, signed_s3_request_with_headers,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::Region;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{BucketLocationConstraint, CreateBucketConfiguration};
|
||||
use std::error::Error;
|
||||
|
||||
@@ -33,7 +39,7 @@ mod tests {
|
||||
async fn test_create_bucket_with_us_east_1_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
|
||||
let client = env.create_s3_client();
|
||||
|
||||
client
|
||||
@@ -48,6 +54,13 @@ mod tests {
|
||||
.await
|
||||
.expect("region-aware CreateBucket must pass SigV4 validation and succeed");
|
||||
|
||||
let location = client.get_bucket_location().bucket("region-aware-bucket").send().await?;
|
||||
assert_eq!(
|
||||
location.location_constraint().map(|value| value.as_str()),
|
||||
Some(""),
|
||||
"us-east-1 must serialize an empty LocationConstraint element"
|
||||
);
|
||||
|
||||
// The bucket exists and is listed.
|
||||
let listed = client.list_buckets().send().await?;
|
||||
let names: Vec<&str> = listed.buckets().iter().filter_map(|b| b.name()).collect();
|
||||
@@ -63,7 +76,7 @@ mod tests {
|
||||
async fn test_create_bucket_without_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
|
||||
let client = env.create_s3_client();
|
||||
|
||||
client
|
||||
@@ -76,4 +89,423 @@ mod tests {
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_bucket_rejects_unserved_location_constraints() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
|
||||
let client = env.create_s3_client();
|
||||
|
||||
for (index, region) in ["eu-1", "not-a-real-region-42", "us-west-2", "EU"].iter().enumerate() {
|
||||
let bucket = format!("rejected-region-{index}");
|
||||
let error = client
|
||||
.create_bucket()
|
||||
.bucket(&bucket)
|
||||
.create_bucket_configuration(
|
||||
CreateBucketConfiguration::builder()
|
||||
.location_constraint(BucketLocationConstraint::from(*region))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a region not served by this instance must be rejected");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidLocationConstraint")
|
||||
);
|
||||
|
||||
let missing = client
|
||||
.head_bucket()
|
||||
.bucket(&bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("rejected bucket must not exist");
|
||||
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
}
|
||||
|
||||
let missing = client
|
||||
.get_bucket_location()
|
||||
.bucket("missing-region-bucket")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("missing bucket must remain an error");
|
||||
assert_eq!(missing.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchBucket"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_bucket_uses_configured_region() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
for region in ["eu-1", "eu-west-1"] {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec!["--region", region]).await?;
|
||||
let client = Client::from_conf(
|
||||
env.create_s3_client()
|
||||
.config()
|
||||
.to_builder()
|
||||
.region(Region::new(region))
|
||||
.build(),
|
||||
);
|
||||
let mut configurations = vec![None, Some(CreateBucketConfiguration::builder().build())];
|
||||
for constraint in ["", region].into_iter().chain((region == "eu-west-1").then_some("EU")) {
|
||||
configurations.push(Some(
|
||||
CreateBucketConfiguration::builder()
|
||||
.location_constraint(BucketLocationConstraint::from(constraint))
|
||||
.build(),
|
||||
));
|
||||
}
|
||||
|
||||
// Omitted and empty constraints retain the S3-compatible server's site default.
|
||||
for (index, configuration) in configurations.into_iter().enumerate() {
|
||||
let bucket = format!("configured-region-{index}");
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(&bucket)
|
||||
.set_create_bucket_configuration(configuration)
|
||||
.send()
|
||||
.await?;
|
||||
let location = client.get_bucket_location().bucket(&bucket).send().await?;
|
||||
assert_eq!(location.location_constraint().map(|value| value.as_str()), Some(region));
|
||||
}
|
||||
|
||||
// A correctly signed request still cannot choose another bucket region.
|
||||
let error = client
|
||||
.create_bucket()
|
||||
.bucket("wrong-default-region")
|
||||
.create_bucket_configuration(
|
||||
CreateBucketConfiguration::builder()
|
||||
.location_constraint(BucketLocationConstraint::from("us-east-1"))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the default region must not bypass a custom site region");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidLocationConstraint")
|
||||
);
|
||||
let missing = client
|
||||
.head_bucket()
|
||||
.bucket("wrong-default-region")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("rejected bucket must not exist");
|
||||
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn location_policy_client(
|
||||
env: &RustFSTestEnvironment,
|
||||
user: &str,
|
||||
statements: serde_json::Value,
|
||||
) -> Result<Client, Box<dyn Error + Send + Sync>> {
|
||||
let secret = "location-policy-test-secret";
|
||||
admin_create_user(env, user, secret).await?;
|
||||
let policy = serde_json::json!({"Version":"2012-10-17", "Statement":statements});
|
||||
admin_add_canned_policy_via(
|
||||
AdminTransport::Signed,
|
||||
&env.url,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
user,
|
||||
&policy.to_string(),
|
||||
)
|
||||
.await?;
|
||||
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, user, user).await?;
|
||||
Ok(Client::from_conf(
|
||||
build_test_s3_config(&env.url, user, secret, None, "location-policy")
|
||||
.to_builder()
|
||||
.region(Region::new("eu-west-1"))
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_bucket_location_constraint_policy_allow() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
|
||||
let root = Client::from_conf(
|
||||
env.create_s3_client()
|
||||
.config()
|
||||
.to_builder()
|
||||
.region(Region::new("eu-west-1"))
|
||||
.build(),
|
||||
);
|
||||
let configuration = |region: &str| {
|
||||
CreateBucketConfiguration::builder()
|
||||
.location_constraint(BucketLocationConstraint::from(region))
|
||||
.build()
|
||||
};
|
||||
|
||||
for (user, condition, accepts_region, accepts_alias, accepts_missing) in [
|
||||
(
|
||||
"location-allow",
|
||||
serde_json::json!({"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"location-alias",
|
||||
serde_json::json!({"StringEquals":{"s3:LocationConstraint":"EU"}}),
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"location-null",
|
||||
serde_json::json!({"Null":{"s3:LocationConstraint":"true"}}),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let client = location_policy_client(
|
||||
&env,
|
||||
user,
|
||||
serde_json::json!([{
|
||||
"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*", "Condition":condition,
|
||||
}]),
|
||||
)
|
||||
.await?;
|
||||
for (index, (config, allowed)) in [
|
||||
(Some(configuration("eu-west-1")), accepts_region),
|
||||
(Some(configuration("EU")), accepts_alias),
|
||||
(None, accepts_missing),
|
||||
(Some(CreateBucketConfiguration::builder().build()), accepts_missing),
|
||||
(Some(configuration("")), accepts_missing),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let bucket = format!("{user}-{index}");
|
||||
let result = client
|
||||
.create_bucket()
|
||||
.bucket(&bucket)
|
||||
.set_create_bucket_configuration(config)
|
||||
.send()
|
||||
.await;
|
||||
if allowed {
|
||||
result.unwrap_or_else(|error| panic!("{bucket} should be allowed: {error:?}"));
|
||||
root.head_bucket().bucket(&bucket).send().await?;
|
||||
} else {
|
||||
let denied = result.expect_err("the requested XML constraint must satisfy the conditional Allow");
|
||||
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403), "{bucket}");
|
||||
assert_eq!(
|
||||
denied.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("AccessDenied"),
|
||||
"{bucket}"
|
||||
);
|
||||
let absent = root
|
||||
.head_bucket()
|
||||
.bucket(&bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("denied bucket must not be created");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
}
|
||||
}
|
||||
}
|
||||
let object_lock_client = location_policy_client(
|
||||
&env,
|
||||
"location-lock",
|
||||
serde_json::json!([
|
||||
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
|
||||
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}},
|
||||
{"Effect":"Allow", "Action":["s3:PutBucketObjectLockConfiguration", "s3:PutBucketVersioning"],
|
||||
"Resource":"arn:aws:s3:::location-*", "Condition":{"Null":{"s3:LocationConstraint":"true"}}}
|
||||
]),
|
||||
)
|
||||
.await?;
|
||||
object_lock_client
|
||||
.create_bucket()
|
||||
.bucket("location-with-lock")
|
||||
.create_bucket_configuration(configuration("eu-west-1"))
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await
|
||||
.expect("only CreateBucket receives the XML condition; dependent actions see it absent");
|
||||
let object_lock = root
|
||||
.get_object_lock_configuration()
|
||||
.bucket("location-with-lock")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
object_lock
|
||||
.object_lock_configuration()
|
||||
.and_then(|configuration| configuration.object_lock_enabled())
|
||||
.map(|value| value.as_str()),
|
||||
Some("Enabled")
|
||||
);
|
||||
let versioning = root.get_bucket_versioning().bucket("location-with-lock").send().await?;
|
||||
assert_eq!(versioning.status().map(|value| value.as_str()), Some("Enabled"));
|
||||
|
||||
let user = "location-session";
|
||||
location_policy_client(
|
||||
&env,
|
||||
user,
|
||||
serde_json::json!([{
|
||||
"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"
|
||||
}]),
|
||||
)
|
||||
.await?;
|
||||
let session_policy = serde_json::json!({"Version":"2012-10-17", "Statement":[
|
||||
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"},
|
||||
{"Effect":"Deny", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
|
||||
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}}
|
||||
]});
|
||||
let assumed = build_test_sts_client(&env.url, user, "location-policy-test-secret", None, "location-policy-sts")
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/location-policy")
|
||||
.role_session_name("location-policy-session")
|
||||
.policy(session_policy.to_string())
|
||||
.send()
|
||||
.await?;
|
||||
let credentials = assumed.credentials().expect("AssumeRole returns temporary credentials");
|
||||
let session = Client::from_conf(
|
||||
build_test_s3_config(
|
||||
&env.url,
|
||||
credentials.access_key_id(),
|
||||
credentials.secret_access_key(),
|
||||
Some(credentials.session_token()),
|
||||
"location-policy-session",
|
||||
)
|
||||
.to_builder()
|
||||
.region(Region::new("eu-west-1"))
|
||||
.build(),
|
||||
);
|
||||
let denied = session
|
||||
.create_bucket()
|
||||
.bucket("location-session-denied")
|
||||
.create_bucket_configuration(configuration("eu-west-1"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the STS session's conditional Deny must be enforced");
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
let absent = root
|
||||
.head_bucket()
|
||||
.bucket("location-session-denied")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("session denial must not create a bucket");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
session.create_bucket().bucket("location-session-allowed").send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_bucket_location_constraint_policy_deny() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
|
||||
let root = Client::from_conf(
|
||||
env.create_s3_client()
|
||||
.config()
|
||||
.to_builder()
|
||||
.region(Region::new("eu-west-1"))
|
||||
.build(),
|
||||
);
|
||||
let user = "location-deny";
|
||||
let client = location_policy_client(
|
||||
&env,
|
||||
user,
|
||||
serde_json::json!([
|
||||
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"},
|
||||
{"Effect":"Deny", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
|
||||
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}},
|
||||
]),
|
||||
)
|
||||
.await?;
|
||||
let denied = client
|
||||
.create_bucket()
|
||||
.bucket("location-deny-matching")
|
||||
.create_bucket_configuration(
|
||||
CreateBucketConfiguration::builder()
|
||||
.location_constraint(BucketLocationConstraint::from("eu-west-1"))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a matching explicit Deny must override the unconditional Allow");
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
let absent = root
|
||||
.head_bucket()
|
||||
.bucket("location-deny-matching")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("denied bucket must not exist");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
client.create_bucket().bucket("location-deny-missing").send().await?;
|
||||
|
||||
let xml = r#"<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><LocationConstraint>eu-west-1</LocationConstraint></CreateBucketConfiguration>"#;
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert("locationconstraint", http::HeaderValue::from_static("us-east-1"));
|
||||
for (index, body) in [xml.to_owned(), xml.replace("eu-west-1", "eu-west-1")]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let bucket = format!("location-deny-spoofed-{index}");
|
||||
let spoofed = signed_s3_request_with_headers(
|
||||
http::Method::PUT,
|
||||
&format!("{}/{bucket}?LocationConstraint=us-east-1", env.url),
|
||||
Some(body),
|
||||
Some("application/xml"),
|
||||
user,
|
||||
"location-policy-test-secret",
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(spoofed.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
assert!(spoofed.text().await?.contains("<Code>AccessDenied</Code>"));
|
||||
let absent = root
|
||||
.head_bucket()
|
||||
.bucket(&bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("spoofed conditions must not create a bucket");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
}
|
||||
|
||||
let bucket = "location-anonymous";
|
||||
root.create_bucket().bucket(bucket).send().await?;
|
||||
for (effect, match_status, missing_status) in [
|
||||
("Allow", reqwest::StatusCode::CONFLICT, reqwest::StatusCode::FORBIDDEN),
|
||||
("Deny", reqwest::StatusCode::FORBIDDEN, reqwest::StatusCode::CONFLICT),
|
||||
] {
|
||||
let mut statements = vec![];
|
||||
if effect == "Deny" {
|
||||
statements.push(serde_json::json!({"Effect":"Allow", "Principal":"*", "Action":"s3:CreateBucket", "Resource":format!("arn:aws:s3:::{bucket}")}));
|
||||
}
|
||||
statements.push(serde_json::json!({"Effect":effect, "Principal":"*", "Action":"s3:CreateBucket", "Resource":format!("arn:aws:s3:::{bucket}"),
|
||||
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}}));
|
||||
root.put_bucket_policy()
|
||||
.bucket(bucket)
|
||||
.policy(serde_json::json!({"Version":"2012-10-17", "Statement":statements}).to_string())
|
||||
.send()
|
||||
.await?;
|
||||
let unsigned = local_http_client();
|
||||
for (body, expected) in [(xml, match_status), ("", missing_status)] {
|
||||
let response = unsigned
|
||||
.put(format!("{}/{bucket}", env.url))
|
||||
.header("content-type", "application/xml")
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.status(), expected, "anonymous {effect}, XML present={}", !body.is_empty());
|
||||
let response = response.text().await?;
|
||||
assert!(response.contains(if expected == reqwest::StatusCode::FORBIDDEN {
|
||||
"<Code>AccessDenied</Code>"
|
||||
} else {
|
||||
"<Code>BucketAlreadyExists</Code>"
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use futures::{StreamExt, TryStreamExt, stream};
|
||||
use rustfs_data_usage::DataUsageInfo;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
@@ -74,16 +75,23 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
|
||||
// Create bucket and upload objects
|
||||
client.create_bucket().bucket(TEST_BUCKET).send().await?;
|
||||
|
||||
for i in 0..1000 {
|
||||
let key = format!("obj-{i:04}");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"hello-world"))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
// Keep the full fixture and await every durable PUT before checking publication.
|
||||
stream::iter(0..1000)
|
||||
.map(Ok)
|
||||
.try_for_each_concurrent(16, |i| {
|
||||
let client = &client;
|
||||
async move {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(format!("obj-{i:04}"))
|
||||
.body(ByteStream::from_static(b"hello-world"))
|
||||
.send()
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let usage = wait_for_bucket_usage(&env, TEST_BUCKET, |usage| {
|
||||
usage
|
||||
|
||||
@@ -24,6 +24,163 @@
|
||||
//! `xl.meta` immediately visible, and the old file-cache shortcut has been
|
||||
//! removed from the read path.
|
||||
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::retry::RetryConfig;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
async fn delete_exact_marker_versions(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
versions: &[(String, bool)],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let objects = versions
|
||||
.iter()
|
||||
.map(|(version, _)| ObjectIdentifier::builder().key(key).version_id(version).build())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let response = client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(Delete::builder().set_objects(Some(objects)).build()?)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(response.errors().is_empty(), "explicit batch returned errors: {response:?}");
|
||||
let mut actual = BTreeMap::new();
|
||||
for deleted in response.deleted() {
|
||||
assert_eq!(deleted.key(), Some(key));
|
||||
*actual
|
||||
.entry((
|
||||
deleted.version_id().ok_or("Deleted entry omitted version ID")?.to_string(),
|
||||
deleted.delete_marker().unwrap_or(false),
|
||||
deleted.delete_marker_version_id().map(str::to_string),
|
||||
))
|
||||
.or_insert(0_usize) += 1;
|
||||
}
|
||||
let mut expected = BTreeMap::new();
|
||||
for (version, marker) in versions {
|
||||
*expected
|
||||
.entry((version.clone(), *marker, marker.then(|| version.clone())))
|
||||
.or_insert(0_usize) += 1;
|
||||
}
|
||||
assert_eq!(actual, expected, "every requested occurrence must report its own version identity");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn assert_explicit_marker_batch_cleanup(
|
||||
clients: &[Client],
|
||||
bucket: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
assert!(!clients.is_empty());
|
||||
let clients: Vec<_> = clients
|
||||
.iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let writer = &clients[0];
|
||||
let marker_writer = &clients[1 % clients.len()];
|
||||
let deleter = &clients[2 % clients.len()];
|
||||
let key = "same-key-history";
|
||||
writer.create_bucket().bucket(bucket).send().await?;
|
||||
writer
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let mut data = Vec::new();
|
||||
for byte in [41_u8, 73_u8] {
|
||||
let body = vec![byte; 1024 * 1024];
|
||||
let uploaded = writer
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(body.clone().into())
|
||||
.send()
|
||||
.await?;
|
||||
data.push((uploaded.version_id().ok_or("versioned PUT omitted ID")?.to_string(), body));
|
||||
}
|
||||
let mut markers = Vec::with_capacity(16);
|
||||
for _ in 0..16 {
|
||||
let deleted = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(deleted.delete_marker(), Some(true));
|
||||
markers.push(deleted.version_id().ok_or("marker creation omitted ID")?.to_string());
|
||||
}
|
||||
let mut requested: Vec<_> = markers.iter().map(|id| (id.clone(), true)).collect();
|
||||
requested.push((markers[0].clone(), true));
|
||||
requested.push((markers[15].clone(), true));
|
||||
delete_exact_marker_versions(deleter, bucket, key, &requested).await?;
|
||||
for client in &clients {
|
||||
let versions = client.list_object_versions().bucket(bucket).send().await?;
|
||||
assert!(versions.delete_markers().is_empty(), "marker survived successful batch: {versions:?}");
|
||||
let mut actual: Vec<_> = versions
|
||||
.versions()
|
||||
.iter()
|
||||
.filter_map(|version| version.version_id())
|
||||
.collect();
|
||||
actual.sort_unstable();
|
||||
let mut expected: Vec<_> = data.iter().map(|(id, _)| id.as_str()).collect();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
let current = client.get_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(current.body.collect().await?.into_bytes().as_ref(), data[1].1.as_slice());
|
||||
let historical = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(&data[0].0)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), data[0].1.as_slice());
|
||||
}
|
||||
let uuid_marker = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
|
||||
let uuid_marker = uuid_marker.version_id().ok_or("marker creation omitted UUID")?.to_string();
|
||||
writer
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Suspended)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let null_marker = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(null_marker.delete_marker(), Some(true));
|
||||
assert_eq!(null_marker.version_id(), Some("null"));
|
||||
let requested = [
|
||||
(uuid_marker.clone(), true),
|
||||
(data[1].0.clone(), false),
|
||||
("null".to_string(), true),
|
||||
(data[0].0.clone(), false),
|
||||
(uuid_marker, true),
|
||||
];
|
||||
delete_exact_marker_versions(deleter, bucket, key, &requested).await?;
|
||||
for client in &clients {
|
||||
let versions = client.list_object_versions().bucket(bucket).send().await?;
|
||||
assert!(versions.versions().is_empty() && versions.delete_markers().is_empty(), "{versions:?}");
|
||||
}
|
||||
clients
|
||||
.last()
|
||||
.expect("nonempty clients")
|
||||
.delete_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
@@ -35,6 +192,16 @@ mod tests {
|
||||
env.create_s3_client()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_explicit_delete_marker_batch_removes_versions_without_client_retries()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await?;
|
||||
super::assert_explicit_marker_batch_cleanup(&[env.create_s3_client()], "explicit-marker-batch").await
|
||||
}
|
||||
|
||||
/// Regression test for Issue #1878.
|
||||
///
|
||||
/// Verifies that after calling `delete_objects` (the batch plural API) on
|
||||
|
||||
@@ -34,11 +34,12 @@ use crate::common::{
|
||||
};
|
||||
use crate::replication_extension_test::LOOPBACK_REPLICATION_TARGET_ENV;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::retry::RetryConfig;
|
||||
use aws_sdk_s3::config::{Credentials, retry::RetryConfig};
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use futures::future::try_join_all;
|
||||
use http::{Method, StatusCode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -77,6 +78,7 @@ pub(crate) struct DistCluster {
|
||||
pub cluster: RustFSTestClusterEnvironment,
|
||||
pool_storage_roots: Option<Vec<PathBuf>>,
|
||||
owned_pool_dirs: Vec<PathBuf>,
|
||||
capture_log_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl DistCluster {
|
||||
@@ -128,11 +130,12 @@ impl DistCluster {
|
||||
for &(key, value) in extra_env {
|
||||
cluster.set_env(key, value);
|
||||
}
|
||||
configure_node_logs(&mut cluster)?;
|
||||
configure_node_logs(&mut cluster, None)?;
|
||||
Ok(Self {
|
||||
cluster,
|
||||
pool_storage_roots,
|
||||
owned_pool_dirs,
|
||||
capture_log_dir: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,17 +173,34 @@ impl DistCluster {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append every node's stdout/stderr to `directory/node-<idx>.log`, including
|
||||
/// nodes added by later pool expansion. Call before the first start so startup
|
||||
/// events such as the resolved durability mode are captured.
|
||||
pub fn capture_logs_under(&mut self, directory: &Path) -> TestResult {
|
||||
self.capture_log_dir = Some(directory.to_path_buf());
|
||||
configure_node_logs(&mut self.cluster, Some(directory))
|
||||
}
|
||||
|
||||
pub fn client_with_credentials(&self, node_idx: usize, access_key: &str, secret_key: &str) -> TestResult<Client> {
|
||||
if node_idx >= self.cluster.nodes.len() {
|
||||
return Err("node_idx is invalid".into());
|
||||
}
|
||||
Ok(Client::from_conf(build_test_s3_config(
|
||||
&self.cluster.nodes[node_idx].url,
|
||||
access_key,
|
||||
secret_key,
|
||||
None,
|
||||
"cluster-iam-test",
|
||||
)))
|
||||
let config = self.cluster.create_s3_client(node_idx)?.config().to_builder();
|
||||
Ok(Client::from_conf(
|
||||
config
|
||||
.credentials_provider(Credentials::new(access_key, secret_key, None, None, "cluster-iam-test"))
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Stop the fleet, drop retired pools from the command line, and restart
|
||||
/// the remaining pools, which are renumbered from zero.
|
||||
pub async fn remove_retired_pools_and_restart(&mut self, retired: &[usize]) -> TestResult {
|
||||
self.stop_all_gracefully().await?;
|
||||
self.cluster.remove_single_node_pools(retired)?;
|
||||
self.cluster.start().await?;
|
||||
wait_for_ready(&self.cluster).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn append_pool_and_restart(&mut self) -> TestResult<usize> {
|
||||
@@ -192,7 +212,7 @@ impl DistCluster {
|
||||
.ok_or("pool expansion requires configured isolated pool filesystems")?;
|
||||
self.owned_pool_dirs
|
||||
.push(relocate_pool_storage(&mut self.cluster, node_idx, roots)?);
|
||||
configure_node_logs(&mut self.cluster)?;
|
||||
configure_node_logs(&mut self.cluster, self.capture_log_dir.as_deref())?;
|
||||
self.cluster.start().await?;
|
||||
wait_for_ready(&self.cluster).await?;
|
||||
Ok(node_idx)
|
||||
@@ -326,7 +346,19 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
|
||||
Ok(pool_run_root)
|
||||
}
|
||||
|
||||
fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
|
||||
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
|
||||
if let Some(directory) = capture_dir {
|
||||
std::fs::create_dir_all(directory)?;
|
||||
for node_idx in 0..cluster.nodes.len() {
|
||||
// Overrides a path assigned from RUSTFS_E2E_LOG_DIR so every node of
|
||||
// the run, including the seed node, logs into the same directory.
|
||||
let path = directory.join(format!("node-{node_idx}.log"));
|
||||
if cluster.node_capture_log_paths[node_idx].as_deref() != Some(path.to_string_lossy().as_ref()) {
|
||||
cluster.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -656,13 +688,50 @@ pub(crate) async fn wait_for_replicated_bytes(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Quota stats for `bucket` from every node at once. Each node answers from
|
||||
/// its own cached copy of the persisted usage snapshot, and only the scanner
|
||||
/// leader's save invalidates that copy. Leadership goes to whichever node
|
||||
/// starts its scanner first, so a probe pinned to one node can wait out the
|
||||
/// whole cache TTL on a node that never saves.
|
||||
pub(crate) async fn quota_stats_on_every_node(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
bucket: &str,
|
||||
) -> TestResult<Vec<(StatusCode, String)>> {
|
||||
let path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
|
||||
try_join_all(
|
||||
cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| admin_request(&node.url, Method::GET, &path, None, &cluster.access_key, &cluster.secret_key)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `current_usage` from a quota-stats body. `None` means the node has no
|
||||
/// scanner usage for the bucket yet: a quota admitted by the reservation
|
||||
/// ledger reports `null` rather than a guess.
|
||||
pub(crate) fn quota_stats_current_usage(body: &str) -> TestResult<Option<u64>> {
|
||||
let stats: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
|
||||
match stats.get("current_usage") {
|
||||
Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(usage) => Ok(Some(
|
||||
usage
|
||||
.as_u64()
|
||||
.ok_or_else(|| format!("quota stats current_usage is not a byte count: {stats}"))?,
|
||||
)),
|
||||
None => Err(format!("quota stats omitted current_usage: {stats}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, bucket: &str, quota_bytes: u64) -> TestResult {
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
|| async {
|
||||
let (status, _) =
|
||||
cluster_admin(cluster, Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
|
||||
Ok(status.is_success() || status == StatusCode::NOT_FOUND)
|
||||
let stats = quota_stats_on_every_node(cluster, bucket).await?;
|
||||
Ok(stats
|
||||
.iter()
|
||||
.any(|(status, _)| status.is_success() || *status == StatusCode::NOT_FOUND))
|
||||
},
|
||||
"quota stats ready",
|
||||
)
|
||||
@@ -686,23 +755,44 @@ pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, buc
|
||||
.await
|
||||
}
|
||||
|
||||
/// The existing decommission cases wait up to 180 s for movement to finish; a
|
||||
/// start refused because a movement gate is busy is retried within the same budget.
|
||||
const DECOMMISSION_START_BUSY_BUDGET: Duration = Duration::from_secs(180);
|
||||
const DECOMMISSION_START_PROOF_BUDGET: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Start decommission and fail closed unless the admin API acknowledges it.
|
||||
pub(crate) async fn start_decommission(cluster: &RustFSTestClusterEnvironment, pool_id: usize) -> TestResult {
|
||||
let path = format!("/rustfs/admin/v3/pools/decommission?pool={pool_id}&by-id=true");
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
post_decommission_start(cluster, &path, DECOMMISSION_START_BUSY_BUDGET).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST a decommission start and return the response body. A start that meets
|
||||
/// a busy movement gate answers a retryable 503 and is retried within
|
||||
/// `busy_budget`; the init stage's missing fleet capability proof (500) is
|
||||
/// retried for 45 s. Every other status is fatal.
|
||||
pub(crate) async fn post_decommission_start(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
path: &str,
|
||||
busy_budget: Duration,
|
||||
) -> TestResult<String> {
|
||||
let started = Instant::now();
|
||||
let mut delay = Duration::from_millis(250);
|
||||
loop {
|
||||
let (status, response) = cluster_admin(cluster, Method::POST, &path, None).await?;
|
||||
let (status, response) = cluster_admin(cluster, Method::POST, path, None).await?;
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
return Ok(response);
|
||||
}
|
||||
if status == StatusCode::INTERNAL_SERVER_ERROR
|
||||
let elapsed = started.elapsed();
|
||||
let busy = status == StatusCode::SERVICE_UNAVAILABLE && elapsed < busy_budget;
|
||||
let awaiting_proof = status == StatusCode::INTERNAL_SERVER_ERROR
|
||||
&& response.contains("requires a live fleet capability proof")
|
||||
&& Instant::now() < deadline
|
||||
{
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
&& elapsed < DECOMMISSION_START_PROOF_BUDGET;
|
||||
if !busy && !awaiting_proof {
|
||||
return Err(format!("POST {path} did not start decommission: {status} {response}").into());
|
||||
}
|
||||
return Err(format!("POST {path} did not start decommission: {status} {response}").into());
|
||||
sleep(delay).await;
|
||||
delay = (delay * 2).min(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,6 +975,21 @@ pub(crate) async fn rebalance_status_json(cluster: &RustFSTestClusterEnvironment
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
/// Mirrors `REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX` in `crates/ecstore/src/services/rebalance`.
|
||||
const REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX: &str = "deferred transient rebalance entry failure:";
|
||||
|
||||
/// A transient entry failure (for example a write quorum miss on a busy
|
||||
/// target) is recorded as `lastError` and retried at the end of the run; the
|
||||
/// worker clears it when the pool completes. It is only acceptable while the
|
||||
/// pool is still started: a terminal pool keeping it is a real failure.
|
||||
fn is_retrying_deferred_rebalance_error(pool: &serde_json::Value, error: &str) -> bool {
|
||||
error.starts_with(REBALANCE_DEFERRED_ENTRY_ERROR_PREFIX)
|
||||
&& pool
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|status| status.eq_ignore_ascii_case("started"))
|
||||
}
|
||||
|
||||
fn validate_rebalance_status<'a>(status: &'a serde_json::Value, expected_id: &str) -> TestResult<&'a [serde_json::Value]> {
|
||||
let id = status
|
||||
.get("id")
|
||||
@@ -912,7 +1017,7 @@ fn validate_rebalance_status<'a>(status: &'a serde_json::Value, expected_id: &st
|
||||
if pool
|
||||
.get("lastError")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|error| !error.is_empty())
|
||||
.is_some_and(|error| !error.is_empty() && !is_retrying_deferred_rebalance_error(pool, error))
|
||||
{
|
||||
return Err(format!("rebalance reported lastError: {status}").into());
|
||||
}
|
||||
@@ -1259,6 +1364,29 @@ async fn append_single_node_pool_extends_ellipses_volumes() {
|
||||
assert!(volumes.contains("/drive{0...1}"), "expanded layout must keep drive ellipses: {volumes}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_single_node_pools_drops_retired_pools_and_renumbers() {
|
||||
let mut env =
|
||||
RustFSTestClusterEnvironment::with_topology(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1], vec![2], vec![3]]))
|
||||
.await
|
||||
.expect("four-pool topology");
|
||||
let kept: Vec<String> = env.nodes[2..].iter().map(|node| node.address.clone()).collect();
|
||||
|
||||
env.remove_single_node_pools(&[0, 1]).expect("remove two retired pools");
|
||||
assert_eq!(env.nodes.len(), 2);
|
||||
assert_eq!(env.nodes.iter().map(|node| node.address.clone()).collect::<Vec<_>>(), kept);
|
||||
assert_eq!(env.nodes.iter().map(|node| node.pool_idx).collect::<Vec<_>>(), vec![0, 1]);
|
||||
let volumes = env.rustfs_volumes_arg();
|
||||
assert_eq!(volumes.split(' ').count(), 2, "expected two pool arguments, got: {volumes}");
|
||||
assert!(
|
||||
volumes.contains(&kept[0]) && volumes.contains(&kept[1]),
|
||||
"retired pools must leave the command line: {volumes}"
|
||||
);
|
||||
|
||||
env.remove_single_node_pools(&[0, 1])
|
||||
.expect_err("the last remaining pool cannot be removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_single_node_pool_rejects_striped_single_pool() {
|
||||
let mut env = RustFSTestClusterEnvironment::new(4).await.expect("four-node single pool");
|
||||
@@ -1412,3 +1540,23 @@ fn rebalance_complete_accepts_non_participating_pools_but_requires_progress() {
|
||||
});
|
||||
assert!(!rebalance_complete(&no_movement, "run-1").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebalance_status_tolerates_deferred_entry_error_only_while_started() {
|
||||
let deferred = "deferred transient rebalance entry failure: Storage resources are insufficient for the write operation: b/k";
|
||||
let mut status = serde_json::json!({
|
||||
"id": "run-1",
|
||||
"pools": [{ "id": 0, "status": "Started", "stopping": false, "lastError": deferred, "progress": { "objects": 1, "bytes": 1 } }]
|
||||
});
|
||||
assert!(rebalance_active(&status, "run-1").expect("a retried deferral keeps the run active"));
|
||||
|
||||
status["pools"][0]["status"] = serde_json::json!("Completed");
|
||||
assert!(
|
||||
rebalance_complete(&status, "run-1").is_err(),
|
||||
"a completed pool must have cleared its deferred entry error"
|
||||
);
|
||||
|
||||
status["pools"][0]["status"] = serde_json::json!("Started");
|
||||
status["pools"][0]["lastError"] = serde_json::json!("rebalance entry failed: Storage resources are insufficient");
|
||||
assert!(rebalance_active(&status, "run-1").is_err(), "a non-deferred lastError stays fatal");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
|
||||
use crate::tls_gen;
|
||||
use aws_sdk_s3::{Client, config::retry::RetryConfig, primitives::ByteStream};
|
||||
use rand::{Rng, SeedableRng, rngs::StdRng};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
struct ExpectedRead {
|
||||
range: Option<(u64, u64)>,
|
||||
length: u64,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
struct ObjectFixture {
|
||||
key: String,
|
||||
path: PathBuf,
|
||||
length: u64,
|
||||
reads: Vec<ExpectedRead>,
|
||||
}
|
||||
|
||||
fn fixture(directory: &Path, length: u64) -> TestResult<ObjectFixture> {
|
||||
let key = format!("object-{length}");
|
||||
let path = directory.join(&key);
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
let mut rng = StdRng::seed_from_u64(length);
|
||||
let mut block = vec![0; usize::try_from(MIB)?];
|
||||
let mut remaining = length;
|
||||
let mut digest = Sha256::new();
|
||||
while remaining != 0 {
|
||||
let count = usize::try_from(remaining.min(MIB))?;
|
||||
rng.fill_bytes(&mut block[..count]);
|
||||
file.write_all(&block[..count])?;
|
||||
digest.update(&block[..count]);
|
||||
remaining -= u64::try_from(count)?;
|
||||
}
|
||||
drop(file);
|
||||
let mut reads = vec![ExpectedRead {
|
||||
range: None,
|
||||
length,
|
||||
sha256: hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower),
|
||||
}];
|
||||
// Unaligned ranges cross TLS frames and EC blocks; the tail detects
|
||||
// truncation and incorrect range offsets independently of a full GET.
|
||||
for (start, end) in [(17, (MIB + 31).min(length - 1)), (length.saturating_sub(65537), length - 1)] {
|
||||
let mut input = std::fs::File::open(&path)?;
|
||||
input.seek(SeekFrom::Start(start))?;
|
||||
let range_length = end - start + 1;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut input = input.take(range_length);
|
||||
loop {
|
||||
let count = input.read(&mut block)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&block[..count]);
|
||||
}
|
||||
reads.push(ExpectedRead {
|
||||
range: Some((start, end)),
|
||||
length: range_length,
|
||||
sha256: hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower),
|
||||
});
|
||||
}
|
||||
Ok(ObjectFixture {
|
||||
key,
|
||||
path,
|
||||
length,
|
||||
reads,
|
||||
})
|
||||
}
|
||||
|
||||
async fn verified_get(
|
||||
client: &Client,
|
||||
node: usize,
|
||||
bucket: &str,
|
||||
object: &ObjectFixture,
|
||||
expected: &ExpectedRead,
|
||||
concurrent: bool,
|
||||
) -> TestResult<serde_json::Value> {
|
||||
let started = Instant::now();
|
||||
let mut received = 0u64;
|
||||
let mut stage = "response headers";
|
||||
tokio::time::timeout(READ_TIMEOUT, async {
|
||||
let mut request = client.get_object().bucket(bucket).key(&object.key);
|
||||
if let Some((start, end)) = expected.range {
|
||||
request = request.range(format!("bytes={start}-{end}"));
|
||||
}
|
||||
let mut output = request.send().await?;
|
||||
assert_eq!(output.content_length(), Some(i64::try_from(expected.length)?));
|
||||
if let Some((start, end)) = expected.range {
|
||||
assert_eq!(output.content_range(), Some(format!("bytes {start}-{end}/{}", object.length).as_str()));
|
||||
} else {
|
||||
assert_eq!(output.content_range(), None);
|
||||
}
|
||||
stage = "response body";
|
||||
let mut digest = Sha256::new();
|
||||
while let Some(chunk) = output.body.next().await {
|
||||
let chunk = chunk?;
|
||||
received += u64::try_from(chunk.len())?;
|
||||
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
|
||||
digest.update(&chunk);
|
||||
}
|
||||
let actual_sha256 = hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower);
|
||||
assert_eq!(received, expected.length, "node {node}: truncated GET {}", object.key);
|
||||
assert_eq!(actual_sha256, expected.sha256, "node {node}: corrupt GET {}", object.key);
|
||||
eprintln!(
|
||||
"TLS acceptance: GET node={node}, key={}, range={:?}, concurrent={concurrent}, bytes={received}, elapsed={:?}",
|
||||
object.key, expected.range, started.elapsed()
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"operation": "get", "node": node, "key": object.key,
|
||||
"range": expected.range, "bytes": received, "sha256": actual_sha256,
|
||||
"concurrent": concurrent, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"TLS GET timed out: node={node}, key={}, range={:?}, concurrent={concurrent}, stage={stage}, received={received}/{}, elapsed={:?}: {error}",
|
||||
object.key, expected.range, expected.length, started.elapsed()
|
||||
),
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
/// This case always exercises the full acceptance sizes; do not substitute
|
||||
/// tiny objects and count that as a large distributed-read pass.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
|
||||
let mode = std::env::var("RUSTFS_E2E_INTERNODE_HTTP_MODE").unwrap_or_else(|_| "http1".into());
|
||||
let protocol = match mode.as_str() {
|
||||
"http1" => "http1",
|
||||
"auto" | "unpooled" => "auto",
|
||||
_ => return Err("RUSTFS_E2E_INTERNODE_HTTP_MODE must be http1, auto or unpooled".into()),
|
||||
};
|
||||
let mut dist = DistCluster::new_stopped_with_env(
|
||||
DistLayout::FourByFour,
|
||||
&[
|
||||
("RUSTFS_INTERNODE_HTTP_PROTOCOL", protocol),
|
||||
("RUSTFS_SCANNER_ENABLED", "true"),
|
||||
("RUSTFS_SCANNER_START_DELAY_SECS", "0"),
|
||||
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||
("RUSTFS_HEAL_ENABLED", "true"),
|
||||
("RUST_LOG", "warn"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if mode == "unpooled" {
|
||||
dist.cluster.set_env("RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST", "0");
|
||||
}
|
||||
let directory = PathBuf::from(&dist.cluster.temp_dir);
|
||||
let tls_directory = directory.join("tls");
|
||||
tls_gen::run(tls_gen::Args {
|
||||
out_dir: tls_directory.clone(),
|
||||
days: 1,
|
||||
force: false,
|
||||
})?;
|
||||
dist.cluster.enable_tls(&tls_directory).await?;
|
||||
let report_path = std::env::var_os("RUSTFS_INTERNODE_ACCEPTANCE_REPORT").map(PathBuf::from);
|
||||
if let Some(path) = &report_path
|
||||
&& (path.exists() || path.with_extension("pids.json").exists())
|
||||
{
|
||||
return Err("acceptance report paths must be unused".into());
|
||||
}
|
||||
let log_directory = report_path.as_ref().map_or_else(
|
||||
|| {
|
||||
std::env::var_os("RUSTFS_E2E_LOG_DIR")
|
||||
.map_or_else(|| directory.join("logs"), |root| PathBuf::from(root).join(unique_bucket("internode-tls")))
|
||||
},
|
||||
|path| path.with_extension("logs"),
|
||||
);
|
||||
std::fs::create_dir(&log_directory)?;
|
||||
dist.capture_logs_under(&log_directory)?;
|
||||
dist.cluster.start().await?;
|
||||
let clients = dist
|
||||
.clients()?
|
||||
.into_iter()
|
||||
.map(|client| Client::from_conf(client.config().to_builder().retry_config(RetryConfig::disabled()).build()))
|
||||
.collect::<Vec<_>>();
|
||||
let bucket = unique_bucket("internode-tls");
|
||||
clients[0].create_bucket().bucket(&bucket).send().await?;
|
||||
let pids = dist
|
||||
.cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.process.as_ref().map(std::process::Child::id))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(path) = &report_path {
|
||||
std::fs::write(path.with_extension("pids.json"), serde_json::to_vec(&pids)?)?;
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
for length in [32 * 1024, 100 * MIB, 1024 * MIB] {
|
||||
let fixture_directory = directory.clone();
|
||||
let object = Arc::new(tokio::task::spawn_blocking(move || fixture(&fixture_directory, length)).await??);
|
||||
eprintln!("TLS acceptance: fixture key={}, bytes={length}", object.key);
|
||||
let started = Instant::now();
|
||||
tokio::time::timeout(
|
||||
READ_TIMEOUT,
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object.key)
|
||||
.body(ByteStream::from_path(&object.path).await?)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"TLS PUT timed out: key={}, bytes={length}, elapsed={:?}: {error}",
|
||||
object.key,
|
||||
started.elapsed()
|
||||
),
|
||||
)
|
||||
})??;
|
||||
eprintln!("TLS acceptance: PUT key={}, bytes={length}, elapsed={:?}", object.key, started.elapsed());
|
||||
results.push(
|
||||
serde_json::json!({"operation": "put", "bytes": length, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0}),
|
||||
);
|
||||
if length == 32 * 1024 {
|
||||
let alternate = dist.client_with_credentials(0, &dist.cluster.access_key, &dist.cluster.secret_key)?;
|
||||
let alternate = Client::from_conf(alternate.config().to_builder().retry_config(RetryConfig::disabled()).build());
|
||||
results.push(verified_get(&alternate, 0, &bucket, &object, &object.reads[0], false).await?);
|
||||
let invalid = dist.client_with_credentials(0, "invalid-tls-user", "invalid-tls-secret-password")?;
|
||||
let invalid = Client::from_conf(invalid.config().to_builder().retry_config(RetryConfig::disabled()).build());
|
||||
let denied = invalid
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect_err("alternate credentials must not inherit the root identity");
|
||||
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
}
|
||||
for expected in &object.reads {
|
||||
for (node, client) in clients.iter().enumerate() {
|
||||
results.push(verified_get(client, node, &bucket, &object, expected, false).await?);
|
||||
}
|
||||
}
|
||||
// Each reader owns a Tokio task and its cooperative budget. Polling
|
||||
// every body and synchronous digest in one parent task can let an
|
||||
// always-ready reader starve its siblings and their deadlines.
|
||||
let mut readers = JoinSet::new();
|
||||
for (node, client) in clients.iter().enumerate() {
|
||||
let client = client.clone();
|
||||
let bucket = bucket.clone();
|
||||
let object = Arc::clone(&object);
|
||||
readers.spawn(async move { verified_get(&client, node, &bucket, &object, &object.reads[0], true).await });
|
||||
}
|
||||
while let Some(result) = readers.join_next().await {
|
||||
results.push(result??);
|
||||
}
|
||||
tokio::fs::remove_file(&object.path).await?;
|
||||
}
|
||||
for node in 0..clients.len() {
|
||||
let log = tokio::fs::read_to_string(log_directory.join(format!("node-{node}.log"))).await?;
|
||||
assert!(
|
||||
!log.contains("internode body stalled"),
|
||||
"node {node}: internode body stall during verified reads"
|
||||
);
|
||||
}
|
||||
let report = serde_json::json!({
|
||||
"schema": 1, "mode": mode, "tls_verified": true, "nodes": 4, "drives_per_node": 4,
|
||||
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||
"source_dirty": env!("RUSTFS_E2E_BUILD_DIRTY"),
|
||||
"scanner_enabled": true, "heal_enabled": true,
|
||||
"scanner_progress_verified": false, "heal_progress_verified": false,
|
||||
"results": results,
|
||||
});
|
||||
if let Some(path) = report_path {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&report)?)?;
|
||||
}
|
||||
eprintln!("internode_tls_acceptance={report}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -26,12 +26,16 @@ mod expand_decommission_rebalance_test;
|
||||
mod extra_test;
|
||||
mod harness;
|
||||
mod heal_test;
|
||||
mod internode_tls_test;
|
||||
mod multipart_fence_acceptance_test;
|
||||
mod object_lock_test;
|
||||
mod observability_test;
|
||||
mod replication_delete_marker_test;
|
||||
mod replication_quota_test;
|
||||
mod s3_basic_test;
|
||||
mod s3_during_data_movement_test;
|
||||
mod scanner_heal_topology_fixture;
|
||||
mod scanner_heal_topology_test;
|
||||
mod site_replication_test;
|
||||
mod upgrade_test;
|
||||
mod versioning_test;
|
||||
|
||||
@@ -0,0 +1,825 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
TestResult, assert_object_bytes, cluster_admin_ok, configure_node_logs, payload_for, retrying_put, sha256_hex, unique_bucket,
|
||||
wait_until,
|
||||
};
|
||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
||||
use crate::rpc_fault_proxy::{RpcFaultProxy, RpcPause};
|
||||
use aws_sdk_s3::{
|
||||
Client,
|
||||
config::retry::RetryConfig,
|
||||
error::{ProvideErrorMetadata, SdkError},
|
||||
operation::create_multipart_upload::{CreateMultipartUploadError, CreateMultipartUploadOutput},
|
||||
primitives::ByteStream,
|
||||
types::{CompletedMultipartUpload, CompletedPart},
|
||||
};
|
||||
use futures::{StreamExt as _, TryStreamExt as _, stream};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const DEADLINE: Duration = Duration::from_secs(45);
|
||||
|
||||
async fn fixture(
|
||||
drives_per_node: usize,
|
||||
storage_class: &str,
|
||||
) -> TestResult<(RustFSTestClusterEnvironment, Vec<RpcFaultProxy>, Vec<Client>, String)> {
|
||||
let mut cluster =
|
||||
RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, drives_per_node)).await?;
|
||||
for (name, value) in [
|
||||
("RUSTFS_STORAGE_CLASS_STANDARD", storage_class),
|
||||
("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "5"),
|
||||
("RUSTFS_OBJECT_LOCK_RPC_TIMEOUT_MS", "3000"),
|
||||
("RUSTFS_DRIVE_METADATA_TIMEOUT_SECS", "2"),
|
||||
("RUSTFS_DRIVE_MAX_TIMEOUT_DURATION", "5"),
|
||||
// Keep disk-health ejection out of a method-specific quorum test.
|
||||
("RUSTFS_DRIVE_SUSPECT_FAILURE_THRESHOLD", "1000"),
|
||||
("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false"),
|
||||
("NO_PROXY", "127.0.0.1,::1,localhost"),
|
||||
("HTTP_PROXY", ""),
|
||||
("HTTPS_PROXY", ""),
|
||||
("RUST_LOG", "warn"),
|
||||
] {
|
||||
cluster.set_env(name, value);
|
||||
}
|
||||
let mut proxies = Vec::new();
|
||||
for node in 0..4 {
|
||||
proxies.push(cluster.start_rpc_proxy_for_node(node).await?);
|
||||
}
|
||||
configure_node_logs(&mut cluster, None)?;
|
||||
cluster.start().await?;
|
||||
let clients = cluster
|
||||
.create_all_clients()?
|
||||
.into_iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let bucket = unique_bucket("mpu-fence");
|
||||
cluster.create_test_bucket(&bucket).await?;
|
||||
Ok((cluster, proxies, clients, bucket))
|
||||
}
|
||||
|
||||
fn create(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
) -> JoinHandle<Result<CreateMultipartUploadOutput, SdkError<CreateMultipartUploadError>>> {
|
||||
let request = client.create_multipart_upload().bucket(bucket).key(key);
|
||||
tokio::spawn(async move { request.send().await })
|
||||
}
|
||||
|
||||
async fn wait_for_faults(pauses: &[RpcPause], methods: &[&str]) -> TestResult {
|
||||
if methods.iter().any(|method| matches!(*method, "Lock" | "LockBatch")) {
|
||||
// Adaptive lock fan-out may not contact an unnecessary voter. All
|
||||
// selected peers remain gated; require an actual scoped request hit.
|
||||
let (result, _, _) = futures::future::select_all(pauses.iter().map(|pause| Box::pin(pause.wait_for_hits(1)))).await;
|
||||
result?;
|
||||
} else {
|
||||
// Storage mutations fan out eagerly. Every selected peer must really
|
||||
// receive this upload's RPC, so an unrelated failure cannot fake quorum.
|
||||
for pause in pauses {
|
||||
pause.wait_for_hits(1).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_unavailable<E: std::fmt::Debug + ProvideErrorMetadata>(error: &SdkError<E>, cause: &str, retry: Option<&str>) {
|
||||
let response = error.raw_response().expect("an S3 service response");
|
||||
assert_eq!(response.status().as_u16(), 503, "{error:?}");
|
||||
assert_eq!(error.as_service_error().and_then(|e| e.meta().code()), Some("ServiceUnavailable"));
|
||||
assert_eq!(response.headers().get("x-rustfs-error-cause"), Some(cause));
|
||||
assert_eq!(response.headers().get("retry-after"), retry);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.is_some_and(|v| v.starts_with("application/xml"))
|
||||
);
|
||||
let request_id = response.headers().get("x-amz-request-id").expect("request correlation ID");
|
||||
assert!(!request_id.is_empty());
|
||||
eprintln!("MPU acceptance: cause={cause}, retry={retry:?}, request_id={request_id}");
|
||||
}
|
||||
|
||||
async fn abort(client: &Client, bucket: &str, key: &str, upload: &str) -> TestResult {
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload)
|
||||
.send()
|
||||
.await?;
|
||||
let missing = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an aborted upload must no longer admit ListParts");
|
||||
assert_eq!(missing.as_service_error().and_then(|e| e.meta().code()), Some("NoSuchUpload"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Fault removal does not synchronously clear existing server leases or drive
|
||||
// health state. Keep the injected operation single-attempt, then require
|
||||
// bounded recovery of the exact cleanup API instead of assuming instant heal.
|
||||
async fn recover_after_fault<T, E, F, Fut>(operation: &str, mut call: F) -> TestResult<Result<T, SdkError<E>>>
|
||||
where
|
||||
E: std::fmt::Debug + ProvideErrorMetadata,
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, SdkError<E>>>,
|
||||
{
|
||||
let started = Instant::now();
|
||||
let mut attempts = 0;
|
||||
let mut last_failure = String::new();
|
||||
let outcome = tokio::time::timeout(DEADLINE, async {
|
||||
loop {
|
||||
attempts += 1;
|
||||
let result = call().await;
|
||||
let retry = result.as_ref().err().and_then(|error| {
|
||||
let response = error.raw_response()?;
|
||||
if response.status().as_u16() != 503
|
||||
|| error.as_service_error()?.meta().code() != Some("ServiceUnavailable")
|
||||
|| !matches!(
|
||||
response.headers().get("x-rustfs-error-cause"),
|
||||
Some(
|
||||
"namespace_lock_timeout"
|
||||
| "namespace_lock_unavailable"
|
||||
| "storage_write_quorum"
|
||||
| "storage_read_quorum"
|
||||
)
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let delay = match response.headers().get("retry-after") {
|
||||
Some("1") => Duration::from_secs(1),
|
||||
None => Duration::from_millis(100),
|
||||
Some(_) => return None,
|
||||
};
|
||||
last_failure = format!(
|
||||
"cause={:?}, request_id={:?}",
|
||||
response.headers().get("x-rustfs-error-cause"),
|
||||
response.headers().get("x-amz-request-id")
|
||||
);
|
||||
Some(delay)
|
||||
});
|
||||
match retry {
|
||||
Some(delay) => tokio::time::sleep(delay).await,
|
||||
None => return result,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
eprintln!(
|
||||
"MPU recovery: operation={operation}, attempts={attempts}, elapsed={:?}, last={last_failure}",
|
||||
started.elapsed()
|
||||
);
|
||||
outcome.map_err(|_| format!("{operation} did not recover within {DEADLINE:?}: {last_failure}").into())
|
||||
}
|
||||
|
||||
async fn abort_after_fault(client: &Client, bucket: &str, key: &str, upload: &str, allow_missing: bool) -> TestResult {
|
||||
match recover_after_fault(&format!("Abort {key}"), || {
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload)
|
||||
.send()
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(error) if allow_missing && error.as_service_error().and_then(|e| e.meta().code()) == Some("NoSuchUpload") => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let missing = recover_after_fault(&format!("ListParts {key}"), || {
|
||||
client.list_parts().bucket(bucket).key(key).upload_id(upload).send()
|
||||
})
|
||||
.await?
|
||||
.expect_err("a recovered Abort must remove the upload");
|
||||
assert_eq!(missing.as_service_error().and_then(|e| e.meta().code()), Some("NoSuchUpload"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn round_trip(clients: &[Client], bucket: &str, key: &str, size: usize) -> TestResult {
|
||||
let upload = clients[0].create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
|
||||
let body = payload_for(key, size);
|
||||
let mut parts: Vec<_> = stream::iter(body.chunks(8 * 1024 * 1024).enumerate())
|
||||
.map(|(part, bytes)| async move {
|
||||
let number = i32::try_from(part + 1)?;
|
||||
let result = clients[part % clients.len()]
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(number)
|
||||
.body(ByteStream::from(bytes.to_vec()))
|
||||
.send()
|
||||
.await?;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(
|
||||
CompletedPart::builder()
|
||||
.part_number(number)
|
||||
.e_tag(result.e_tag().ok_or("missing part ETag")?)
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.buffer_unordered(4)
|
||||
.try_collect()
|
||||
.await?;
|
||||
parts.sort_by_key(|part| part.part_number());
|
||||
clients[1]
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(parts)).build())
|
||||
.send()
|
||||
.await?;
|
||||
for client in clients {
|
||||
assert_object_bytes(client, bucket, key, &body).await?;
|
||||
}
|
||||
clients[0].delete_object().bucket(bucket).key(key).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_no_uploads(clients: &[Client], bucket: &str) -> TestResult {
|
||||
for client in clients {
|
||||
let uploads = client.list_multipart_uploads().bucket(bucket).send().await?;
|
||||
assert!(uploads.uploads().is_empty(), "unexpected residual uploads: {:?}", uploads.uploads());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_no_uploads_after_fault(clients: &[Client], bucket: &str) -> TestResult {
|
||||
for client in clients {
|
||||
let uploads = recover_after_fault("ListMultipartUploads after fault", || {
|
||||
client.list_multipart_uploads().bucket(bucket).send()
|
||||
})
|
||||
.await??;
|
||||
assert!(uploads.uploads().is_empty(), "unexpected residual uploads: {:?}", uploads.uploads());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_object_bytes_after_fault(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
|
||||
let output = recover_after_fault(&format!("GetObject {key} after fault"), || {
|
||||
client.get_object().bucket(bucket).key(key).send()
|
||||
})
|
||||
.await??;
|
||||
// A successful response must have a complete, correct body. Streaming
|
||||
// failures are not retried or converted into a successful comparison.
|
||||
let bytes = output.body.collect().await?.into_bytes();
|
||||
if bytes.as_ref() != expected {
|
||||
return Err(format!("object {key} bytes differ: expected {} bytes, got {}", expected.len(), bytes.len()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// This fixture inspects RustFS RPCs, so use the server-generated upload's
|
||||
// canonical suffix and fail explicitly if its encoding changes.
|
||||
fn upload_scope(upload_id: &str) -> TestResult<String> {
|
||||
let decoded = String::from_utf8(base64_simd::URL_SAFE_NO_PAD.decode_to_vec(upload_id.as_bytes())?)?;
|
||||
let (_, suffix) = decoded.split_once('.').ok_or("server upload ID lacks deployment delimiter")?;
|
||||
if suffix.is_empty() {
|
||||
return Err("server upload ID has an empty suffix".into());
|
||||
}
|
||||
Ok(suffix.to_owned())
|
||||
}
|
||||
|
||||
fn upload_path_scope(bucket: &str, key: &str, upload_id: &str) -> TestResult<String> {
|
||||
Ok(format!(
|
||||
"{}/{}",
|
||||
sha256_hex(format!("{bucket}/{key}").as_bytes()),
|
||||
upload_scope(upload_id)?
|
||||
))
|
||||
}
|
||||
|
||||
async fn with_paused_peers<T: Send + 'static>(
|
||||
proxies: &[RpcFaultProxy],
|
||||
methods: &[&'static str],
|
||||
limits: &[usize],
|
||||
scope: &str,
|
||||
request: impl std::future::Future<Output = T> + Send + 'static,
|
||||
) -> TestResult<T> {
|
||||
let pauses: Vec<_> = proxies[1..=limits.len()]
|
||||
.iter()
|
||||
.zip(limits)
|
||||
.map(|(proxy, limit)| proxy.pause(methods, *limit, Some(scope)))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let task = tokio::spawn(request);
|
||||
wait_for_faults(&pauses, methods).await?;
|
||||
let result = tokio::time::timeout(DEADLINE, task).await??;
|
||||
for pause in &pauses {
|
||||
pause.discard_pending();
|
||||
}
|
||||
drop(pauses);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn quorum_outcome<T: std::fmt::Debug, E: std::fmt::Debug + ProvideErrorMetadata>(
|
||||
result: Result<T, SdkError<E>>,
|
||||
at_quorum: bool,
|
||||
locks: bool,
|
||||
) -> TestResult<Option<T>> {
|
||||
if at_quorum {
|
||||
Ok(Some(result.map_err(|error| format!("exactly quorum must succeed: {error:?}"))?))
|
||||
} else {
|
||||
assert_unavailable(
|
||||
&result.expect_err("quorum-minus-one must fail"),
|
||||
if locks {
|
||||
"namespace_lock_timeout"
|
||||
} else {
|
||||
"storage_write_quorum"
|
||||
},
|
||||
if locks { Some("1") } else { None },
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_lock_peer_recovery(proxies: &[RpcFaultProxy], client: &Client, bucket: &str) -> TestResult {
|
||||
let key = unique_bucket("peer-recovery");
|
||||
let observers = proxies[1..]
|
||||
.iter()
|
||||
.map(|proxy| proxy.pause(&["Lock", "LockBatch"], usize::MAX, Some(&key)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for observer in &observers {
|
||||
observer.release();
|
||||
}
|
||||
wait_until(
|
||||
DEADLINE,
|
||||
|| async {
|
||||
retrying_put(client, bucket, &key, vec![0x42; 1024], DEADLINE).await?;
|
||||
// Quorum success alone can leave one peer's request breaker open.
|
||||
// Two replies require a half-open peer to finish its first probe
|
||||
// before admitting another RPC; every peer must rejoin the matrix.
|
||||
Ok(observers.iter().all(|observer| observer.forwarded() >= 2))
|
||||
},
|
||||
"all lock peers recover after fault retirement",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_storage_peer_recovery(
|
||||
proxies: &[RpcFaultProxy],
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
upload: &str,
|
||||
drives_per_node: usize,
|
||||
) -> TestResult {
|
||||
let scope = upload_scope(upload)?;
|
||||
tokio::time::timeout(
|
||||
DEADLINE,
|
||||
wait_until(
|
||||
DEADLINE,
|
||||
|| async {
|
||||
let observers = proxies[1..]
|
||||
.iter()
|
||||
.map(|proxy| proxy.pause(&["ReadVersion"], usize::MAX, Some(&scope)))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for observer in &observers {
|
||||
observer.release();
|
||||
}
|
||||
// A fresh absent key forces a full metadata read without mutating
|
||||
// this upload. Count distinct configured disk routes, not retries
|
||||
// from the same healthy disk or just lock-RPC reachability.
|
||||
let key = format!("storage-ready-{}", uuid::Uuid::new_v4());
|
||||
let _ = client.list_parts().bucket(bucket).key(key).upload_id(upload).send().await;
|
||||
Ok(observers
|
||||
.iter()
|
||||
.all(|observer| observer.forwarded_routes() >= drives_per_node))
|
||||
},
|
||||
"all storage disk routes recover after fault retirement",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "storage route recovery deadline exceeded".to_string())?
|
||||
}
|
||||
|
||||
async fn phase_quorum_matrix(
|
||||
proxies: &[RpcFaultProxy],
|
||||
clients: &[Client],
|
||||
bucket: &str,
|
||||
locks: bool,
|
||||
drives_per_node: usize,
|
||||
) -> TestResult {
|
||||
for phase in ["create", "part", "complete", "abort"] {
|
||||
// Ordinary Create holds only the shared bucket lifecycle fence.
|
||||
// Four-node read quorum is two; commit/write quorum is three.
|
||||
let unavailable_at_quorum = if locks && phase == "create" { 2 } else { 1 };
|
||||
for unavailable in [unavailable_at_quorum, unavailable_at_quorum + 1] {
|
||||
let key = format!("{locks}-{phase}-{unavailable}");
|
||||
let mut limits = vec![usize::MAX; unavailable];
|
||||
// EC12+4: one peer contributes four shards; one packet on the
|
||||
// second peer moves exactly twelve available shards to eleven.
|
||||
if !locks && drives_per_node == 4 && unavailable > 1 {
|
||||
limits[unavailable - 1] = 1;
|
||||
}
|
||||
let available_votes = if locks {
|
||||
4 - unavailable
|
||||
} else {
|
||||
4 * drives_per_node - limits.iter().map(|limit| drives_per_node.min(*limit)).sum::<usize>()
|
||||
};
|
||||
let methods: &[&'static str] = if locks {
|
||||
&["Lock", "LockBatch"]
|
||||
} else {
|
||||
match phase {
|
||||
"create" => &["WriteMetadata"],
|
||||
"part" => &["PreparePartTransaction"],
|
||||
"complete" => &["RenameData", "RenameDataAtIncarnation", "RenameDataWithQuotaFence"],
|
||||
"abort" => &["Delete"],
|
||||
_ => unreachable!(),
|
||||
}
|
||||
};
|
||||
eprintln!("MPU quorum acceptance: phase={phase}, lock_fault={locks}, available_votes={available_votes}");
|
||||
if phase == "create" {
|
||||
let request = clients[0].create_multipart_upload().bucket(bucket).key(&key);
|
||||
let result = with_paused_peers(proxies, methods, &limits, bucket, async move { request.send().await }).await?;
|
||||
if let Some(upload) = quorum_outcome(result, unavailable == unavailable_at_quorum, locks)? {
|
||||
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
|
||||
wait_for_storage_peer_recovery(proxies, &clients[0], bucket, upload_id, drives_per_node).await?;
|
||||
abort_after_fault(&clients[0], bucket, &key, upload_id, false).await?;
|
||||
}
|
||||
} else {
|
||||
let upload = clients[0]
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("setup Create failed for {key}: {error:?}"))?;
|
||||
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
|
||||
let scope = if !locks && phase == "abort" {
|
||||
upload_path_scope(bucket, &key, upload_id)?
|
||||
} else {
|
||||
upload_scope(upload_id)?
|
||||
};
|
||||
let body = payload_for(&key, if drives_per_node == 4 { 9 * 1024 * 1024 } else { 1024 * 1024 });
|
||||
let initial = if phase != "part" {
|
||||
Some(
|
||||
clients[0]
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match phase {
|
||||
"part" => {
|
||||
let request = clients[0]
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(body.clone()));
|
||||
let result =
|
||||
with_paused_peers(proxies, methods, &limits, &scope, async move { request.send().await }).await?;
|
||||
if let Some(part) = quorum_outcome(result, unavailable == unavailable_at_quorum, locks)? {
|
||||
let completion = clients[0]
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part.e_tag().ok_or("missing ETag")?)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
recover_after_fault(&format!("Complete {key} after part fault"), || completion.clone().send())
|
||||
.await??;
|
||||
for reader in clients {
|
||||
assert_object_bytes_after_fault(reader, bucket, &key, &body).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
"complete" => {
|
||||
let part = initial.as_ref().expect("complete fixture has one part");
|
||||
let request = clients[0]
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part.e_tag().ok_or("missing ETag")?)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
let result =
|
||||
with_paused_peers(proxies, methods, &limits, &scope, async move { request.send().await }).await?;
|
||||
if quorum_outcome(result, unavailable == unavailable_at_quorum, locks)?.is_some() {
|
||||
for reader in clients {
|
||||
assert_object_bytes_after_fault(reader, bucket, &key, &body).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
"abort" => {
|
||||
let request = clients[0]
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id);
|
||||
let result =
|
||||
with_paused_peers(proxies, methods, &limits, &scope, async move { request.send().await }).await?;
|
||||
quorum_outcome(result, unavailable == unavailable_at_quorum, locks)?;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
wait_for_storage_peer_recovery(proxies, &clients[0], bucket, upload_id, drives_per_node).await?;
|
||||
// A rejected Complete must retain authority for ordinary
|
||||
// Abort. NoSuchUpload must not hide partial staging loss.
|
||||
let allow_missing = phase != "complete" || unavailable == unavailable_at_quorum;
|
||||
abort_after_fault(&clients[0], bucket, &key, upload_id, allow_missing).await?;
|
||||
recover_after_fault(&format!("DeleteObject {key} after {phase}"), || {
|
||||
clients[0].delete_object().bucket(bucket).key(&key).send()
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
wait_for_lock_peer_recovery(proxies, &clients[0], bucket).await?;
|
||||
round_trip(clients, bucket, "recovered", 9 * 1024 * 1024).await?;
|
||||
assert_no_uploads_after_fault(clients, bucket).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_delay_preserves_write_quorum_and_rejects_quorum_minus_one() -> TestResult {
|
||||
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
|
||||
phase_quorum_matrix(&proxies, &clients, &bucket, false, 1).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_delay_preserves_admission_quorum_and_reports_timeout() -> TestResult {
|
||||
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
|
||||
phase_quorum_matrix(&proxies, &clients, &bucket, true, 1).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sixteen_shard_multipart_metadata_requires_twelve_votes() -> TestResult {
|
||||
let (cluster, proxies, clients, bucket) = fixture(4, "EC:4").await?;
|
||||
let storage = cluster_admin_ok(&cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||
let storage: serde_json::Value = serde_json::from_str(&storage)?;
|
||||
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
|
||||
assert_eq!(storage.disks.len(), 16);
|
||||
assert_eq!(storage.backend.total_sets, vec![1]);
|
||||
assert_eq!(storage.backend.drives_per_set, vec![16]);
|
||||
assert_eq!(storage.backend.standard_sc_parity, Some(4));
|
||||
phase_quorum_matrix(&proxies, &clients, &bucket, false, 4).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_metadata_wait_releases_fence_for_bucket_recreation() -> TestResult {
|
||||
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
|
||||
let pauses: Vec<_> = proxies[1..]
|
||||
.iter()
|
||||
.map(|proxy| proxy.pause(&["WriteMetadata"], usize::MAX, Some(&bucket)))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let task = create(&clients[0], &bucket, "cancelled");
|
||||
wait_for_faults(&pauses, &["WriteMetadata"]).await?;
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("cancelled S3 future").is_cancelled());
|
||||
drop(pauses);
|
||||
// Deletion must wait for admitted server work to finish, then erase its
|
||||
// generation. Client cancellation alone does not prove server cancellation.
|
||||
tokio::time::timeout(DEADLINE, clients[1].delete_bucket().bucket(&bucket).send()).await??;
|
||||
clients[2].create_bucket().bucket(&bucket).send().await?;
|
||||
assert_no_uploads(&clients, &bucket).await?;
|
||||
round_trip(&clients, &bucket, "new-generation", 9 * 1024 * 1024).await?;
|
||||
assert_no_uploads(&clients, &bucket).await
|
||||
}
|
||||
|
||||
/// Same logical geometry as the incident: one pool, four 16-shard sets,
|
||||
/// EC12+4, 45 MiB files, 8 MiB parts, two files and four parts in flight.
|
||||
#[tokio::test]
|
||||
async fn eight_node_eight_drive_multipart_load_leaves_no_residual_uploads() -> TestResult {
|
||||
let rounds = std::env::var("RUSTFS_E2E_MPU_LOAD_ROUNDS")
|
||||
.unwrap_or_else(|_| "3".into())
|
||||
.parse::<usize>()?;
|
||||
if !(1..=256).contains(&rounds) {
|
||||
return Err("RUSTFS_E2E_MPU_LOAD_ROUNDS must be in 1..=256".into());
|
||||
}
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(8, 8)).await?;
|
||||
for (name, value) in [
|
||||
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
|
||||
("TOKIO_WORKER_THREADS", "2"),
|
||||
("RAYON_NUM_THREADS", "2"),
|
||||
("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false"),
|
||||
("NO_PROXY", "127.0.0.1,::1,localhost"),
|
||||
("HTTP_PROXY", ""),
|
||||
("HTTPS_PROXY", ""),
|
||||
("RUST_LOG", "warn"),
|
||||
] {
|
||||
cluster.set_env(name, value);
|
||||
}
|
||||
configure_node_logs(&mut cluster, None)?;
|
||||
cluster.start().await?;
|
||||
let clients: Vec<_> = cluster
|
||||
.create_all_clients()?
|
||||
.into_iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let storage = cluster_admin_ok(&cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||
let storage: serde_json::Value = serde_json::from_str(&storage)?;
|
||||
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
|
||||
assert_eq!(storage.disks.len(), 64);
|
||||
assert_eq!(storage.backend.total_sets, vec![4]);
|
||||
assert_eq!(storage.backend.drives_per_set, vec![16]);
|
||||
assert_eq!(storage.backend.standard_sc_parity, Some(4));
|
||||
let bucket = unique_bucket("mpu-8x8");
|
||||
cluster.create_test_bucket(&bucket).await?;
|
||||
let started = Instant::now();
|
||||
for round in 0..rounds {
|
||||
let first = format!("round-{round}-first");
|
||||
let second = format!("round-{round}-second");
|
||||
tokio::try_join!(
|
||||
round_trip(&clients, &bucket, &first, 45 * 1024 * 1024),
|
||||
round_trip(&clients, &bucket, &second, 45 * 1024 * 1024),
|
||||
)?;
|
||||
let aborted = clients[round % clients.len()]
|
||||
.create_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key("aborted")
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = aborted.upload_id().ok_or("missing upload ID")?;
|
||||
clients[(round + 1) % clients.len()]
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key("aborted")
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(vec![0xa5; 8 * 1024 * 1024]))
|
||||
.send()
|
||||
.await?;
|
||||
abort(&clients[(round + 2) % clients.len()], &bucket, "aborted", upload_id).await?;
|
||||
assert_no_uploads(&clients, &bucket).await?;
|
||||
eprintln!(
|
||||
"MPU 8x8 acceptance: round={}/{rounds}, completed={}, aborted={}, elapsed={:?}",
|
||||
round + 1,
|
||||
2 * (round + 1),
|
||||
round + 1,
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resume_at_rpc<T: Send + 'static>(
|
||||
proxies: &[RpcFaultProxy],
|
||||
methods: &[&'static str],
|
||||
scope: &str,
|
||||
request: impl std::future::Future<Output = TestResult<T>> + Send + 'static,
|
||||
) -> TestResult<T> {
|
||||
let pauses: Vec<_> = proxies[1..]
|
||||
.iter()
|
||||
.map(|proxy| proxy.pause(methods, usize::MAX, Some(scope)))
|
||||
.collect::<Result<_, _>>()?;
|
||||
eprintln!("MPU delay acceptance: methods={methods:?}, scope={scope}");
|
||||
let task = tokio::spawn(request);
|
||||
wait_for_faults(&pauses, methods).await?;
|
||||
assert!(
|
||||
!task.is_finished(),
|
||||
"the selected commit must wait while only one shard/voter is available"
|
||||
);
|
||||
drop(pauses);
|
||||
tokio::time::timeout(DEADLINE, task).await??
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_multipart_phase_resumes_after_metadata_and_lock_delays() -> TestResult {
|
||||
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
|
||||
for locks in [false, true] {
|
||||
let key = format!("phase-{locks}");
|
||||
let client = clients[0].clone();
|
||||
let create = client.create_multipart_upload().bucket(&bucket).key(&key);
|
||||
let upload = resume_at_rpc(
|
||||
&proxies,
|
||||
if locks { &["Lock", "LockBatch"] } else { &["WriteMetadata"] },
|
||||
bucket.as_str(),
|
||||
async move { Ok(create.send().await?) },
|
||||
)
|
||||
.await?;
|
||||
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
|
||||
let scope = upload_scope(upload_id)?;
|
||||
let body = payload_for(&key, 1024 * 1024);
|
||||
let part = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(body.clone()));
|
||||
let part = resume_at_rpc(
|
||||
&proxies,
|
||||
if locks {
|
||||
&["Lock", "LockBatch"]
|
||||
} else {
|
||||
&["PreparePartTransaction"]
|
||||
},
|
||||
&scope,
|
||||
async move { Ok(part.send().await?) },
|
||||
)
|
||||
.await?;
|
||||
let complete = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part.e_tag().ok_or("missing ETag")?)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
resume_at_rpc(
|
||||
&proxies,
|
||||
if locks {
|
||||
&["Lock", "LockBatch"]
|
||||
} else {
|
||||
&["RenameData", "RenameDataAtIncarnation", "RenameDataWithQuotaFence"]
|
||||
},
|
||||
&scope,
|
||||
async move { Ok(complete.send().await?) },
|
||||
)
|
||||
.await?;
|
||||
for reader in &clients {
|
||||
assert_object_bytes(reader, &bucket, &key, &body).await?;
|
||||
}
|
||||
client.delete_object().bucket(&bucket).key(&key).send().await?;
|
||||
let upload = client.create_multipart_upload().bucket(&bucket).key(&key).send().await?;
|
||||
let upload_id = upload.upload_id().ok_or("missing upload ID")?;
|
||||
let scope = upload_scope(upload_id)?;
|
||||
let scope = if locks {
|
||||
scope
|
||||
} else {
|
||||
upload_path_scope(&bucket, &key, upload_id)?
|
||||
};
|
||||
let request = client.abort_multipart_upload().bucket(&bucket).key(&key).upload_id(upload_id);
|
||||
resume_at_rpc(&proxies, if locks { &["Lock", "LockBatch"] } else { &["Delete"] }, &scope, async move {
|
||||
Ok(request.send().await?)
|
||||
})
|
||||
.await?;
|
||||
let missing = client
|
||||
.list_parts()
|
||||
.bucket(&bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("delayed Abort must remove the upload");
|
||||
assert_eq!(missing.as_service_error().and_then(|e| e.meta().code()), Some("NoSuchUpload"));
|
||||
assert_no_uploads(&clients, &bucket).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -13,13 +13,14 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, retrying_put, set_bucket_quota,
|
||||
set_remote_target, unique_bucket, wait_for_ready, wait_for_replicated_bytes, wait_until,
|
||||
DistCluster, DistLayout, TestResult, cluster_admin_ok, enable_versioning, put_bucket_replication, put_object,
|
||||
quota_stats_current_usage, quota_stats_on_every_node, retrying_put, set_bucket_quota, set_remote_target, unique_bucket,
|
||||
wait_for_ready, wait_for_replicated_bytes, wait_until,
|
||||
};
|
||||
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
|
||||
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, admin_request, init_logging};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use http::Method;
|
||||
use http::{Method, StatusCode};
|
||||
use std::time::Duration;
|
||||
|
||||
async fn wait_for_replication_status(
|
||||
@@ -109,12 +110,165 @@ async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("quota-refresh");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let native_path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||
let compat_set = format!("/rustfs/admin/v3/set-bucket-quota?bucket={bucket}");
|
||||
let compat_get = format!("/rustfs/admin/v3/get-bucket-quota?bucket={bucket}");
|
||||
|
||||
// Warm every node's no-quota cache. The compatibility GET does not
|
||||
// depend on a scanner usage snapshot for this newly created bucket.
|
||||
for node in &dist.cluster.nodes {
|
||||
let (status, body) = admin_request(
|
||||
&node.url,
|
||||
Method::GET,
|
||||
&compat_get,
|
||||
None,
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
|
||||
}
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
|| async {
|
||||
let (status, body) = admin_request(
|
||||
&dist.cluster.nodes[0].url,
|
||||
Method::PUT,
|
||||
&native_path,
|
||||
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
if status == StatusCode::SERVICE_UNAVAILABLE {
|
||||
return Ok(false);
|
||||
}
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
Ok(true)
|
||||
},
|
||||
"quota fleet capability ready",
|
||||
)
|
||||
.await?;
|
||||
|
||||
for (method, path, payload, expected) in [
|
||||
(Method::GET, native_path.as_str(), None, Some(8192_u64)),
|
||||
(
|
||||
Method::PUT,
|
||||
native_path.as_str(),
|
||||
Some(serde_json::json!({"quota": 4096, "quota_type": "HARD"})),
|
||||
Some(4096),
|
||||
),
|
||||
(Method::DELETE, native_path.as_str(), None, None),
|
||||
(
|
||||
Method::PUT,
|
||||
compat_set.as_str(),
|
||||
Some(serde_json::json!({"quota": 16384, "quotatype": "hard", "reservation_protocol": 2})),
|
||||
Some(16384),
|
||||
),
|
||||
(
|
||||
Method::PUT,
|
||||
compat_set.as_str(),
|
||||
Some(serde_json::json!({"quota": 0, "quotatype": "hard"})),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
if method != Method::GET {
|
||||
cluster_admin_ok(&dist.cluster, method, path, payload.map(|value| value.to_string())).await?;
|
||||
}
|
||||
// No polling: a successful mutation waits for healthy peers to reload.
|
||||
for (index, node) in dist.cluster.nodes.iter().enumerate() {
|
||||
let (status, body) = admin_request(
|
||||
&node.url,
|
||||
Method::GET,
|
||||
&compat_get,
|
||||
None,
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&body)?["quota"],
|
||||
expected.unwrap_or(0),
|
||||
"node {index} kept stale quota metadata",
|
||||
);
|
||||
if expected.is_some() {
|
||||
let (status, body) = admin_request(
|
||||
&node.url,
|
||||
Method::GET,
|
||||
&native_path,
|
||||
None,
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"].as_u64(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cluster_admin_ok(
|
||||
&dist.cluster,
|
||||
Method::PUT,
|
||||
&native_path,
|
||||
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
|
||||
)
|
||||
.await?;
|
||||
dist.cluster.stop_node(3)?;
|
||||
// An unreachable peer cannot turn an already committed clear into a failure.
|
||||
cluster_admin_ok(&dist.cluster, Method::DELETE, &native_path, None).await?;
|
||||
for node in dist.cluster.nodes.iter().take(3) {
|
||||
let (status, body) = admin_request(
|
||||
&node.url,
|
||||
Method::GET,
|
||||
&compat_get,
|
||||
None,
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start_with_env(DistLayout::FourByFour, FAST_DATA_USAGE_SCANNER_ENV).await?;
|
||||
let bucket = unique_bucket("quota");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
// A bucket without a quota reports usage only once a snapshot covers it.
|
||||
// The scanner leader sees its own save at once; every other node holds a
|
||||
// snapshot cached before the bucket existed and must reload it early
|
||||
// rather than answer 503 for the rest of the 30 s cache TTL.
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
|| async {
|
||||
let stats = quota_stats_on_every_node(&dist.cluster, &bucket).await?;
|
||||
Ok(stats.iter().any(|(status, _)| status.is_success()))
|
||||
},
|
||||
"first node reports quota stats for the new bucket",
|
||||
)
|
||||
.await?;
|
||||
wait_until(
|
||||
Duration::from_secs(15),
|
||||
|| async {
|
||||
let stats = quota_stats_on_every_node(&dist.cluster, &bucket).await?;
|
||||
Ok(stats.iter().all(|(status, _)| status.is_success()))
|
||||
},
|
||||
"every node reports quota stats for the new bucket",
|
||||
)
|
||||
.await?;
|
||||
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
|
||||
|
||||
let client = dist.client(1)?;
|
||||
@@ -122,23 +276,12 @@ async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
|| async {
|
||||
let (status, body) = super::harness::cluster_admin(
|
||||
&dist.cluster,
|
||||
Method::GET,
|
||||
&format!("/rustfs/admin/v3/quota-stats/{bucket}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if !status.is_success() {
|
||||
return Ok(false);
|
||||
for (status, body) in quota_stats_on_every_node(&dist.cluster, &bucket).await? {
|
||||
if status.is_success() && quota_stats_current_usage(&body)?.is_some_and(|usage| usage >= 1024) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let stats: serde_json::Value =
|
||||
serde_json::from_str(&body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
|
||||
let usage = stats
|
||||
.get("current_usage")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.ok_or_else(|| format!("quota stats omitted current_usage: {stats}"))?;
|
||||
Ok(usage >= 1024)
|
||||
Ok(false)
|
||||
},
|
||||
"quota stats observe small object",
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,761 @@
|
||||
// Copyright 2026 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.
|
||||
|
||||
//! `scanner_heal_topology_` protection matrix (backlog#2873, S00).
|
||||
//!
|
||||
//! Every case seeds the deterministic inventory from the fixture into one
|
||||
//! pool, then drives expansion, rebalance or decommission while re-verifying
|
||||
//! the recorded versions through every node and the physical pool placement
|
||||
//! from `xl.meta`. Fault cases hold the mover at a proven window through the
|
||||
//! `e2e-test-hooks` barriers before injecting a kill or a commit failure. The
|
||||
//! gate mapping is documented in `docs/testing/scanner-heal-topology-gates.md`.
|
||||
|
||||
use super::harness::unique_bucket;
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, TestResult, decommission_status_json, rebalance_complete, rebalance_status_json, start_decommission,
|
||||
start_rebalance, wait_for_decommission_active, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
|
||||
wait_for_rebalance_active, wait_for_rebalance_complete,
|
||||
};
|
||||
use super::scanner_heal_topology_fixture::{
|
||||
LiveWorkload, MOVEMENT_ACTIVE_TIMEOUT, MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL,
|
||||
STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP, STAGE_BEFORE_TARGET_COMMIT, TopologyManifest, TopologyRun,
|
||||
assert_bucket_durability_strict, assert_every_node_serves, assert_probe_objects, assert_process_durability_strict,
|
||||
assert_seed_pool_census_unchanged, assert_single_pool_placement, cancel_decommission, capture_seed_pool_census,
|
||||
clear_decommission, expect_decommission_rejected, expected_live_state, incarnation_sidecar_census, pool_count, pools_holding,
|
||||
put_probe_objects, rebalance_stopped, require_test_hooks, run_live_workload, seed_manifest, start_decommission_targets,
|
||||
stop_rebalance, verify_from_every_node, verify_live_state, verify_manifest, wait_for_decommission_flag,
|
||||
};
|
||||
use crate::common::{admin_request, init_logging};
|
||||
use http::Method;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::Duration;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
const ALL_POOLS: [usize; 4] = [0, 1, 2, 3];
|
||||
const TARGET_POOLS: [usize; 3] = [1, 2, 3];
|
||||
const LIVE_WRITERS: usize = 6;
|
||||
const LIVE_ROUNDS: usize = 6;
|
||||
/// Locally the decommission start reaches its durable save in about a second
|
||||
/// (POST to response ~0.8 s); 60 s separates a stalled start path from slow I/O.
|
||||
const PRE_ACK_REACH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// Cancel and stop are acknowledged before every in-flight entry finishes its
|
||||
/// cleanup; terminal state and placement are polled within this budget.
|
||||
const TERMINAL_TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
/// Recovery includes the fleet-proof retry before migration can resume. A
|
||||
/// healthy CI restart completed after 253 s while still making progress; keep
|
||||
/// this recovery budget below the distributed profile's 720 s process limit.
|
||||
const DECOMMISSION_RESUME_COMPLETE_TIMEOUT: Duration = Duration::from_secs(360);
|
||||
|
||||
fn pools(ids: &[usize]) -> BTreeSet<usize> {
|
||||
ids.iter().copied().collect()
|
||||
}
|
||||
|
||||
fn seed_pool_sidecars(
|
||||
census: &std::collections::BTreeMap<String, crate::chaos::VersionShardCensus>,
|
||||
) -> Vec<(&String, &crate::chaos::VersionShardCensus)> {
|
||||
census
|
||||
.iter()
|
||||
.filter(|(location, _)| location.starts_with(&format!("{SEED_POOL}/")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Expansion and restart must leave the seed pool's sidecar census unchanged.
|
||||
fn assert_seed_sidecars_unchanged(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult {
|
||||
for bucket in manifest.buckets() {
|
||||
let now = incarnation_sidecar_census(&run.dist, bucket)?;
|
||||
let before = &manifest.incarnation_sidecars[bucket];
|
||||
if seed_pool_sidecars(&now) != seed_pool_sidecars(before) {
|
||||
return Err(format!(
|
||||
"{label}: bucket incarnation sidecar of {bucket} changed on the seed pool: before {before:?} now {now:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After the seed pool is retired every bucket must keep its incarnation
|
||||
/// sidecar on at least one target pool.
|
||||
fn assert_sidecars_survive_on_targets(
|
||||
run: &TopologyRun,
|
||||
manifest: &TopologyManifest,
|
||||
targets: &BTreeSet<usize>,
|
||||
label: &str,
|
||||
) -> TestResult<Value> {
|
||||
let mut summary = serde_json::Map::new();
|
||||
for bucket in manifest.buckets() {
|
||||
let now = incarnation_sidecar_census(&run.dist, bucket)?;
|
||||
let on_targets: Vec<&String> = now
|
||||
.keys()
|
||||
.filter(|location| {
|
||||
location
|
||||
.split('/')
|
||||
.next()
|
||||
.and_then(|pool| pool.parse::<usize>().ok())
|
||||
.is_some_and(|pool| targets.contains(&pool))
|
||||
})
|
||||
.collect();
|
||||
if on_targets.is_empty() {
|
||||
return Err(
|
||||
format!("{label}: bucket {bucket} has no incarnation sidecar on target pools {targets:?}: {now:?}").into(),
|
||||
);
|
||||
}
|
||||
summary.insert(bucket.to_string(), json!(on_targets));
|
||||
}
|
||||
Ok(Value::Object(summary))
|
||||
}
|
||||
|
||||
fn manifest_summary(manifest: &TopologyManifest) -> Value {
|
||||
let mut shapes = std::collections::BTreeMap::new();
|
||||
for entry in &manifest.entries {
|
||||
*shapes.entry(entry.shape).or_insert(0usize) += 1;
|
||||
}
|
||||
json!({
|
||||
"buckets": manifest.buckets(),
|
||||
"entries": manifest.entries.len(),
|
||||
"keys": manifest.entries.iter().map(|entry| format!("{}/{}", entry.bucket, entry.key)).collect::<BTreeSet<_>>().len(),
|
||||
"shapes": shapes,
|
||||
"bytes": manifest.entries.iter().map(|entry| entry.length).sum::<u64>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn timeouts() -> Value {
|
||||
json!({
|
||||
"movement_active_secs": MOVEMENT_ACTIVE_TIMEOUT.as_secs(),
|
||||
"movement_complete_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
|
||||
"decommission_resume_complete_secs": DECOMMISSION_RESUME_COMPLETE_TIMEOUT.as_secs(),
|
||||
"barrier_reach_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
|
||||
"terminal_transition_secs": TERMINAL_TRANSITION_TIMEOUT.as_secs(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancel and stop acknowledge before every in-flight entry has finished its
|
||||
/// cleanup, so placement is polled until it settles within the terminal budget.
|
||||
async fn wait_for_single_pool_placement(
|
||||
run: &TopologyRun,
|
||||
manifest: &TopologyManifest,
|
||||
allowed: &BTreeSet<usize>,
|
||||
label: &str,
|
||||
) -> TestResult<std::collections::BTreeMap<String, usize>> {
|
||||
let deadline = Instant::now() + TERMINAL_TRANSITION_TIMEOUT;
|
||||
loop {
|
||||
match assert_single_pool_placement(&run.dist, manifest, allowed, label) {
|
||||
Ok(placement) => return Ok(placement),
|
||||
Err(error) if Instant::now() < deadline => {
|
||||
tracing::info!("{label}: placement not settled yet: {error}");
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stopped run may leave a committed target beside its retained source.
|
||||
/// Every version must still be held by at least one pool; returns the
|
||||
/// versions held by more than one.
|
||||
fn assert_held_by_some_pool(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult<Vec<String>> {
|
||||
let mut duplicated = Vec::new();
|
||||
for entry in &manifest.entries {
|
||||
let held = pools_holding(&run.dist, &entry.bucket, &entry.key, entry.version_id.as_deref())?;
|
||||
if held.is_empty() {
|
||||
return Err(format!(
|
||||
"{label}: {}/{} version {:?} is held by no pool",
|
||||
entry.bucket, entry.key, entry.version_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if held.len() > 1 {
|
||||
duplicated.push(format!("{}/{}@{} {held:?}", entry.bucket, entry.key, entry.listed_version_id()));
|
||||
}
|
||||
}
|
||||
Ok(duplicated)
|
||||
}
|
||||
|
||||
async fn expand_and_verify(run: &mut TopologyRun, manifest: &TopologyManifest) -> TestResult {
|
||||
run.dist.expand_to_four_pools().await?;
|
||||
assert_eq!(pool_count(&run.dist.cluster).await?, 4, "expected four pools after expansion");
|
||||
verify_from_every_node(&run.dist, manifest, "after expansion to four pools").await?;
|
||||
assert_single_pool_placement(&run.dist, manifest, &pools(&[SEED_POOL]), "before data movement")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// G-EXP: 1→2→4 pools with scanner and heal enabled; old versions intact on
|
||||
/// every node, seed-pool shards and set placement untouched, new writes land
|
||||
/// in exactly one pool, and the pool map survives a full restart.
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_expand_one_to_four_pools_preserves_versions_and_placement() -> TestResult {
|
||||
init_logging();
|
||||
let mut run = TopologyRun::start("expand").await?;
|
||||
let manifest = seed_manifest(&run).await?;
|
||||
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
|
||||
let seed_census = capture_seed_pool_census(&run.dist, &manifest)?;
|
||||
let scratch = unique_bucket("sht-scratch");
|
||||
run.dist.create_bucket(&scratch).await?;
|
||||
|
||||
let mut steps = Vec::new();
|
||||
for expected_pools in 2..=4 {
|
||||
let new_node = run.dist.append_pool_and_restart().await?;
|
||||
if new_node + 1 != expected_pools {
|
||||
return Err(format!("appended node index {new_node} does not match pool count {expected_pools}").into());
|
||||
}
|
||||
let label = format!("after expanding to {expected_pools} pools");
|
||||
assert_eq!(pool_count(&run.dist.cluster).await?, expected_pools, "{label}: pool list");
|
||||
verify_from_every_node(&run.dist, &manifest, &label).await?;
|
||||
assert_seed_pool_census_unchanged(&run.dist, &manifest, &seed_census, &label)?;
|
||||
assert_seed_sidecars_unchanged(&run, &manifest, &label)?;
|
||||
steps.push(json!({ "pools": expected_pools, "verified_nodes": run.dist.cluster.nodes.len() }));
|
||||
}
|
||||
|
||||
let probes = put_probe_objects(&run.dist, &scratch, "post-expand", 8).await?;
|
||||
let probe_placement = assert_probe_objects(&run.dist, &scratch, &probes, &pools(&ALL_POOLS), "post-expand probes").await?;
|
||||
|
||||
run.dist.restart_current_binary_gracefully().await?;
|
||||
assert_eq!(pool_count(&run.dist.cluster).await?, 4, "pool map after restart");
|
||||
verify_from_every_node(&run.dist, &manifest, "after full restart").await?;
|
||||
assert_seed_pool_census_unchanged(&run.dist, &manifest, &seed_census, "after full restart")?;
|
||||
assert_seed_sidecars_unchanged(&run, &manifest, "after full restart")?;
|
||||
assert_probe_objects(&run.dist, &scratch, &probes, &pools(&ALL_POOLS), "post-expand probes after restart").await?;
|
||||
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
|
||||
|
||||
run.write_evidence(
|
||||
"g-exp-expand",
|
||||
&json!({
|
||||
"gate": "G-EXP",
|
||||
"manifest": manifest_summary(&manifest),
|
||||
"bucket_durability": bucket_durability,
|
||||
"process_durability_strict_nodes": strict_nodes,
|
||||
"expansion_steps": steps,
|
||||
"probe_placement": probe_placement,
|
||||
"timeouts": timeouts(),
|
||||
}),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for the first rebalance run to report either `stopped` (the expected
|
||||
/// outcome) or `completed` (the inventory moved before the stop arrived).
|
||||
async fn wait_for_rebalance_stopped_or_completed(run: &TopologyRun, rebalance_id: &str) -> TestResult<(&'static str, Value)> {
|
||||
let deadline = Instant::now() + TERMINAL_TRANSITION_TIMEOUT;
|
||||
loop {
|
||||
let status = rebalance_status_json(&run.dist.cluster).await?;
|
||||
if rebalance_stopped(&status, rebalance_id)? {
|
||||
return Ok(("stopped", status));
|
||||
}
|
||||
if rebalance_complete(&status, rebalance_id).unwrap_or(false) {
|
||||
return Ok(("completed_before_stop", status));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"rebalance {rebalance_id} reached neither stopped nor completed within {TERMINAL_TRANSITION_TIMEOUT:?}: {status}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// G-REB: rebalance with concurrent PUT/GET/DELETE and the scanner running;
|
||||
/// stop mid-run, verify no content error and no lost acknowledged version,
|
||||
/// restart the cluster, then rebalance to completion.
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_rebalance_survives_live_writes_stop_and_restart() -> TestResult {
|
||||
init_logging();
|
||||
let mut run = TopologyRun::start("rebalance").await?;
|
||||
let manifest = seed_manifest(&run).await?;
|
||||
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
|
||||
let scratch = unique_bucket("sht-scratch");
|
||||
run.dist.create_bucket(&scratch).await?;
|
||||
expand_and_verify(&mut run, &manifest).await?;
|
||||
|
||||
let baseline: Vec<(String, String)> = manifest
|
||||
.plain_entries()
|
||||
.filter(|entry| matches!(entry.shape, "inline" | "external" | "special"))
|
||||
.map(|entry| (entry.key.clone(), entry.sha256.clone()))
|
||||
.collect();
|
||||
|
||||
let first_id = start_rebalance(&run.dist.cluster).await?;
|
||||
wait_for_rebalance_active(&run.dist.cluster, &first_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
|
||||
let during_rebalance = put_probe_objects(&run.dist, &scratch, "during-rebalance", 4).await?;
|
||||
let workload = tokio::spawn(run_live_workload(LiveWorkload {
|
||||
clients: run.dist.clients()?,
|
||||
bucket: scratch.clone(),
|
||||
prefix: "live".to_string(),
|
||||
writers: LIVE_WRITERS,
|
||||
rounds: LIVE_ROUNDS,
|
||||
baseline_bucket: manifest.plain_bucket.clone(),
|
||||
baseline: baseline.clone(),
|
||||
pause: Duration::from_millis(100),
|
||||
}));
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
stop_rebalance(&run.dist.cluster).await?;
|
||||
let (first_outcome, first_status) = wait_for_rebalance_stopped_or_completed(&run, &first_id).await?;
|
||||
let ops = workload.await??;
|
||||
let live_state = expected_live_state(&ops);
|
||||
|
||||
for node_idx in 0..run.dist.cluster.nodes.len() {
|
||||
let client = run.dist.client(node_idx)?;
|
||||
verify_manifest(&client, &manifest, &format!("after rebalance {first_outcome} via node {node_idx}")).await?;
|
||||
verify_live_state(
|
||||
&client,
|
||||
&scratch,
|
||||
&live_state,
|
||||
&format!("after rebalance {first_outcome} via node {node_idx}"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let retained_after_stop = assert_held_by_some_pool(&run, &manifest, &format!("after rebalance {first_outcome}"))?;
|
||||
let during_placement = assert_probe_objects(
|
||||
&run.dist,
|
||||
&scratch,
|
||||
&during_rebalance,
|
||||
&pools(&TARGET_POOLS),
|
||||
"writes issued while the seed pool was rebalancing must avoid it",
|
||||
)
|
||||
.await?;
|
||||
|
||||
run.dist.restart_current_binary_gracefully().await?;
|
||||
assert_every_node_serves(&run.dist, &scratch, "after-stop-restart").await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "after restart following the stop").await?;
|
||||
verify_live_state(&run.dist.client(0)?, &scratch, &live_state, "after restart following the stop").await?;
|
||||
|
||||
let second_id = start_rebalance(&run.dist.cluster).await?;
|
||||
wait_for_rebalance_active(&run.dist.cluster, &second_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
|
||||
wait_for_rebalance_complete(&run.dist.cluster, &second_id, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "after the second rebalance completed").await?;
|
||||
verify_live_state(&run.dist.client(3)?, &scratch, &live_state, "after the second rebalance completed").await?;
|
||||
let final_placement =
|
||||
assert_single_pool_placement(&run.dist, &manifest, &pools(&ALL_POOLS), "after the second rebalance completed")?;
|
||||
let moved = final_placement.values().filter(|pool| **pool != SEED_POOL).count();
|
||||
if moved == 0 {
|
||||
return Err("rebalance completed without moving any manifest version off the seed pool".into());
|
||||
}
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
|
||||
|
||||
run.write_evidence(
|
||||
"g-reb-rebalance",
|
||||
&json!({
|
||||
"gate": "G-REB",
|
||||
"manifest": manifest_summary(&manifest),
|
||||
"first_run": { "id": first_id, "outcome": first_outcome, "status": first_status, "versions_held_by_two_pools_after_stop": retained_after_stop },
|
||||
"second_run": { "id": second_id, "versions_off_seed_pool": moved },
|
||||
"live_ops": ops,
|
||||
"probe_placement_during_rebalance": during_placement,
|
||||
"process_durability_strict_nodes": strict_nodes,
|
||||
"timeouts": timeouts(),
|
||||
}),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// G-DEC: cancel an active decommission and keep every version; queue two
|
||||
/// pools, retire them in order, reject retiring the last active pools, and
|
||||
/// keep listings, history, markers and locks correct across a restart.
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_decommission_queue_cancel_resume_and_last_pool_guard() -> TestResult {
|
||||
init_logging();
|
||||
let mut run = TopologyRun::start("decommission").await?;
|
||||
let manifest = seed_manifest(&run).await?;
|
||||
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
|
||||
let scratch = unique_bucket("sht-scratch");
|
||||
run.dist.create_bucket(&scratch).await?;
|
||||
expand_and_verify(&mut run, &manifest).await?;
|
||||
|
||||
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
wait_for_decommission_running_with_progress(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_ACTIVE_TIMEOUT).await?;
|
||||
cancel_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
let canceled =
|
||||
wait_for_decommission_flag(&run.dist.cluster, DECOMMISSION_POOL_ID, "canceled", TERMINAL_TRANSITION_TIMEOUT).await?;
|
||||
let after_cancel = wait_for_single_pool_placement(&run, &manifest, &pools(&ALL_POOLS), "after cancel").await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "after cancel").await?;
|
||||
clear_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
|
||||
start_decommission_targets(&run.dist.cluster, "0,1").await?;
|
||||
let queued = wait_for_decommission_flag(&run.dist.cluster, 1, "queued", Duration::from_secs(5)).await?;
|
||||
wait_for_decommission_active(&run.dist.cluster, 0, MOVEMENT_ACTIVE_TIMEOUT).await?;
|
||||
let during = put_probe_objects(&run.dist, &scratch, "during-decommission", 4).await?;
|
||||
wait_for_decommission_complete(&run.dist.cluster, 0, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||
wait_for_decommission_complete(&run.dist.cluster, 1, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||
let (rejected_status, rejected_body) = expect_decommission_rejected(&run.dist.cluster, "2,3").await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "after retiring pools 0 and 1").await?;
|
||||
let final_placement = assert_single_pool_placement(&run.dist, &manifest, &pools(&[2, 3]), "after retiring pools 0 and 1")?;
|
||||
assert_probe_objects(&run.dist, &scratch, &during, &pools(&[2, 3]), "writes issued during decommission").await?;
|
||||
let sidecars = assert_sidecars_survive_on_targets(&run, &manifest, &pools(&[2, 3]), "after retiring pools 0 and 1")?;
|
||||
|
||||
// A decommissioned pool must leave the command line; the server refuses to
|
||||
// start while it is still listed. Pools 2 and 3 are renumbered to 0 and 1.
|
||||
run.dist.remove_retired_pools_and_restart(&[0, 1]).await?;
|
||||
let pools_after_restart = pool_count(&run.dist.cluster).await?;
|
||||
if pools_after_restart != 2 {
|
||||
return Err(format!("expected two pools after removing the retired pools, found {pools_after_restart}").into());
|
||||
}
|
||||
verify_from_every_node(&run.dist, &manifest, "after restart without the retired pools").await?;
|
||||
assert_single_pool_placement(&run.dist, &manifest, &pools(&[0, 1]), "after restart without the retired pools")?;
|
||||
let status_after_restart = decommission_status_json(&run.dist.cluster).await?;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
|
||||
|
||||
run.write_evidence(
|
||||
"g-dec-decommission",
|
||||
&json!({
|
||||
"gate": "G-DEC",
|
||||
"manifest": manifest_summary(&manifest),
|
||||
"cancel": canceled,
|
||||
"placement_after_cancel_pools": after_cancel.values().collect::<BTreeSet<_>>(),
|
||||
"queued_pool_1": queued,
|
||||
"final_placement_pools": final_placement.values().collect::<BTreeSet<_>>(),
|
||||
"sidecars_on_targets": sidecars,
|
||||
"last_pool_rejection": { "status": rejected_status.as_u16(), "body": rejected_body },
|
||||
"status_after_restart": status_after_restart,
|
||||
"process_durability_strict_nodes": strict_nodes,
|
||||
"timeouts": timeouts(),
|
||||
}),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Outcome of a fault case: the worker result and the data verdict are
|
||||
/// reported separately so a fail-closed worker and a lost version never hide
|
||||
/// each other.
|
||||
async fn settle_decommission(
|
||||
run: &TopologyRun,
|
||||
manifest: &TopologyManifest,
|
||||
label: &str,
|
||||
completion_timeout: Duration,
|
||||
) -> TestResult<Value> {
|
||||
let completion = wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, completion_timeout).await;
|
||||
let allowed = if completion.is_ok() {
|
||||
pools(&TARGET_POOLS)
|
||||
} else {
|
||||
pools(&ALL_POOLS)
|
||||
};
|
||||
let integrity = async {
|
||||
verify_from_every_node(&run.dist, manifest, label).await?;
|
||||
wait_for_single_pool_placement(run, manifest, &allowed, label).await
|
||||
}
|
||||
.await;
|
||||
let status = decommission_status_json(&run.dist.cluster).await.unwrap_or(Value::Null);
|
||||
settle(label, completion, integrity, status)
|
||||
}
|
||||
|
||||
async fn settle_rebalance(run: &TopologyRun, manifest: &TopologyManifest, rebalance_id: &str, label: &str) -> TestResult<Value> {
|
||||
let completion = wait_for_rebalance_complete(&run.dist.cluster, rebalance_id, MOVEMENT_COMPLETE_TIMEOUT).await;
|
||||
let integrity = async {
|
||||
verify_from_every_node(&run.dist, manifest, label).await?;
|
||||
wait_for_single_pool_placement(run, manifest, &pools(&ALL_POOLS), label).await
|
||||
}
|
||||
.await;
|
||||
let status = rebalance_status_json(&run.dist.cluster).await.unwrap_or(Value::Null);
|
||||
settle(label, completion, integrity, status)
|
||||
}
|
||||
|
||||
fn settle(
|
||||
label: &str,
|
||||
completion: TestResult,
|
||||
integrity: TestResult<std::collections::BTreeMap<String, usize>>,
|
||||
status: Value,
|
||||
) -> TestResult<Value> {
|
||||
match (completion, integrity) {
|
||||
(Ok(()), Ok(placement)) => {
|
||||
Ok(json!({ "worker": "completed", "placement_pools": placement.values().collect::<BTreeSet<_>>(), "status": status }))
|
||||
}
|
||||
(Err(worker), Ok(_)) => {
|
||||
Err(format!("{label}: data stayed intact but the worker did not complete: {worker}; status {status}").into())
|
||||
}
|
||||
(Ok(()), Err(data)) => Err(format!("{label}: worker completed but data diverged: {data}").into()),
|
||||
(Err(worker), Err(data)) => {
|
||||
Err(format!("{label}: worker did not complete: {worker}; and data diverged: {data}; status {status}").into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record(run: &TopologyRun, case: &str, outcome: &TestResult<Value>, mut evidence: Value) -> TestResult {
|
||||
evidence["outcome"] = match outcome {
|
||||
Ok(detail) => detail.clone(),
|
||||
Err(error) => json!({ "error": error.to_string() }),
|
||||
};
|
||||
run.write_evidence(case, &evidence)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// G-DEC fault: SIGKILL the decommission leader while an entry is held after
|
||||
/// its target commit and before source cleanup; the resumed worker must
|
||||
/// finish without losing or duplicating the version.
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_without_loss() -> TestResult {
|
||||
init_logging();
|
||||
require_test_hooks();
|
||||
let mut run = TopologyRun::start("decom-kill").await?;
|
||||
let manifest = seed_manifest(&run).await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
|
||||
expand_and_verify(&mut run, &manifest).await?;
|
||||
|
||||
let key = manifest.plain_key("external", 3)?;
|
||||
run.movement_barrier
|
||||
.arm_movement("decommission", &manifest.plain_bucket, &key, STAGE_BEFORE_SOURCE_CLEANUP)?;
|
||||
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
let reached = run
|
||||
.movement_barrier
|
||||
.wait_reached_during(
|
||||
&run.dist.cluster,
|
||||
MovementOperation::Decommission(DECOMMISSION_POOL_ID),
|
||||
MOVEMENT_COMPLETE_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
|
||||
if !window.contains(&SEED_POOL) || window.len() < 2 {
|
||||
return Err(format!(
|
||||
"barrier window did not show a committed target beside the retained source for {key}: pools {window:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let killed_pid = run.kill_node(SEED_POOL)?;
|
||||
run.movement_barrier.disarm()?;
|
||||
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
|
||||
let label = "after the killed leader resumed decommission";
|
||||
let outcome = settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
"g-dec-kill-before-source-cleanup",
|
||||
&outcome,
|
||||
json!({
|
||||
"gate": "G-DEC",
|
||||
"window": STAGE_BEFORE_SOURCE_CLEANUP,
|
||||
"nonce": run.movement_barrier.nonce(),
|
||||
"object": key,
|
||||
"reached": reached,
|
||||
"pools_holding_at_window": window,
|
||||
"killed_pid": killed_pid,
|
||||
"restarted_pid": restarted_pid,
|
||||
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
|
||||
"timeouts": timeouts(),
|
||||
}),
|
||||
)?;
|
||||
outcome?;
|
||||
strict_nodes?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// G-DEC fault: inject a target commit failure after the multipart copy is
|
||||
/// staged; the source must stay authoritative and a later attempt completes.
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_decommission_target_commit_failure_retains_source() -> TestResult {
|
||||
init_logging();
|
||||
require_test_hooks();
|
||||
let mut run = TopologyRun::start("decom-target-fail").await?;
|
||||
let manifest = seed_manifest(&run).await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
|
||||
expand_and_verify(&mut run, &manifest).await?;
|
||||
|
||||
let key = manifest.plain_key("multipart", 0)?;
|
||||
run.movement_barrier
|
||||
.arm_movement("decommission", &manifest.plain_bucket, &key, STAGE_BEFORE_TARGET_COMMIT)?;
|
||||
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
let reached = run
|
||||
.movement_barrier
|
||||
.wait_reached_during(
|
||||
&run.dist.cluster,
|
||||
MovementOperation::Decommission(DECOMMISSION_POOL_ID),
|
||||
MOVEMENT_COMPLETE_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
|
||||
if window != pools(&[SEED_POOL]) {
|
||||
return Err(format!("the target must not be published before its commit; {key} is held by {window:?}").into());
|
||||
}
|
||||
run.movement_barrier.disarm()?;
|
||||
run.movement_barrier.release_movement_with_io_error()?;
|
||||
|
||||
let label = "after the failed target commit was retried";
|
||||
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
"g-dec-target-commit-failure",
|
||||
&outcome,
|
||||
json!({
|
||||
"gate": "G-DEC",
|
||||
"window": STAGE_BEFORE_TARGET_COMMIT,
|
||||
"nonce": run.movement_barrier.nonce(),
|
||||
"object": key,
|
||||
"reached": reached,
|
||||
"injection": "io_error",
|
||||
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
|
||||
"timeouts": timeouts(),
|
||||
}),
|
||||
)?;
|
||||
outcome?;
|
||||
strict_nodes?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// G-DEC fault: kill the leader after the decommission start transaction is
|
||||
/// durable in `pool.bin` but before the admin request is acknowledged and
|
||||
/// before any worker starts; the persisted responsibility must resume on restart.
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_decommission_kill_after_responsibility_persisted_before_ack_resumes() -> TestResult {
|
||||
init_logging();
|
||||
require_test_hooks();
|
||||
let mut run = TopologyRun::start("decom-ack").await?;
|
||||
let manifest = seed_manifest(&run).await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
|
||||
expand_and_verify(&mut run, &manifest).await?;
|
||||
|
||||
run.movement_barrier
|
||||
.arm_movement("decommission_start", ".rustfs.sys", "pool.bin", STAGE_AFTER_RESPONSIBILITY_PERSISTED)?;
|
||||
let leader_url = run.dist.cluster.nodes[SEED_POOL].url.clone();
|
||||
let access_key = run.dist.cluster.access_key.clone();
|
||||
let secret_key = run.dist.cluster.secret_key.clone();
|
||||
let start_request = tokio::spawn(async move {
|
||||
// Mirror start_decommission: the init stage answers 500 with the fleet
|
||||
// proof marker until the fleet has proven its capability.
|
||||
let path = format!("/rustfs/admin/v3/pools/decommission?pool={DECOMMISSION_POOL_ID}&by-id=true");
|
||||
// A busy movement gate answers a retryable 503; keep retrying within
|
||||
// the window budget so a refused start is never read as a reached window.
|
||||
let deadline = Instant::now() + PRE_ACK_REACH_TIMEOUT;
|
||||
loop {
|
||||
match admin_request(&leader_url, Method::POST, &path, None, &access_key, &secret_key).await {
|
||||
Ok((status, body))
|
||||
if (status.as_u16() == 503
|
||||
|| (status.as_u16() == 500 && body.contains("requires a live fleet capability proof")))
|
||||
&& Instant::now() < deadline =>
|
||||
{
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok((status, body)) => return Ok(format!("{status} {body}")),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
});
|
||||
// The start request normally reaches the durable save within about a
|
||||
// second. If it returns first the window was never entered; if it stays in
|
||||
// flight past the budget, the start path itself stalled before the save.
|
||||
let requested_at = Instant::now();
|
||||
let mut start_request = start_request;
|
||||
let reached = tokio::select! {
|
||||
reached = run.movement_barrier.wait_reached(PRE_ACK_REACH_TIMEOUT) => match reached {
|
||||
Ok(reached) => reached,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"the decommission start stayed in flight for {PRE_ACK_REACH_TIMEOUT:?} without reaching the durable start save: {error}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
},
|
||||
finished = &mut start_request => {
|
||||
return Err(format!("the decommission start returned before the pre-ACK window was reached: {:?}", finished?).into());
|
||||
}
|
||||
};
|
||||
let reach_elapsed = requested_at.elapsed();
|
||||
let killed_pid = run.kill_node(SEED_POOL)?;
|
||||
let start_outcome = start_request.await?;
|
||||
if let Ok(response) = &start_outcome {
|
||||
return Err(format!(
|
||||
"the decommission start was acknowledged ({response}) although the leader was killed in the pre-ACK window"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
run.movement_barrier.disarm()?;
|
||||
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
|
||||
|
||||
let label = "after the persisted decommission resumed";
|
||||
let resumed = wait_for_decommission_active(&run.dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(60)).await;
|
||||
let outcome = match resumed {
|
||||
Ok(()) => settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await,
|
||||
Err(error) => Err(format!("{label}: the persisted decommission did not become active after the restart: {error}").into()),
|
||||
};
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
"g-dec-kill-before-ack",
|
||||
&outcome,
|
||||
json!({
|
||||
"gate": "G-DEC",
|
||||
"window": STAGE_AFTER_RESPONSIBILITY_PERSISTED,
|
||||
"nonce": run.movement_barrier.nonce(),
|
||||
"reached": reached,
|
||||
"reach_elapsed_ms": reach_elapsed.as_millis() as u64,
|
||||
"start_request": { "outcome": "uncertain", "error": start_outcome.err() },
|
||||
"killed_pid": killed_pid,
|
||||
"restarted_pid": restarted_pid,
|
||||
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
|
||||
"timeouts": timeouts(),
|
||||
}),
|
||||
)?;
|
||||
outcome?;
|
||||
strict_nodes?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// G-REB fault: SIGKILL the rebalancing node while an entry is held after its
|
||||
/// target commit and before source cleanup; the resumed run must complete
|
||||
/// without losing or duplicating the version.
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_rebalance_kill_before_source_cleanup_resumes_without_loss() -> TestResult {
|
||||
init_logging();
|
||||
require_test_hooks();
|
||||
let mut run = TopologyRun::start("rebal-kill").await?;
|
||||
let manifest = seed_manifest(&run).await?;
|
||||
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
|
||||
expand_and_verify(&mut run, &manifest).await?;
|
||||
|
||||
let key = manifest.plain_key("empty", 0)?;
|
||||
run.movement_barrier
|
||||
.arm_movement("rebalance", &manifest.plain_bucket, &key, STAGE_BEFORE_SOURCE_CLEANUP)?;
|
||||
let rebalance_id = start_rebalance(&run.dist.cluster).await?;
|
||||
let reached = run
|
||||
.movement_barrier
|
||||
.wait_reached_during(&run.dist.cluster, MovementOperation::Rebalance(&rebalance_id), MOVEMENT_COMPLETE_TIMEOUT)
|
||||
.await?;
|
||||
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
|
||||
if !window.contains(&SEED_POOL) || window.len() < 2 {
|
||||
return Err(format!(
|
||||
"barrier window did not show a committed target beside the retained source for {key}: pools {window:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let killed_pid = run.kill_node(SEED_POOL)?;
|
||||
run.movement_barrier.disarm()?;
|
||||
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
|
||||
let label = "after the killed node resumed rebalance";
|
||||
let outcome = settle_rebalance(&run, &manifest, &rebalance_id, label).await;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
"g-reb-kill-before-source-cleanup",
|
||||
&outcome,
|
||||
json!({
|
||||
"gate": "G-REB",
|
||||
"window": STAGE_BEFORE_SOURCE_CLEANUP,
|
||||
"nonce": run.movement_barrier.nonce(),
|
||||
"object": key,
|
||||
"reached": reached,
|
||||
"pools_holding_at_window": window,
|
||||
"rebalance_id": rebalance_id,
|
||||
"killed_pid": killed_pid,
|
||||
"restarted_pid": restarted_pid,
|
||||
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
|
||||
"timeouts": timeouts(),
|
||||
}),
|
||||
)?;
|
||||
outcome?;
|
||||
strict_nodes?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,6 +17,14 @@ use crate::common::init_logging;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_explicit_delete_marker_batch_is_immediately_visible() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("explicit-marker-batch");
|
||||
crate::delete_objects_versioning_test::assert_explicit_marker_batch_cleanup(&dist.clients()?, &bucket).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -45,6 +45,9 @@ pub mod on_demand_migration;
|
||||
#[cfg(test)]
|
||||
pub mod fault_proxy;
|
||||
|
||||
#[cfg(test)]
|
||||
mod rpc_fault_proxy;
|
||||
|
||||
// Reliability tests built on the fault-injection harness
|
||||
#[cfg(test)]
|
||||
mod reliability_disk_fault_test;
|
||||
@@ -189,6 +192,9 @@ mod conditional_delete_objects_test;
|
||||
#[cfg(test)]
|
||||
mod delete_authorization_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod table_location_authorization_test;
|
||||
|
||||
// Regression test for signed DELETE Object?versionId requests without Content-Length.
|
||||
#[cfg(test)]
|
||||
mod delete_object_no_content_length_test;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Harbor / Docker Distribution multipart staging to CopyObject regressions.
|
||||
//! Multipart completion validation, staging preservation, and CopyObject readiness.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
@@ -39,6 +39,132 @@ fn list_contains_key(output: &aws_sdk_s3::operation::list_objects_v2::ListObject
|
||||
.any(|object| object.key().is_some_and(|candidate| candidate == key))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_complete_invalid_etag_returns_400_and_preserves_retry() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "multipart-invalid-etag";
|
||||
let key = "object";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
let original = b"original object";
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(original))
|
||||
.send()
|
||||
.await?;
|
||||
let upload = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||
let upload_id = upload.upload_id().ok_or("missing upload id")?;
|
||||
let payload = b"completed multipart payload";
|
||||
let mut uploaded_etag = None;
|
||||
|
||||
// The first case reproduces #8771's Create -> Complete sequence without UploadPart.
|
||||
for (upload_first, part_number, etag) in [
|
||||
(false, 1, Some("unknown-etag")),
|
||||
(true, 1, Some("unknown-etag")),
|
||||
(true, 1, Some("\"unknown-etag\"")),
|
||||
(true, 1, Some("00000000000000000000000000000000")),
|
||||
(true, 1, None),
|
||||
(true, 2, Some("unknown-etag")),
|
||||
] {
|
||||
if upload_first && uploaded_etag.is_none() {
|
||||
let part = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(payload))
|
||||
.send()
|
||||
.await?;
|
||||
uploaded_etag = Some(part.e_tag().ok_or("missing part ETag")?.to_owned());
|
||||
}
|
||||
let error = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.set_e_tag(etag.map(str::to_owned))
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("missing or mismatched parts must not complete the upload");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400), "{error:?}");
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidPart"),
|
||||
"{error:?}"
|
||||
);
|
||||
|
||||
let staged = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(staged.parts().len(), usize::from(uploaded_etag.is_some()));
|
||||
if let Some(etag) = uploaded_etag.as_deref() {
|
||||
assert_eq!(staged.parts()[0].part_number(), Some(1));
|
||||
assert_eq!(staged.parts()[0].e_tag(), Some(etag));
|
||||
}
|
||||
let current = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(current.as_ref(), original, "rejected completion must preserve the original object");
|
||||
}
|
||||
|
||||
let etag = uploaded_etag.ok_or("part was not uploaded")?;
|
||||
// Accept the unquoted value, then replay with the quoted UploadPart response.
|
||||
for etag in [etag.trim_matches('"'), etag.as_str()] {
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(CompletedPart::builder().part_number(1).e_tag(etag).build())
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let current = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(current.as_ref(), payload, "a valid retry must publish the exact uploaded bytes");
|
||||
client.delete_object().bucket(bucket).key(key).send().await?;
|
||||
env.delete_test_bucket(bucket).await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_one_part_mpu(
|
||||
client: &aws_sdk_s3::Client,
|
||||
bucket: &str,
|
||||
|
||||
@@ -230,6 +230,302 @@ fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Sign the data and terminating chunks independently of the server decoder.
|
||||
fn encode_signed_aws_chunked(signer: &SigV4, headers: &SignedHeaders, decoded: &[u8], trailer: bool) -> Vec<u8> {
|
||||
let mut previous = headers
|
||||
.authorization
|
||||
.rsplit_once("Signature=")
|
||||
.expect("seed signature")
|
||||
.1
|
||||
.to_string();
|
||||
let mut encoded = Vec::new();
|
||||
for chunk in [decoded, b"".as_slice()] {
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{previous}\n{}\n{}",
|
||||
headers.amz_date,
|
||||
get_scope(REGION, signer.time, "s3"),
|
||||
sha256_hex(b""),
|
||||
sha256_hex(chunk),
|
||||
);
|
||||
previous = get_signature(get_signing_key(&signer.secret_key, REGION, signer.time, "s3"), &string_to_sign);
|
||||
encoded.extend_from_slice(format!("{:x};chunk-signature={previous}\r\n", chunk.len()).as_bytes());
|
||||
encoded.extend_from_slice(chunk);
|
||||
encoded.extend_from_slice(b"\r\n");
|
||||
}
|
||||
if trailer {
|
||||
let checksum = sha256_base64(decoded);
|
||||
let canonical = format!("x-amz-checksum-sha256:{checksum}\n");
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256-TRAILER\n{}\n{}\n{previous}\n{}",
|
||||
headers.amz_date,
|
||||
get_scope(REGION, signer.time, "s3"),
|
||||
sha256_hex(canonical.as_bytes()),
|
||||
);
|
||||
let signature = get_signature(get_signing_key(&signer.secret_key, REGION, signer.time, "s3"), &string_to_sign);
|
||||
encoded.extend_from_slice(
|
||||
format!("x-amz-checksum-sha256:{checksum}\r\nx-amz-trailer-signature:{signature}\r\n\r\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Exercise decoder failures through both storage publication boundaries.
|
||||
#[tokio::test]
|
||||
async fn aws_chunked_named_errors_reject_put_and_upload_part() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
let client = env.create_s3_client();
|
||||
let decoded = b"private-chunked-fixture-body";
|
||||
let mut failures = Vec::new();
|
||||
for multipart in [false, true] {
|
||||
for (case, expected_status, expected_code) in [
|
||||
("signature", 403, "SignatureDoesNotMatch"),
|
||||
("final-signature", 403, "SignatureDoesNotMatch"),
|
||||
("format", 400, "IncompleteBody"),
|
||||
("metadata-at-limit", 400, "IncompleteBody"),
|
||||
("metadata-over-limit", 400, "EntityTooLarge"),
|
||||
("chunk-over-limit", 400, "EntityTooLarge"),
|
||||
("trailers-over-limit", 400, "IncompleteBody"),
|
||||
("too-many-trailers", 400, "IncompleteBody"),
|
||||
] {
|
||||
let key = format!("chunked-{case}-{multipart}.bin");
|
||||
let upload_id = if multipart {
|
||||
Some(
|
||||
client
|
||||
.create_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await?
|
||||
.upload_id()
|
||||
.ok_or("missing upload ID")?
|
||||
.to_owned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let query = upload_id
|
||||
.as_ref()
|
||||
.map(|id| format!("partNumber=1&uploadId={}", urlencoding::encode(id)))
|
||||
.unwrap_or_default();
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let target = if query.is_empty() {
|
||||
path.clone()
|
||||
} else {
|
||||
format!("{path}?{query}")
|
||||
};
|
||||
let signer = SigV4::new(&env);
|
||||
let unsigned = matches!(case, "trailers-over-limit" | "too-many-trailers");
|
||||
let mode = if unsigned {
|
||||
UNSIGNED_PAYLOAD_TRAILER
|
||||
} else {
|
||||
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
};
|
||||
let length = decoded.len().to_string();
|
||||
let mut extra = vec![
|
||||
("content-encoding", "aws-chunked"),
|
||||
("x-amz-decoded-content-length", length.as_str()),
|
||||
];
|
||||
if unsigned {
|
||||
extra.push(("x-amz-trailer", "x-amz-checksum-sha256"));
|
||||
}
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, &query, mode, &extra);
|
||||
let encoded = match case {
|
||||
"signature" | "final-signature" => {
|
||||
let mut body = encode_signed_aws_chunked(&signer, &headers, decoded, false);
|
||||
let offset = if case == "signature" {
|
||||
body.windows(2).position(|w| w == b"\r\n").expect("chunk header") + 2
|
||||
} else {
|
||||
body.len() - 5
|
||||
};
|
||||
body[offset] = if body[offset] == b'a' { b'b' } else { b'a' };
|
||||
body
|
||||
}
|
||||
"format" => b"invalid-chunk-size\r\n".to_vec(),
|
||||
"metadata-at-limit" | "metadata-over-limit" => {
|
||||
let size = if case == "metadata-at-limit" { 1024 } else { 1025 };
|
||||
let mut body = vec![b'z'; size - 2];
|
||||
body.extend_from_slice(b"\r\n");
|
||||
body
|
||||
}
|
||||
"chunk-over-limit" => format!("10000001;chunk-signature={}\r\n", "0".repeat(64)).into_bytes(),
|
||||
"trailers-over-limit" | "too-many-trailers" => {
|
||||
let mut body = format!("{:x}\r\n", decoded.len()).into_bytes();
|
||||
body.extend_from_slice(decoded);
|
||||
body.extend_from_slice(b"\r\n0\r\n");
|
||||
if case == "trailers-over-limit" {
|
||||
body.extend_from_slice(format!("x-private:{}\r\n\r\n", "v".repeat(16385 - 14)).as_bytes());
|
||||
} else {
|
||||
body.extend_from_slice("x-private:v\r\n".repeat(101).as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
}
|
||||
body
|
||||
}
|
||||
_ => unreachable!("all fixtures are enumerated"),
|
||||
};
|
||||
let mut request = local_http_client()
|
||||
.put(format!("{}{target}", env.url))
|
||||
.header("authorization", &headers.authorization)
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("x-amz-date", &headers.amz_date);
|
||||
for (name, value) in extra {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = request
|
||||
.body(encoded)
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await?;
|
||||
eprintln!("aws-chunked {case} multipart={multipart}: HTTP {status}, {body}");
|
||||
if status != expected_status || !body.contains(&format!("<Code>{expected_code}</Code>")) {
|
||||
failures.push(format!(
|
||||
"{case} multipart={multipart}: expected {expected_status} {expected_code}, got {status} {body}"
|
||||
));
|
||||
}
|
||||
for private in [
|
||||
"private-chunked-fixture-body",
|
||||
"chunk-signature=",
|
||||
"AwsChunkedStreamError",
|
||||
"x-private",
|
||||
] {
|
||||
if body.contains(private) {
|
||||
failures.push(format!("{case}: response exposed {private}"));
|
||||
}
|
||||
}
|
||||
if let Some(id) = upload_id {
|
||||
let parts = client.list_parts().bucket(BUCKET).key(&key).upload_id(&id).send().await?;
|
||||
assert!(parts.parts().is_empty(), "{case}: rejected UploadPart published a part");
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.upload_id(id)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let absent = client
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a rejected body must not publish an object");
|
||||
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aws_chunked_valid_signed_and_trailer_boundaries_succeed() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
let client = env.create_s3_client();
|
||||
let decoded = b"valid-chunked-control";
|
||||
for (case, multipart) in ["signed", "signed-trailer", "trailer-size-limit", "trailer-count-limit"]
|
||||
.into_iter()
|
||||
.flat_map(|case| [false, true].map(|multipart| (case, multipart)))
|
||||
{
|
||||
let key = format!("{case}-{multipart}");
|
||||
let upload_id = if multipart {
|
||||
Some(
|
||||
client
|
||||
.create_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await?
|
||||
.upload_id()
|
||||
.ok_or("missing upload ID")?
|
||||
.to_owned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let query = upload_id
|
||||
.as_ref()
|
||||
.map(|id| format!("partNumber=1&uploadId={}", urlencoding::encode(id)))
|
||||
.unwrap_or_default();
|
||||
let signer = SigV4::new(&env);
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let target = if query.is_empty() {
|
||||
path.clone()
|
||||
} else {
|
||||
format!("{path}?{query}")
|
||||
};
|
||||
let length = decoded.len().to_string();
|
||||
let mode = match case {
|
||||
"signed" => "STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
|
||||
"signed-trailer" => "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER",
|
||||
_ => UNSIGNED_PAYLOAD_TRAILER,
|
||||
};
|
||||
let mut extra = vec![
|
||||
("content-encoding", "aws-chunked"),
|
||||
("x-amz-decoded-content-length", length.as_str()),
|
||||
];
|
||||
if case != "signed" {
|
||||
extra.push(("x-amz-trailer", "x-amz-checksum-sha256"));
|
||||
}
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, &query, mode, &extra);
|
||||
let mut encoded = if case.starts_with("signed") {
|
||||
encode_signed_aws_chunked(&signer, &headers, decoded, case == "signed-trailer")
|
||||
} else {
|
||||
encode_unsigned_aws_chunked_with_sha256_trailer(decoded)
|
||||
};
|
||||
if !case.starts_with("signed") {
|
||||
encoded.truncate(encoded.len() - 2);
|
||||
if case == "trailer-size-limit" {
|
||||
let checksum_line_size = format!("x-amz-checksum-sha256:{}\r\n", sha256_base64(decoded)).len();
|
||||
encoded.extend_from_slice(format!("x-padding:{}\r\n", "v".repeat(16384 - checksum_line_size - 14)).as_bytes());
|
||||
} else {
|
||||
encoded.extend_from_slice("x-padding:v\r\n".repeat(99).as_bytes());
|
||||
}
|
||||
encoded.extend_from_slice(b"\r\n");
|
||||
}
|
||||
let mut request = local_http_client()
|
||||
.put(format!("{}{target}", env.url))
|
||||
.header("authorization", &headers.authorization)
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("x-amz-date", &headers.amz_date);
|
||||
for (name, value) in extra {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = request
|
||||
.body(encoded)
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let etag = response
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let body = response.text().await?;
|
||||
assert_eq!(status.as_u16(), 200, "{case}: {body}");
|
||||
if let Some(id) = upload_id {
|
||||
let part = aws_sdk_s3::types::CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(etag.ok_or("successful UploadPart omitted ETag")?)
|
||||
.build();
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.upload_id(id)
|
||||
.multipart_upload(aws_sdk_s3::types::CompletedMultipartUpload::builder().parts(part).build())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let object = client.get_object().bucket(BUCKET).key(&key).send().await?;
|
||||
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), decoded, "{case}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Positive control: a correctly hand-signed request must succeed. Without
|
||||
/// this, every negative assertion below could pass for the wrong reason (a
|
||||
/// broken signer that never produces a valid signature).
|
||||
@@ -566,47 +862,54 @@ async fn tampered_payload_is_rejected() -> Result<(), Box<dyn std::error::Error
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
|
||||
let key = "tampered-payload.txt";
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let claimed_body = b"the-body-i-claim-to-send";
|
||||
let actual_body = b"the-body-i-really-send!!";
|
||||
assert_eq!(claimed_body.len(), actual_body.len(), "keep content-length stable for the mismatch");
|
||||
use md5::{Digest, Md5};
|
||||
|
||||
let signer = SigV4::new(&env);
|
||||
// Sign over the hash of the CLAIMED body (single-chunk payload hash), then
|
||||
// send a different body of equal length.
|
||||
let headers = signer.sign("PUT", &path, "", &sha256_hex(claimed_body));
|
||||
|
||||
let result = send_signed(&env, reqwest::Method::PUT, &path, &headers, Some(actual_body.to_vec())).await;
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let client = env.create_s3_client();
|
||||
// Empty, small-eager, zero-copy, and streaming PUTs must share the error contract.
|
||||
for size in [0, 24, 2 * 1024 * 1024, 17 * 1024 * 1024] {
|
||||
let actual_body = vec![b'a'; size];
|
||||
let wrong_hash = sha256_hex(b"a different payload");
|
||||
let content_md5 = base64_simd::STANDARD.encode_to_string(Md5::digest(&actual_body));
|
||||
for with_md5 in [false, true] {
|
||||
let key = format!("tampered-payload-{size}-{with_md5}.txt");
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let extra = if with_md5 {
|
||||
vec![("content-md5", content_md5.as_str())]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, "", &wrong_hash, &extra);
|
||||
let mut request = local_http_client()
|
||||
.put(format!("{}{}", env.url, path))
|
||||
.header("x-amz-date", &headers.amz_date)
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("authorization", &headers.authorization);
|
||||
for (name, value) in extra {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = request.body(actual_body.clone()).send().await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "payload mismatch: {body}");
|
||||
assert_error_code(&body, "XAmzContentSHA256Mismatch");
|
||||
assert!(
|
||||
status.is_client_error(),
|
||||
"payload mismatch must be rejected with a client error, got {status}, body:\n{body}"
|
||||
body.replace("'", "'")
|
||||
.contains("<Message>The provided 'x-amz-content-sha256' header does not match what was computed.</Message>"),
|
||||
"wrong message: {body}"
|
||||
);
|
||||
info!(%status, "tampered payload rejected with error status");
|
||||
}
|
||||
// A mid-stream hash-mismatch abort surfacing as a transport error is
|
||||
// also a valid rejection (definitely not a 200 success).
|
||||
Err(err) => {
|
||||
assert!(!err.is_connect(), "connection failure is not proof of payload rejection: {err}");
|
||||
assert!(!err.is_timeout(), "request timeout is not proof of payload rejection: {err}");
|
||||
info!(%err, "tampered payload rejected via mid-stream transport error");
|
||||
|
||||
let absent = client
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a tampered payload must not publish an object");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
}
|
||||
}
|
||||
|
||||
let absent = env
|
||||
.create_s3_client()
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a tampered payload must not publish an object");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -638,9 +941,9 @@ async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::e
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"multipart payload mismatch must be rejected as BadDigest, body:\n{body}"
|
||||
"multipart payload mismatch must be rejected as XAmzContentSHA256Mismatch, body:\n{body}"
|
||||
);
|
||||
assert_error_code(&body, "BadDigest");
|
||||
assert_error_code(&body, "XAmzContentSHA256Mismatch");
|
||||
|
||||
let parts = client
|
||||
.list_parts()
|
||||
|
||||
@@ -10085,10 +10085,9 @@ async fn build_scanner_compensation_pair(
|
||||
/// P1-20: objects that already exist when a rule with
|
||||
/// ExistingObjectReplication=Enabled arrives are compensated by the scanner's
|
||||
/// existing-object resync pass, whatever wrote them — plain PUT, CopyObject,
|
||||
/// or Snowball auto-extract. The pinned exception is a null-version object
|
||||
/// (written before the bucket became versioned): the scanner heal gate skips
|
||||
/// nil-version objects entirely (`scanner_folder.rs` heal_replication), so it
|
||||
/// must NEVER be compensated.
|
||||
/// or Snowball auto-extract. That includes a null-version object written
|
||||
/// before the bucket became versioned (rustfs/rustfs#8747): it is compensated
|
||||
/// like any other existing object and lands as the target's null version.
|
||||
#[tokio::test]
|
||||
async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestResult {
|
||||
init_logging();
|
||||
@@ -10098,14 +10097,54 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
|
||||
// Null-version cell: PUT before versioning; the object keeps the nil
|
||||
// version id forever.
|
||||
// Null-version cell: PUT before versioning; the object keeps the null
|
||||
// version forever.
|
||||
let null_key = "pre-versioning-null.txt";
|
||||
let null_payload = "null version payload";
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(null_key)
|
||||
.body(ByteStream::from_static(b"null version payload"))
|
||||
.body(ByteStream::from_static(null_payload.as_bytes()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Multipart null-version cell: a one-part upload already carries a
|
||||
// multipart ETag, so its replica travels the target's multipart path,
|
||||
// whose completion must keep the null version too.
|
||||
let multipart_null_key = "pre-versioning-null-multipart.txt";
|
||||
let multipart_null_payload = "multipart null version payload";
|
||||
let upload = source_client
|
||||
.create_multipart_upload()
|
||||
.bucket(source_bucket)
|
||||
.key(multipart_null_key)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = upload.upload_id().ok_or("multipart upload id missing")?;
|
||||
let part = source_client
|
||||
.upload_part()
|
||||
.bucket(source_bucket)
|
||||
.key(multipart_null_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(multipart_null_payload.as_bytes()))
|
||||
.send()
|
||||
.await?;
|
||||
source_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(source_bucket)
|
||||
.key(multipart_null_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.set_e_tag(part.e_tag().map(str::to_string))
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -10193,9 +10232,22 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
|
||||
wait_for_replicated_object(&target_client, target_bucket, copy_key, plain_payload).await?;
|
||||
wait_for_replicated_object(&target_client, target_bucket, member_key, std::str::from_utf8(member_payload)?).await?;
|
||||
|
||||
// Null-version contract: with every sibling compensated (scanner proven
|
||||
// live), the nil-version object must stay absent across further cycles.
|
||||
assert_replication_key_absent(&target_client, target_bucket, null_key, Duration::from_secs(6)).await?;
|
||||
// The null versions converge too, and keep their identity whichever path
|
||||
// carried them: each replica is the target's null version, not a freshly
|
||||
// minted one.
|
||||
for (key, payload) in [(null_key, null_payload), (multipart_null_key, multipart_null_payload)] {
|
||||
wait_for_replicated_object(&target_client, target_bucket, key, payload).await?;
|
||||
let null_replica = target_client
|
||||
.get_object()
|
||||
.bucket(target_bucket)
|
||||
.key(key)
|
||||
.version_id("null")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(null_replica.version_id(), Some("null"), "{key} must replicate as the null version");
|
||||
let null_replica_body = null_replica.body.collect().await?.into_bytes();
|
||||
assert_eq!(null_replica_body.as_ref(), payload.as_bytes());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
// Copyright 2026 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.
|
||||
|
||||
//! Method-selective faults without changing the server or gRPC body/trailers.
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt as _, Full, Limited, combinators::BoxBody};
|
||||
use hyper::{Request, body::Incoming, service::service_fn};
|
||||
use hyper_util::{
|
||||
client::legacy::{Client, connect::HttpConnector},
|
||||
rt::{TokioExecutor, TokioIo},
|
||||
server::conn::auto,
|
||||
};
|
||||
use prost::Message as _;
|
||||
use rustfs_protos::proto_gen::node_service::{DeleteRequest, ReadVersionRequest};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{Notify, watch},
|
||||
task::{JoinHandle, JoinSet},
|
||||
};
|
||||
|
||||
type ProxyBody = BoxBody<Bytes, io::Error>;
|
||||
type ProxyClient = Client<HttpConnector, ProxyBody>;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Disposition {
|
||||
Hold,
|
||||
Forward,
|
||||
Discard,
|
||||
}
|
||||
|
||||
struct PauseState {
|
||||
methods: Vec<&'static str>,
|
||||
scope: Option<Vec<u8>>,
|
||||
limit: usize,
|
||||
matched: AtomicUsize,
|
||||
blocked: AtomicUsize,
|
||||
forwarded: AtomicUsize,
|
||||
forwarded_routes: Mutex<HashSet<String>>,
|
||||
reached: Notify,
|
||||
release: watch::Sender<Disposition>,
|
||||
}
|
||||
|
||||
type PauseSlot = Arc<Mutex<Option<Arc<PauseState>>>>;
|
||||
|
||||
pub(crate) struct RpcPause {
|
||||
state: Arc<PauseState>,
|
||||
slot: PauseSlot,
|
||||
}
|
||||
|
||||
impl RpcPause {
|
||||
pub(crate) fn hits(&self) -> usize {
|
||||
self.state.blocked.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Selected RPCs whose backend returned a response, excluding discarded packets.
|
||||
pub(crate) fn forwarded(&self) -> usize {
|
||||
self.state.forwarded.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(crate) fn forwarded_routes(&self) -> usize {
|
||||
self.state.forwarded_routes.lock().expect("RPC route observations").len()
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_hits(&self, count: usize) -> io::Result<()> {
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
loop {
|
||||
let notified = self.state.reached.notified();
|
||||
if self.hits() >= count {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "the selected RPC fault was not reached"))
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.settle(Disposition::Forward);
|
||||
}
|
||||
|
||||
/// Retire packets that outlived the caller's operation budget. A later
|
||||
/// healthy request must not replay an expired lock grant or mutation.
|
||||
pub(crate) fn discard_pending(&self) {
|
||||
self.settle(Disposition::Discard);
|
||||
}
|
||||
|
||||
fn settle(&self, disposition: Disposition) {
|
||||
self.state.release.send_if_modified(|current| {
|
||||
if *current == Disposition::Hold {
|
||||
*current = disposition;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RpcPause {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
let mut slot = self.slot.lock().expect("RPC pause slot");
|
||||
if slot.as_ref().is_some_and(|current| Arc::ptr_eq(current, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RpcFaultProxy {
|
||||
address: SocketAddr,
|
||||
slot: PauseSlot,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl RpcFaultProxy {
|
||||
pub(crate) async fn start_on(address: SocketAddr, target: SocketAddr) -> io::Result<Self> {
|
||||
let listener = TcpListener::bind(address).await?;
|
||||
let address = listener.local_addr()?;
|
||||
let slot: PauseSlot = Arc::new(Mutex::new(None));
|
||||
let active = slot.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let h1: ProxyClient = Client::builder(TokioExecutor::new()).build_http();
|
||||
let h2: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
let mut connections = JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let Ok((socket, _)) = accepted else { break };
|
||||
let h1 = h1.clone();
|
||||
let h2 = h2.clone();
|
||||
let active = active.clone();
|
||||
connections.spawn(async move {
|
||||
let service = service_fn(move |request: Request<Incoming>| {
|
||||
let client = if request.version() == http::Version::HTTP_2 { h2.clone() } else { h1.clone() };
|
||||
let pause = active.lock().expect("RPC pause slot").clone();
|
||||
forward_request(request, client, target, pause)
|
||||
|
||||
});
|
||||
let _ = auto::Builder::new(TokioExecutor::new())
|
||||
.serve_connection(TokioIo::new(socket), service).await;
|
||||
});
|
||||
}
|
||||
_ = connections.join_next(), if !connections.is_empty() => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self { address, slot, task })
|
||||
}
|
||||
|
||||
pub(crate) fn local_addr(&self) -> SocketAddr {
|
||||
self.address
|
||||
}
|
||||
|
||||
/// Pause at most limit requests to the named unary methods; other streams
|
||||
/// on the same HTTP/2 connection continue forwarding.
|
||||
pub(crate) fn pause(&self, methods: &[&'static str], limit: usize, scope: Option<&str>) -> io::Result<RpcPause> {
|
||||
let mut slot = self.slot.lock().expect("RPC pause slot");
|
||||
if slot.is_some() || methods.is_empty() || limit == 0 || scope.is_some_and(str::is_empty) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"RPC pause needs an empty slot, methods and a positive limit",
|
||||
));
|
||||
}
|
||||
let (release, _) = watch::channel(Disposition::Hold);
|
||||
let state = Arc::new(PauseState {
|
||||
methods: methods.to_vec(),
|
||||
scope: scope.map(|scope| scope.as_bytes().to_vec()),
|
||||
limit,
|
||||
matched: AtomicUsize::new(0),
|
||||
blocked: AtomicUsize::new(0),
|
||||
forwarded: AtomicUsize::new(0),
|
||||
forwarded_routes: Mutex::new(HashSet::new()),
|
||||
reached: Notify::new(),
|
||||
release,
|
||||
});
|
||||
*slot = Some(state.clone());
|
||||
Ok(RpcPause {
|
||||
state,
|
||||
slot: self.slot.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn unary_payload(body: &[u8]) -> io::Result<&[u8]> {
|
||||
if body.len() < 5 || body[0] != 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "scoped RPC needs an uncompressed unary frame"));
|
||||
}
|
||||
let mut encoded_length = [0; 4];
|
||||
encoded_length.copy_from_slice(&body[1..5]);
|
||||
if u32::try_from(body.len() - 5).ok() != Some(u32::from_be_bytes(encoded_length)) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "scoped RPC frame length mismatch"));
|
||||
}
|
||||
Ok(&body[5..])
|
||||
}
|
||||
|
||||
fn scope_matches(method: &str, body: &[u8], scope: &[u8]) -> io::Result<bool> {
|
||||
if method != "Delete" {
|
||||
return Ok(body.windows(scope.len()).any(|window| window == scope));
|
||||
}
|
||||
let request = DeleteRequest::decode(unary_payload(body)?).map_err(io::Error::other)?;
|
||||
let options: serde_json::Value = serde_json::from_str(&request.options).map_err(io::Error::other)?;
|
||||
Ok(request.volume == ".rustfs.sys/multipart"
|
||||
&& request.path.trim_end_matches('/').as_bytes() == scope
|
||||
&& options.get("recursive").and_then(serde_json::Value::as_bool) == Some(true))
|
||||
}
|
||||
|
||||
async fn forward_request(
|
||||
request: Request<Incoming>,
|
||||
client: ProxyClient,
|
||||
target: SocketAddr,
|
||||
pause: Option<Arc<PauseState>>,
|
||||
) -> io::Result<hyper::Response<Incoming>> {
|
||||
let (mut parts, body) = request.into_parts();
|
||||
let mut body = body.map_err(io::Error::other).boxed();
|
||||
let mut observed = None;
|
||||
if let Some(pause) = pause {
|
||||
let method = parts.uri.path().strip_prefix("/node_service.NodeService/");
|
||||
if let Some(method) = method.filter(|name| pause.methods.contains(name)) {
|
||||
let mut route = None;
|
||||
let matches = if let Some(scope) = pause.scope.as_deref() {
|
||||
// Inspect bounded control routing while preserving the exact
|
||||
// signed bytes and trailers forwarded to the backend.
|
||||
let collected = Limited::new(body, 1024 * 1024).collect().await.map_err(io::Error::other)?;
|
||||
let trailers = collected.trailers().cloned();
|
||||
let bytes = collected.to_bytes();
|
||||
let matches = scope_matches(method, &bytes, scope)?;
|
||||
if matches && method == "ReadVersion" {
|
||||
let disk = ReadVersionRequest::decode(unary_payload(&bytes)?)
|
||||
.map_err(io::Error::other)?
|
||||
.disk;
|
||||
if !disk.is_empty() {
|
||||
route = Some(disk);
|
||||
}
|
||||
}
|
||||
body = Full::new(bytes)
|
||||
.map_err(|never| match never {})
|
||||
.with_trailers(async move { trailers.map(Ok::<_, io::Error>) })
|
||||
.boxed();
|
||||
matches
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if matches && pause.matched.fetch_add(1, Ordering::SeqCst) < pause.limit {
|
||||
let mut released = pause.release.subscribe();
|
||||
pause.blocked.fetch_add(1, Ordering::SeqCst);
|
||||
pause.reached.notify_one();
|
||||
let disposition = loop {
|
||||
let disposition = *released.borrow_and_update();
|
||||
if disposition != Disposition::Hold {
|
||||
break disposition;
|
||||
}
|
||||
if released.changed().await.is_err() {
|
||||
break Disposition::Discard;
|
||||
}
|
||||
};
|
||||
if disposition == Disposition::Discard {
|
||||
return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "injected RPC packet loss"));
|
||||
}
|
||||
observed = Some((pause, route));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !parts.headers.contains_key(http::header::HOST)
|
||||
&& let Some(authority) = parts.uri.authority()
|
||||
{
|
||||
parts.headers.insert(
|
||||
http::header::HOST,
|
||||
http::HeaderValue::from_str(authority.as_str()).map_err(io::Error::other)?,
|
||||
);
|
||||
}
|
||||
parts.uri = http::Uri::builder()
|
||||
.scheme("http")
|
||||
.authority(target.to_string())
|
||||
.path_and_query(parts.uri.path_and_query().map_or("/", |value| value.as_str()))
|
||||
.build()
|
||||
.map_err(io::Error::other)?;
|
||||
let response = client
|
||||
.request(Request::from_parts(parts, body))
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
if let Some((pause, route)) = observed
|
||||
&& response.status().is_success()
|
||||
&& response.headers().get("grpc-status").is_none_or(|status| status == "0")
|
||||
{
|
||||
pause.forwarded.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(route) = route {
|
||||
pause.forwarded_routes.lock().expect("RPC route observations").insert(route);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
impl Drop for RpcFaultProxy {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_body_util::{Full, StreamBody};
|
||||
use hyper::{Response, body::Frame};
|
||||
use std::convert::Infallible;
|
||||
|
||||
struct Backend {
|
||||
received: Arc<AtomicUsize>,
|
||||
address: SocketAddr,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for Backend {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn backend() -> io::Result<Backend> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let address = listener.local_addr()?;
|
||||
let received = Arc::new(AtomicUsize::new(0));
|
||||
let received_task = received.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut children = JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let Ok((socket, _)) = accepted else { break };
|
||||
let received = received_task.clone();
|
||||
children.spawn(async move {
|
||||
let service = service_fn(move |request: Request<Incoming>| {
|
||||
let received = received.clone();
|
||||
async move {
|
||||
received.fetch_add(1, Ordering::SeqCst);
|
||||
let authority = request.headers().get(http::header::HOST).cloned();
|
||||
let body = request.into_body().collect().await?.to_bytes();
|
||||
let mut trailers = http::HeaderMap::new();
|
||||
trailers.insert("grpc-status", http::HeaderValue::from_static("0"));
|
||||
trailers.insert("x-proof", http::HeaderValue::from_static("unchanged-proof"));
|
||||
let stream = futures::stream::iter([
|
||||
Ok::<_, Infallible>(Frame::data(body)),
|
||||
Ok(Frame::trailers(trailers)),
|
||||
]);
|
||||
let mut response = Response::new(StreamBody::new(stream));
|
||||
response.headers_mut().insert(http::header::TRAILER, http::HeaderValue::from_static("grpc-status, x-proof"));
|
||||
if let Some(authority) = authority { response.headers_mut().insert("x-original-authority", authority); }
|
||||
Ok::<_, hyper::Error>(response)
|
||||
}});
|
||||
let _ = auto::Builder::new(TokioExecutor::new()).serve_connection(TokioIo::new(socket), service).await;
|
||||
});
|
||||
}
|
||||
_ = children.join_next(), if !children.is_empty() => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Backend { received, address, task })
|
||||
}
|
||||
|
||||
async fn call(client: &ProxyClient, address: SocketAddr, method: &str) -> io::Result<hyper::Response<Incoming>> {
|
||||
call_body(client, address, method, Bytes::from_static(b"opaque signed protobuf")).await
|
||||
}
|
||||
|
||||
async fn call_body(
|
||||
client: &ProxyClient,
|
||||
address: SocketAddr,
|
||||
method: &str,
|
||||
bytes: Bytes,
|
||||
) -> io::Result<hyper::Response<Incoming>> {
|
||||
let body: ProxyBody = Full::new(bytes).map_err(|never| match never {}).boxed();
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("http://{address}/node_service.NodeService/{method}"))
|
||||
.header(http::header::TE, "trailers")
|
||||
.body(body)
|
||||
.map_err(io::Error::other)?;
|
||||
client.request(request).await.map_err(io::Error::other)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_protocol_authority_payload_and_grpc_trailers() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let backend = backend().await.expect("backend listener");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback address"), backend.address)
|
||||
.await
|
||||
.expect("proxy");
|
||||
for h2 in [false, true] {
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(h2).build_http();
|
||||
let response = call(&client, proxy.local_addr(), "ReadVersion")
|
||||
.await
|
||||
.expect("transparent request");
|
||||
assert_eq!(response.version(), if h2 { http::Version::HTTP_2 } else { http::Version::HTTP_11 });
|
||||
assert_eq!(response.headers()["x-original-authority"], proxy.local_addr().to_string());
|
||||
let collected = response.into_body().collect().await.expect("complete body and trailers");
|
||||
let trailers = collected.trailers().expect("gRPC trailers must survive both proxy hops");
|
||||
assert_eq!(trailers["grpc-status"], "0");
|
||||
assert_eq!(trailers["x-proof"], "unchanged-proof");
|
||||
assert_eq!(collected.to_bytes(), Bytes::from_static(b"opaque signed protobuf"));
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("transparent proxy deadline");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn isolates_rpc_methods_limits_injection_and_releases_on_drop() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let backend = backend().await.expect("backend listener");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback address"), backend.address)
|
||||
.await
|
||||
.expect("proxy");
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
let pause = proxy.pause(&["WriteMetadata"], 1, None).expect("arm one metadata request");
|
||||
let task_client = client.clone();
|
||||
let address = proxy.local_addr();
|
||||
let blocked = tokio::spawn(async move { call(&task_client, address, "WriteMetadata").await });
|
||||
pause.wait_for_hits(1).await.expect("metadata barrier reached");
|
||||
for method in ["Lock", "WriteMetadata"] {
|
||||
let response = call(&client, address, method)
|
||||
.await
|
||||
.expect("unselected or over-limit request must progress");
|
||||
assert_eq!(
|
||||
response.into_body().collect().await.expect("forwarded body").to_bytes(),
|
||||
Bytes::from_static(b"opaque signed protobuf")
|
||||
);
|
||||
}
|
||||
assert_eq!(pause.hits(), 1);
|
||||
assert!(!blocked.is_finished(), "the selected request must still be held");
|
||||
drop(pause);
|
||||
assert_eq!(
|
||||
blocked
|
||||
.await
|
||||
.expect("blocked request task")
|
||||
.expect("released request")
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("released body")
|
||||
.to_bytes(),
|
||||
Bytes::from_static(b"opaque signed protobuf")
|
||||
);
|
||||
})
|
||||
.await
|
||||
.expect("selective fault deadline");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn released_observer_requires_a_selected_backend_response() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let backend = backend().await.expect("backend listener");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
|
||||
.await
|
||||
.expect("proxy listener");
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
let observer = proxy
|
||||
.pause(&["Lock"], usize::MAX, Some("recovery-bucket"))
|
||||
.expect("RPC observer");
|
||||
let unrelated = call_body(&client, proxy.local_addr(), "Lock", Bytes::from_static(b"other-bucket"))
|
||||
.await
|
||||
.expect("unrelated request");
|
||||
unrelated.into_body().collect().await.expect("unrelated body");
|
||||
assert_eq!(observer.forwarded(), 0, "unrelated traffic cannot prove recovery");
|
||||
let task_client = client.clone();
|
||||
let address = proxy.local_addr();
|
||||
let request =
|
||||
tokio::spawn(
|
||||
async move { call_body(&task_client, address, "Lock", Bytes::from_static(b"recovery-bucket")).await },
|
||||
);
|
||||
observer.wait_for_hits(1).await.expect("selected request arrived");
|
||||
assert_eq!(observer.forwarded(), 0, "an intercepted request has not reached its backend");
|
||||
observer.release();
|
||||
let body = request
|
||||
.await
|
||||
.expect("request task")
|
||||
.expect("forwarded request")
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("forwarded body");
|
||||
assert_eq!(body.trailers().expect("preserved trailers")["x-proof"], "unchanged-proof");
|
||||
assert_eq!(body.to_bytes(), Bytes::from_static(b"recovery-bucket"));
|
||||
assert_eq!(observer.forwarded(), 1);
|
||||
let another = call_body(&client, address, "Lock", Bytes::from_static(b"recovery-bucket"))
|
||||
.await
|
||||
.expect("observer forwards without pausing");
|
||||
another.into_body().collect().await.expect("second body");
|
||||
assert_eq!(observer.forwarded(), 2);
|
||||
drop(observer);
|
||||
let routes = proxy
|
||||
.pause(&["ReadVersion"], usize::MAX, Some("recovery-route"))
|
||||
.expect("disk-route observer");
|
||||
routes.release();
|
||||
for (disk, expected) in [("drive-a", 1), ("drive-a", 1), ("drive-b", 2)] {
|
||||
let request = ReadVersionRequest {
|
||||
disk: disk.to_owned(),
|
||||
path: "recovery-route".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
.encode_to_vec();
|
||||
let mut frame = vec![0];
|
||||
frame.extend_from_slice(&u32::try_from(request.len()).expect("small frame").to_be_bytes());
|
||||
frame.extend_from_slice(&request);
|
||||
call_body(&client, address, "ReadVersion", Bytes::from(frame))
|
||||
.await
|
||||
.expect("routed read")
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("routed response");
|
||||
assert_eq!(routes.forwarded_routes(), expected, "repeated traffic from one disk is only one voter");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("passive observer deadline");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_fault_discards_expired_packets_without_replaying_them() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let backend = backend().await.expect("backend listener");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
|
||||
.await
|
||||
.expect("proxy listener");
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
assert!(proxy.pause(&["Lock"], 1, Some("")).is_err(), "empty scope must not match every body");
|
||||
let pause = proxy
|
||||
.pause(&["Lock"], usize::MAX, Some("specific-bucket"))
|
||||
.expect("bucket-scoped lock fault");
|
||||
let unrelated = call_body(&client, proxy.local_addr(), "Lock", Bytes::from_static(b"other-bucket"))
|
||||
.await
|
||||
.expect("unrelated bucket must progress");
|
||||
let unrelated = unrelated.into_body().collect().await.expect("unrelated response");
|
||||
assert_eq!(unrelated.trailers().expect("unchanged trailers")["x-proof"], "unchanged-proof");
|
||||
assert_eq!(unrelated.to_bytes(), Bytes::from_static(b"other-bucket"));
|
||||
assert_eq!(pause.hits(), 0);
|
||||
let task_client = client.clone();
|
||||
let address = proxy.local_addr();
|
||||
let blocked =
|
||||
tokio::spawn(
|
||||
async move { call_body(&task_client, address, "Lock", Bytes::from_static(b"specific-bucket")).await },
|
||||
);
|
||||
pause.wait_for_hits(1).await.expect("selected bucket reached");
|
||||
assert!(!blocked.is_finished());
|
||||
pause.discard_pending();
|
||||
drop(pause); // Drop must not replace Discard with Forward.
|
||||
assert!(blocked.await.expect("request task").is_err());
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), 1, "discard must never execute at the peer");
|
||||
let recovered = call(&client, address, "Lock").await.expect("future requests recover");
|
||||
recovered.into_body().collect().await.expect("recovered response");
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), 2);
|
||||
})
|
||||
.await
|
||||
.expect("scoped discard deadline");
|
||||
}
|
||||
#[test]
|
||||
fn delete_scope_matches_only_the_upload_root() {
|
||||
let frame = |volume: &str, path: &str| {
|
||||
let message = DeleteRequest {
|
||||
volume: volume.to_owned(),
|
||||
path: path.to_owned(),
|
||||
options: r#"{"recursive":true}"#.to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
let payload = message.encode_to_vec();
|
||||
let length = u32::try_from(payload.len()).expect("small control packet");
|
||||
let mut bytes = vec![0];
|
||||
bytes.extend_from_slice(&length.to_be_bytes());
|
||||
bytes.extend_from_slice(&payload);
|
||||
bytes
|
||||
};
|
||||
let root = b"hash/upload";
|
||||
assert!(scope_matches("Delete", &frame(".rustfs.sys/multipart", "hash/upload"), root).expect("root frame"));
|
||||
assert!(scope_matches("Delete", &frame(".rustfs.sys/multipart", "hash/upload/"), root).expect("directory frame"));
|
||||
for path in ["hash/upload/part.1", "hash/upload/.abort-authority", "hash/upload-other"] {
|
||||
assert!(!scope_matches("Delete", &frame(".rustfs.sys/multipart", path), root).expect("other path"));
|
||||
}
|
||||
assert!(!scope_matches("Delete", &frame(".rustfs.sys/tmp", "hash/upload"), root).expect("other volume"));
|
||||
let non_recursive = DeleteRequest {
|
||||
volume: ".rustfs.sys/multipart".to_owned(),
|
||||
path: "hash/upload".to_owned(),
|
||||
options: r#"{"recursive":false}"#.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
.encode_to_vec();
|
||||
let mut non_recursive_frame = vec![0];
|
||||
non_recursive_frame.extend_from_slice(&u32::try_from(non_recursive.len()).expect("small frame").to_be_bytes());
|
||||
non_recursive_frame.extend_from_slice(&non_recursive);
|
||||
assert!(!scope_matches("Delete", &non_recursive_frame, root).expect("non-recursive frame"));
|
||||
assert!(
|
||||
scope_matches("Delete", &[0, 0, 0, 0, 1], root).is_err(),
|
||||
"truncation must not become a match"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -245,11 +245,11 @@ async fn test_concurrent_object_operations() -> Result<(), Box<dyn Error + Send
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Internal/private endpoints must be rejected as remote tier backends (SSRF)
|
||||
/// Loopback and metadata endpoints must be rejected as remote tier backends (SSRF)
|
||||
/// before RustFS opens a connection to them.
|
||||
///
|
||||
/// This issues a real admin AddTier call (`PUT /rustfs/admin/v3/tier`) for each
|
||||
/// internal/private endpoint and requires the exact validation response. A
|
||||
/// restricted endpoint and requires the exact validation response. A
|
||||
/// loopback listener additionally proves the literal loopback case is rejected
|
||||
/// without an outbound connection; a generic connectivity failure is not
|
||||
/// sufficient evidence of SSRF protection.
|
||||
@@ -267,6 +267,9 @@ async fn test_tiering_url_validation() -> Result<(), Box<dyn Error + Send + Sync
|
||||
("http://localhost:8080", false),
|
||||
("http://169.254.169.254", false), // cloud instance metadata endpoint
|
||||
("http://[::1]:8080", false),
|
||||
("http://[fd00:ec2::254]", false),
|
||||
("http://[fd00:ec2::23]", false),
|
||||
("http://[fd20:ce::254]", false),
|
||||
];
|
||||
|
||||
for (endpoint, checks_connection_attempt) in internal_endpoints {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk};
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
@@ -52,3 +52,8 @@ pub(crate) mod internode_rpc_signature {
|
||||
pub(crate) mod replication_extension {
|
||||
pub(crate) use super::BucketTargetSys;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod upgrade_compatibility {
|
||||
pub(crate) use super::{DiskOption, Endpoint, new_disk};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// Copyright 2026 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::{
|
||||
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
|
||||
init_logging, signed_request,
|
||||
};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use http::{Method, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
async fn set_policy(env: &RustFSTestEnvironment, user: &str, policy: Value) -> TestResult {
|
||||
admin_add_canned_policy_via(
|
||||
AdminTransport::Signed,
|
||||
&env.url,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
user,
|
||||
&policy.to_string(),
|
||||
)
|
||||
.await?;
|
||||
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, user, user).await
|
||||
}
|
||||
|
||||
async fn catalog_request(
|
||||
env: &RustFSTestEnvironment,
|
||||
credentials: &(String, String),
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
expected: StatusCode,
|
||||
) -> TestResult<Value> {
|
||||
let response = signed_request(
|
||||
method.clone(),
|
||||
&format!("{}{path}", env.url),
|
||||
&credentials.0,
|
||||
&credentials.1,
|
||||
body.map(|value| serde_json::to_vec(&value)).transpose()?,
|
||||
Some("application/json"),
|
||||
)
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
assert_eq!(status, expected, "{method} {path}: {text}");
|
||||
if text.is_empty() || !status.is_success() {
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_object(
|
||||
env: &RustFSTestEnvironment,
|
||||
credentials: &(String, String),
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
expected: StatusCode,
|
||||
) -> TestResult {
|
||||
let response = signed_request(
|
||||
Method::GET,
|
||||
&format!("{}/{bucket}/{key}", env.url),
|
||||
&credentials.0,
|
||||
&credentials.1,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), expected, "GET {bucket}/{key}");
|
||||
if expected == StatusCode::OK {
|
||||
assert_eq!(response.bytes().await?.as_ref(), b"original object");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_table(name: &str) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"schema": {
|
||||
"type": "struct",
|
||||
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
|
||||
},
|
||||
"stage-create": false
|
||||
})
|
||||
}
|
||||
|
||||
fn staged_commit(metadata: &Value, location: &str) -> Value {
|
||||
json!({
|
||||
"requirements": [{"type": "assert-create"}],
|
||||
"updates": [
|
||||
{"action": "assign-uuid", "uuid": metadata["table-uuid"]},
|
||||
{"action": "upgrade-format-version", "format-version": metadata["format-version"]},
|
||||
{"action": "add-schema", "schema": metadata["schemas"][0]},
|
||||
{"action": "set-current-schema", "schema-id": -1},
|
||||
{"action": "add-spec", "spec": metadata["partition-specs"][0]},
|
||||
{"action": "set-default-spec", "spec-id": -1},
|
||||
{"action": "add-sort-order", "sort-order": metadata["sort-orders"][0]},
|
||||
{"action": "set-default-sort-order", "sort-order-id": -1},
|
||||
{"action": "set-location", "location": location}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async fn location_claim_preserves_other_users_objects_after_drop_and_restart(backing: &str) -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let server_env = [("RUSTFS_TABLE_CATALOG_BACKING", backing)];
|
||||
env.start_rustfs_server_without_cleanup_with_env(&[("RUSTFS_TABLE_CATALOG_BACKING", "object")])
|
||||
.await?;
|
||||
env.wait_for_server_ready().await?;
|
||||
let root = (env.access_key.clone(), env.secret_key.clone());
|
||||
let creator = (format!("creator-{}", Uuid::new_v4().simple()), Uuid::new_v4().simple().to_string());
|
||||
let reader = (format!("reader-{}", Uuid::new_v4().simple()), Uuid::new_v4().simple().to_string());
|
||||
let bucket = "table-location-authorization";
|
||||
let root_s3 = env.create_s3_client();
|
||||
root_s3.create_bucket().bucket(bucket).send().await?;
|
||||
for key in ["shared/known.txt", "shared2/known.txt", "authorized/known.txt"] {
|
||||
root_s3
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"original object"))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let catalog = format!("/iceberg/v1/{bucket}/namespaces/lab/tables");
|
||||
catalog_request(&env, &root, Method::PUT, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
|
||||
catalog_request(
|
||||
&env,
|
||||
&root,
|
||||
Method::POST,
|
||||
&format!("/iceberg/v1/{bucket}/namespaces"),
|
||||
Some(json!({"namespace": ["lab"]})),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await?;
|
||||
if backing == "durable-strong" {
|
||||
catalog_request(
|
||||
&env,
|
||||
&root,
|
||||
Method::POST,
|
||||
&format!("/iceberg/v1/{bucket}/catalog/warehouse-index/backfill"),
|
||||
None,
|
||||
StatusCode::NO_CONTENT,
|
||||
)
|
||||
.await?;
|
||||
let preflight = catalog_request(
|
||||
&env,
|
||||
&root,
|
||||
Method::GET,
|
||||
&format!("/iceberg/v1/{bucket}/catalog/migration"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(preflight["blockers"], json!([]), "{preflight}");
|
||||
let migration = catalog_request(
|
||||
&env,
|
||||
&root,
|
||||
Method::POST,
|
||||
&format!("/iceberg/v1/{bucket}/catalog/migration"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(migration["ready_to_enable_durable_strong"], json!(true), "{migration}");
|
||||
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||
env.wait_for_server_ready().await?;
|
||||
}
|
||||
admin_create_user(&env, &creator.0, &creator.1).await?;
|
||||
admin_create_user(&env, &reader.0, &reader.1).await?;
|
||||
let create_statement = json!({
|
||||
"Effect": "Allow",
|
||||
"Action": "admin:CreateTable",
|
||||
"Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab")
|
||||
});
|
||||
set_policy(
|
||||
&env,
|
||||
&creator.0,
|
||||
json!({"Version": "2012-10-17", "Statement": [create_statement.clone()]}),
|
||||
)
|
||||
.await?;
|
||||
set_policy(
|
||||
&env,
|
||||
&reader.0,
|
||||
json!({"Version": "2012-10-17", "Statement": [{
|
||||
"Effect": "Allow", "Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{bucket}/*")
|
||||
}]}),
|
||||
)
|
||||
.await?;
|
||||
for key in ["shared/known.txt", "shared2/known.txt"] {
|
||||
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
|
||||
get_object(&env, &creator, bucket, key, StatusCode::FORBIDDEN).await?;
|
||||
}
|
||||
for (method, path) in [
|
||||
(Method::PUT, format!("/{bucket}/shared/new.txt")),
|
||||
(Method::GET, format!("/{bucket}?list-type=2")),
|
||||
] {
|
||||
let response = signed_request(method, &format!("{}{path}", env.url), &creator.0, &creator.1, None, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
// Both direct and staged requests must reject a caller-selected foreign prefix.
|
||||
for staged in [false, true] {
|
||||
for location in [format!("s3://{bucket}/shared"), format!("s3://{bucket}/shared/")] {
|
||||
let mut request = create_table("claim");
|
||||
request["location"] = json!(location);
|
||||
request["stage-create"] = json!(staged);
|
||||
catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::FORBIDDEN).await?;
|
||||
}
|
||||
}
|
||||
catalog_request(&env, &root, Method::GET, &format!("{catalog}/claim"), None, StatusCode::NOT_FOUND).await?;
|
||||
|
||||
// Server-assigned locations still need only CreateTable, and retain protection after drop.
|
||||
let created = catalog_request(&env, &creator, Method::POST, &catalog, Some(create_table("managed")), StatusCode::OK).await?;
|
||||
let location = created["metadata"]["location"].as_str().ok_or("missing table location")?;
|
||||
let managed_key = format!(
|
||||
"{}/known.txt",
|
||||
location
|
||||
.strip_prefix(&format!("s3://{bucket}/"))
|
||||
.ok_or("table location has wrong bucket")?
|
||||
);
|
||||
root_s3
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&managed_key)
|
||||
.body(ByteStream::from_static(b"original object"))
|
||||
.send()
|
||||
.await?;
|
||||
get_object(&env, &reader, bucket, &managed_key, StatusCode::FORBIDDEN).await?;
|
||||
catalog_request(&env, &root, Method::DELETE, &format!("{catalog}/managed"), None, StatusCode::NO_CONTENT).await?;
|
||||
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||
env.wait_for_server_ready().await?;
|
||||
for table in ["claim", "managed"] {
|
||||
catalog_request(&env, &root, Method::GET, &format!("{catalog}/{table}"), None, StatusCode::NOT_FOUND).await?;
|
||||
}
|
||||
for key in ["shared/known.txt", "shared2/known.txt", "authorized/known.txt"] {
|
||||
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
|
||||
}
|
||||
get_object(&env, &reader, bucket, &managed_key, StatusCode::FORBIDDEN).await?;
|
||||
get_object(&env, &root, bucket, &managed_key, StatusCode::OK).await?;
|
||||
|
||||
// Explicit adoption is scoped to a physical prefix and does not grant S3 access.
|
||||
set_policy(
|
||||
&env,
|
||||
&creator.0,
|
||||
json!({"Version": "2012-10-17", "Statement": [
|
||||
create_statement.clone(),
|
||||
{"Effect": "Allow", "Action": "admin:ClaimTableLocation", "Resource": format!("arn:aws:s3:::{bucket}/authorized")}
|
||||
]}),
|
||||
)
|
||||
.await?;
|
||||
let mut request = create_table("authorized");
|
||||
request["location"] = json!(format!("s3://{bucket}/shared"));
|
||||
catalog_request(&env, &creator, Method::POST, &catalog, Some(request.clone()), StatusCode::FORBIDDEN).await?;
|
||||
request["location"] = json!(format!("s3://{bucket}/authorized"));
|
||||
let adopted = catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::OK).await?;
|
||||
get_object(&env, &creator, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
|
||||
get_object(&env, &reader, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
|
||||
// Existing updates retain their permissions; relocation and re-registration
|
||||
// require a prefix grant even when the caller can access every S3 object.
|
||||
set_policy(
|
||||
&env,
|
||||
&creator.0,
|
||||
json!({"Version": "2012-10-17", "Statement": [
|
||||
create_statement,
|
||||
{"Effect": "Allow", "Action": "admin:RegisterTable", "Resource": [
|
||||
format!("arn:aws:s3:::{bucket}/namespaces/lab"),
|
||||
format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/*")
|
||||
]},
|
||||
{"Effect": "Allow", "Action": "admin:CommitTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/*")},
|
||||
{"Effect": "Allow", "Action": "admin:SetTableMetadataLocation", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/authorized")},
|
||||
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": format!("arn:aws:s3:::{bucket}/*")}
|
||||
]}),
|
||||
)
|
||||
.await?;
|
||||
let requirements = json!([{"type": "assert-table-uuid", "uuid": adopted["metadata"]["table-uuid"]}]);
|
||||
let committed = catalog_request(
|
||||
&env,
|
||||
&creator,
|
||||
Method::POST,
|
||||
&format!("{catalog}/authorized"),
|
||||
Some(json!({
|
||||
"requirements": requirements,
|
||||
"updates": [{"action": "set-properties", "updates": {"test.location-authorization": "retained"}}]
|
||||
})),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await?;
|
||||
catalog_request(
|
||||
&env,
|
||||
&creator,
|
||||
Method::POST,
|
||||
&format!("{catalog}/authorized"),
|
||||
Some(json!({
|
||||
"requirements": requirements,
|
||||
"updates": [{"action": "set-location", "location": format!("s3://{bucket}/shared")}]
|
||||
})),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await?;
|
||||
let current = catalog_request(&env, &root, Method::GET, &format!("{catalog}/authorized"), None, StatusCode::OK).await?;
|
||||
assert_eq!(current["metadata"]["location"], json!(format!("s3://{bucket}/authorized")));
|
||||
assert_eq!(current["metadata-location"], committed["metadata-location"]);
|
||||
catalog_request(
|
||||
&env,
|
||||
&root,
|
||||
Method::DELETE,
|
||||
&format!("{catalog}/authorized"),
|
||||
None,
|
||||
StatusCode::NO_CONTENT,
|
||||
)
|
||||
.await?;
|
||||
catalog_request(
|
||||
&env,
|
||||
&creator,
|
||||
Method::POST,
|
||||
&format!("/iceberg/v1/{bucket}/namespaces/lab/register"),
|
||||
Some(json!({"name": "authorized", "metadata-location": committed["metadata-location"]})),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await?;
|
||||
catalog_request(
|
||||
&env,
|
||||
&creator,
|
||||
Method::POST,
|
||||
&format!("{catalog}/authorized/catalog/import"),
|
||||
Some(json!({"metadata-location": committed["metadata-location"]})),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await?;
|
||||
if backing == "object" {
|
||||
catalog_request(
|
||||
&env,
|
||||
&creator,
|
||||
Method::POST,
|
||||
&format!("{catalog}/authorized/catalog/external/sync"),
|
||||
Some(json!({
|
||||
"catalog": "polaris",
|
||||
"external-namespace": "lab",
|
||||
"external-table": "authorized",
|
||||
"metadata-location": committed["metadata-location"]
|
||||
})),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Staged metadata is client-controlled and must be authorized at publication.
|
||||
let mut request = create_table("staged");
|
||||
request["stage-create"] = json!(true);
|
||||
let staged = catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::OK).await?;
|
||||
catalog_request(
|
||||
&env,
|
||||
&creator,
|
||||
Method::POST,
|
||||
&format!("{catalog}/staged"),
|
||||
Some(staged_commit(&staged["metadata"], &format!("s3://{bucket}/shared"))),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await?;
|
||||
catalog_request(&env, &root, Method::GET, &format!("{catalog}/staged"), None, StatusCode::NOT_FOUND).await?;
|
||||
set_policy(
|
||||
&env,
|
||||
&creator.0,
|
||||
json!({"Version": "2012-10-17", "Statement": [
|
||||
{"Effect": "Allow", "Action": "admin:CreateTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab")},
|
||||
{"Effect": "Allow", "Action": "admin:CommitTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/staged")},
|
||||
{"Effect": "Allow", "Action": "admin:ClaimTableLocation", "Resource": format!("arn:aws:s3:::{bucket}/stage-owned")},
|
||||
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": format!("arn:aws:s3:::{bucket}/*")}
|
||||
]}),
|
||||
)
|
||||
.await?;
|
||||
catalog_request(
|
||||
&env,
|
||||
&creator,
|
||||
Method::POST,
|
||||
&format!("{catalog}/staged"),
|
||||
Some(staged_commit(&staged["metadata"], &format!("s3://{bucket}/stage-owned"))),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await?;
|
||||
catalog_request(&env, &root, Method::DELETE, &format!("{catalog}/staged"), None, StatusCode::NO_CONTENT).await?;
|
||||
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||
env.wait_for_server_ready().await?;
|
||||
for table in ["authorized", "staged"] {
|
||||
catalog_request(&env, &root, Method::GET, &format!("{catalog}/{table}"), None, StatusCode::NOT_FOUND).await?;
|
||||
}
|
||||
get_object(&env, &reader, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
|
||||
get_object(&env, &root, bucket, "authorized/known.txt", StatusCode::OK).await?;
|
||||
for key in ["shared/known.txt", "shared2/known.txt"] {
|
||||
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_catalog_location_claim_preserves_objects_after_drop_and_restart() -> TestResult {
|
||||
location_claim_preserves_other_users_objects_after_drop_and_restart("object").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_strong_catalog_location_claim_preserves_objects_after_drop_and_restart() -> TestResult {
|
||||
location_claim_preserves_other_users_objects_after_drop_and_restart("durable-strong").await
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use crate::common::{
|
||||
RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging, replication_fast_env, rustfs_binary_path,
|
||||
signed_request,
|
||||
signal_process, signed_request,
|
||||
};
|
||||
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation as FakeTargetOperation};
|
||||
use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject, fake_source_client};
|
||||
@@ -1372,6 +1372,353 @@ async fn rc5_rollback_requires_restoring_odm_configuration() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Rc5CheckpointCapture {
|
||||
disk: PathBuf,
|
||||
path: PathBuf,
|
||||
task_id: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn capture_rc5_checkpoint(drives: &[PathBuf], writer_pid: u32) -> Result<Rc5CheckpointCapture, BoxError> {
|
||||
let has_signed_progress = |value: &Value| {
|
||||
value["schema_version"].as_u64() == Some(6)
|
||||
&& value["successful_objects"].as_u64().is_some_and(|count| count > 0)
|
||||
&& value["processed_objects"]
|
||||
.as_array()
|
||||
.is_some_and(|objects| !objects.is_empty())
|
||||
&& value["integrity_digest"].as_str().is_some_and(|digest| !digest.is_empty())
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(60), async {
|
||||
loop {
|
||||
for drive in drives {
|
||||
let mut entries = match tokio::fs::read_dir(drive.join(".rustfs.sys/buckets")).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if !entry.file_name().to_string_lossy().ends_with("_ahm_checkpoint.json") {
|
||||
continue;
|
||||
}
|
||||
let metadata = entry.metadata().await?;
|
||||
if !metadata.is_file() || metadata.len() > 1024 * 1024 {
|
||||
return Err("the old checkpoint is not a bounded regular file".into());
|
||||
}
|
||||
let bytes = match tokio::fs::read(entry.path()).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if !has_signed_progress(&value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Freeze only this fixture's writer before copying its actual
|
||||
// bytes. The environment's normal stop path kills a stopped child.
|
||||
signal_process(writer_pid, "STOP")?;
|
||||
let bytes = tokio::fs::read(entry.path()).await?;
|
||||
let value: Value = serde_json::from_slice(&bytes)?;
|
||||
if !has_signed_progress(&value) {
|
||||
return Err("the stopped rc.5 writer did not retain signed checkpoint progress".into());
|
||||
}
|
||||
let task_id = value["task_id"].as_str().ok_or("the old checkpoint has no task id")?;
|
||||
uuid::Uuid::parse_str(task_id)?;
|
||||
if entry.file_name() != std::ffi::OsStr::new(&format!("{task_id}_ahm_checkpoint.json")) {
|
||||
return Err("the old checkpoint task id does not match its filename".into());
|
||||
}
|
||||
return Ok(Rc5CheckpointCapture {
|
||||
disk: drive.clone(),
|
||||
path: entry.path(),
|
||||
task_id: task_id.to_owned(),
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "rc.5 did not produce a signed checkpoint with progress within 60 seconds")?
|
||||
}
|
||||
|
||||
fn rc5_checkpoint_object(index: usize) -> (String, Vec<u8>) {
|
||||
(
|
||||
format!("object-{index:05}.bin"),
|
||||
format!("rc.5 checkpoint upgrade object {index:05}\n")
|
||||
.repeat(256)
|
||||
.into_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
fn checkpoint_binary_sha256(path: &Path) -> Result<String, BoxError> {
|
||||
use sha2::Digest;
|
||||
use std::io::Read;
|
||||
|
||||
let mut input = std::fs::File::open(path)?;
|
||||
let mut digest = sha2::Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let count = input.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
Ok(hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires the pinned 1.0.0-rc.5 release binary"]
|
||||
async fn rc5_checkpoint_upgrade_rewinds_signed_progress_without_losing_objects() -> TestResult {
|
||||
use crate::storage_api::upgrade_compatibility::{DiskOption, Endpoint, new_disk};
|
||||
use futures::{StreamExt, TryStreamExt, stream};
|
||||
use rustfs_heal::heal::CheckpointManager;
|
||||
|
||||
const BUCKET: &str = "upgrade-rc5-checkpoint";
|
||||
const OBJECTS: usize = 1600;
|
||||
|
||||
init_logging();
|
||||
let previous_binary = source_binary()?;
|
||||
assert_eq!(binary_source_revision(&previous_binary).await?, RC5_COMMIT);
|
||||
let current_binary = rustfs_binary_path();
|
||||
let evidence_dir = std::env::var_os(G09_EVIDENCE_DIR_ENV)
|
||||
.map(|root| PathBuf::from(root).join(format!("rc5-checkpoint-upgrade-{}", uuid::Uuid::new_v4())));
|
||||
if let Some(directory) = &evidence_dir {
|
||||
std::fs::create_dir_all(directory)?;
|
||||
}
|
||||
|
||||
// Declare the server after its extra drives so error unwinding stops it
|
||||
// before the fixture directories are removed.
|
||||
let fixture = tempfile::TempDir::new()?;
|
||||
let extra_drives = (0..3)
|
||||
.map(|index| {
|
||||
let path = fixture.path().join(format!("drive-{index}"));
|
||||
std::fs::create_dir(&path)?;
|
||||
Ok(path.to_str().ok_or("fixture drive path is not UTF-8")?.to_owned())
|
||||
})
|
||||
.collect::<Result<Vec<_>, BoxError>>()?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let log_path = evidence_dir.as_deref().unwrap_or(fixture.path()).join("server.log");
|
||||
env.capture_log_path = Some(log_path.to_str().ok_or("fixture log path is not UTF-8")?.to_owned());
|
||||
let server_env = [
|
||||
("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true"),
|
||||
("RUSTFS_DURABILITY_MODE", "strict"),
|
||||
("RUSTFS_NEW_BUCKET_DURABILITY_MODE", "strict"),
|
||||
("RUST_LOG", "warn,rustfs::heal=debug"),
|
||||
];
|
||||
env.start_rustfs_server_from_binary(&previous_binary, extra_drives.iter().map(String::as_str).collect(), &server_env)
|
||||
.await?;
|
||||
env.create_test_bucket(BUCKET).await?;
|
||||
let (status, durability) = admin_request(
|
||||
&env.url,
|
||||
Method::GET,
|
||||
&format!("/rustfs/admin/v3/bucket-durability/{BUCKET}"),
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "old bucket durability: {durability}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&durability)?["mode"], "strict");
|
||||
|
||||
let old_client = env.create_s3_client();
|
||||
let manifest = stream::iter(0..OBJECTS)
|
||||
.map(|index| {
|
||||
let client = old_client.clone();
|
||||
async move {
|
||||
let (key, body) = rc5_checkpoint_object(index);
|
||||
let entry = serde_json::json!({
|
||||
"bucket": BUCKET,
|
||||
"key": key,
|
||||
"length": body.len(),
|
||||
"sha256": rustfs_utils::hex_sha256(&body, str::to_owned),
|
||||
});
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.body(ByteStream::from(body))
|
||||
.send()
|
||||
.await?;
|
||||
Ok::<_, BoxError>(entry)
|
||||
}
|
||||
})
|
||||
.buffered(4)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
let drives = extra_drives
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.chain(std::iter::once(PathBuf::from(&env.temp_dir)))
|
||||
.collect::<Vec<_>>();
|
||||
let writer_pid = env.process.as_ref().ok_or("rc.5 writer is not running")?.id();
|
||||
let start_heal = async {
|
||||
let (status, body) = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
admin_request(
|
||||
&env.url,
|
||||
Method::POST,
|
||||
"/rustfs/admin/v3/heal/",
|
||||
Some(
|
||||
serde_json::json!({
|
||||
"recursive": true, "dryRun": false, "remove": false, "recreate": false,
|
||||
"scanMode": 2, "updateParity": false, "nolock": false, "pool": 0, "set": 0,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
if status != StatusCode::OK {
|
||||
return Err(format!("rc.5 root heal was not accepted: {status} {body}").into());
|
||||
}
|
||||
Ok::<_, BoxError>(body)
|
||||
};
|
||||
let (capture, heal_start) = tokio::try_join!(capture_rc5_checkpoint(&drives, writer_pid), start_heal)?;
|
||||
env.stop_server();
|
||||
assert_eq!(
|
||||
std::fs::read(&capture.path)?,
|
||||
capture.bytes,
|
||||
"stopping the old writer must preserve captured bytes"
|
||||
);
|
||||
let checkpoint_dir = capture.disk.join(".rustfs.sys/buckets");
|
||||
let resume_path = checkpoint_dir.join(format!("{}_ahm_resume_state.json", capture.task_id));
|
||||
let resume_bytes = std::fs::read(&resume_path)?;
|
||||
let old_checkpoint: Value = serde_json::from_slice(&capture.bytes)?;
|
||||
if let Some(directory) = &evidence_dir {
|
||||
std::fs::write(directory.join("checkpoint.rc5.json"), &capture.bytes)?;
|
||||
std::fs::write(directory.join("resume.rc5.json"), &resume_bytes)?;
|
||||
std::fs::write(directory.join("manifest.json"), serde_json::to_vec_pretty(&manifest)?)?;
|
||||
std::fs::write(
|
||||
directory.join("source.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"kind": "old-release-checkpoint-reader-upgrade",
|
||||
"scope": "schema-6 reader authentication, safe rewind and object reads; not full G09 qualification",
|
||||
"writer_revision": RC5_COMMIT,
|
||||
"writer_binary_sha256": checkpoint_binary_sha256(&previous_binary)?,
|
||||
"candidate_revision": current_source_revision()?,
|
||||
"candidate_binary_sha256": checkpoint_binary_sha256(¤t_binary)?,
|
||||
"layout": "one process and four logical drives; not physical fault qualification",
|
||||
"task_id": capture.task_id,
|
||||
"checkpoint_sha256": rustfs_utils::hex_sha256(&capture.bytes, str::to_owned),
|
||||
"resume_sha256": rustfs_utils::hex_sha256(&resume_bytes, str::to_owned),
|
||||
"acknowledged_objects": manifest.len(),
|
||||
"bucket_durability": serde_json::from_str::<Value>(&durability)?,
|
||||
"heal_start": serde_json::from_str::<Value>(&heal_start)?,
|
||||
}))?,
|
||||
)?;
|
||||
}
|
||||
|
||||
let blocked_path = checkpoint_dir.join(format!("{}_ahm_checkpoint.blocked", capture.task_id));
|
||||
assert!(!blocked_path.try_exists()?, "the original checkpoint must not already be blocked");
|
||||
let disk_options = DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
};
|
||||
// Reopen with the same member order passed to the old writer.
|
||||
let disk_index = drives
|
||||
.iter()
|
||||
.position(|drive| drive == &capture.disk)
|
||||
.ok_or("captured disk is not a fixture member")?;
|
||||
let mut endpoint = Endpoint::try_from(capture.disk.to_str().ok_or("captured drive path is not UTF-8")?)?;
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
let disk = new_disk(&endpoint, &disk_options).await?;
|
||||
let manager = CheckpointManager::load_from_disk(disk.clone(), &capture.task_id).await?;
|
||||
let rewound = manager.get_checkpoint().await;
|
||||
assert_eq!(rewound.schema_version, 7);
|
||||
assert_eq!(rewound.task_id, capture.task_id);
|
||||
assert_eq!((rewound.current_bucket_index, rewound.current_object_index), (0, 0));
|
||||
assert!(rewound.processed_objects.is_empty() && rewound.failed_objects.is_empty() && rewound.skipped_objects.is_empty());
|
||||
assert_eq!(rewound.successful_objects, 0);
|
||||
assert_eq!(rewound.failed_object_count, 0);
|
||||
assert_eq!(rewound.skipped_object_count, 0);
|
||||
assert_eq!(rewound.processed_bytes, 0);
|
||||
assert_eq!((rewound.total_objects, rewound.total_bytes), (0, 0));
|
||||
assert!(rewound.baseline_generation.is_none() && !rewound.baseline_known && !rewound.counter_unknown);
|
||||
assert!(!blocked_path.try_exists()?, "an authentic old checkpoint must not be quarantined");
|
||||
assert_eq!(
|
||||
std::fs::read(&capture.path)?,
|
||||
capture.bytes,
|
||||
"the reader must not rewrite the old checkpoint"
|
||||
);
|
||||
assert_eq!(std::fs::read(&resume_path)?, resume_bytes, "the reader must not take over the old task");
|
||||
drop(manager);
|
||||
drop(disk);
|
||||
|
||||
// A separate copy with one digest byte changed proves that successful
|
||||
// rewind above did not bypass authentication of the original wire bytes.
|
||||
let corrupt_root = fixture.path().join("corrupt-reader-control");
|
||||
let corrupt_metadata = corrupt_root.join(".rustfs.sys/buckets");
|
||||
std::fs::create_dir_all(&corrupt_metadata)?;
|
||||
let mut corrupted = capture.bytes.clone();
|
||||
let digest = old_checkpoint["integrity_digest"]
|
||||
.as_str()
|
||||
.ok_or("old checkpoint digest is not a string")?;
|
||||
let offset = corrupted
|
||||
.windows(digest.len())
|
||||
.position(|bytes| bytes == digest.as_bytes())
|
||||
.ok_or("the digest is absent from the captured bytes")?;
|
||||
corrupted[offset] = if corrupted[offset] == b'A' { b'B' } else { b'A' };
|
||||
let corrupt_path = corrupt_metadata.join(format!("{}_ahm_checkpoint.json", capture.task_id));
|
||||
std::fs::write(&corrupt_path, &corrupted)?;
|
||||
let endpoint = Endpoint::try_from(corrupt_root.to_str().ok_or("control disk path is not UTF-8")?)?;
|
||||
let corrupt_disk = new_disk(&endpoint, &disk_options).await?;
|
||||
let Err(error) = CheckpointManager::load_from_disk(corrupt_disk.clone(), &capture.task_id).await else {
|
||||
return Err("the current checkpoint reader accepted a corrupted signature".into());
|
||||
};
|
||||
assert!(matches!(error, rustfs_heal::Error::InvalidCheckpoint(ref reason) if reason.contains("digest")));
|
||||
let corrupt_blocked = corrupt_metadata.join(format!("{}_ahm_checkpoint.blocked", capture.task_id));
|
||||
assert!(corrupt_blocked.try_exists()?, "the corrupted copy must retain a blocked marker");
|
||||
assert_eq!(std::fs::read(&corrupt_path)?, corrupted, "quarantine must preserve the invalid bytes");
|
||||
if let Some(directory) = &evidence_dir {
|
||||
std::fs::write(directory.join("checkpoint.corrupted.json"), &corrupted)?;
|
||||
std::fs::copy(&corrupt_blocked, directory.join("checkpoint.corrupted.blocked"))?;
|
||||
}
|
||||
drop(corrupt_disk);
|
||||
|
||||
env.restart_server_preserving_data(extra_drives.iter().map(String::as_str).collect(), &server_env)
|
||||
.await?;
|
||||
let current_client = env.create_s3_client();
|
||||
stream::iter(0..OBJECTS)
|
||||
.map(|index| {
|
||||
let client = current_client.clone();
|
||||
async move {
|
||||
let (key, expected) = rc5_checkpoint_object(index);
|
||||
let (_, actual) = read_object(&client, BUCKET, &key, None).await?;
|
||||
assert_eq!(actual, expected, "old object {key} changed during the upgrade");
|
||||
Ok::<_, BoxError>(())
|
||||
}
|
||||
})
|
||||
.buffer_unordered(4)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
env.stop_server();
|
||||
if let Some(directory) = &evidence_dir {
|
||||
std::fs::write(
|
||||
directory.join("result.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"status": "passed",
|
||||
"scope": "old-release checkpoint reader and object-data upgrade only",
|
||||
"authenticated_original_bytes": true,
|
||||
"original_checkpoint_unchanged_after_read": true,
|
||||
"old_successful_objects": old_checkpoint["successful_objects"],
|
||||
"rewound_schema": rewound.schema_version,
|
||||
"rewound_successful_objects": rewound.successful_objects,
|
||||
"corrupt_digest_rejected_and_retained": true,
|
||||
"verified_objects": OBJECTS,
|
||||
}))?,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||
async fn direct_upgrade_from_previous_release_preserves_object_contracts() -> TestResult {
|
||||
|
||||
@@ -278,10 +278,6 @@ harness = false
|
||||
name = "comparison_benchmark"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "rename_data_meta_benchmark"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "single_block_non_inline_benchmark"
|
||||
harness = false
|
||||
@@ -290,5 +286,9 @@ harness = false
|
||||
name = "multipart_read_parts_benchmark"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "local_walk_dir_benchmark"
|
||||
harness = false
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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 criterion::{Criterion, Throughput, criterion_group, criterion_main};
|
||||
use rustfs_filemeta::{MetaCacheEntry, MetacacheReader, test_data::create_real_xlmeta};
|
||||
use std::hint::black_box;
|
||||
|
||||
mod storage_api;
|
||||
use storage_api::local_walk_dir::{Disk, DiskAPI, DiskOption, Endpoint, WalkDirOptions, new_disk};
|
||||
|
||||
const OBJECTS: usize = 1000;
|
||||
|
||||
async fn walk_page(disk: &Disk, recursive: bool) -> Vec<MetaCacheEntry> {
|
||||
let opts = WalkDirOptions {
|
||||
bucket: "bench-bucket".to_string(),
|
||||
base_dir: "prefix".to_string(),
|
||||
recursive,
|
||||
limit: i32::try_from(OBJECTS).expect("page limit fits"),
|
||||
..Default::default()
|
||||
};
|
||||
let (reader, mut writer) = tokio::io::duplex(4096);
|
||||
let produce = async move { disk.walk_dir(opts, &mut writer).await };
|
||||
let consume = async { MetacacheReader::new(reader).read_all().await.expect("decode listing stream") };
|
||||
let (result, entries) = tokio::join!(produce, consume);
|
||||
result.expect("production walk_dir must succeed");
|
||||
entries
|
||||
}
|
||||
|
||||
fn bench_local_walk_dir(c: &mut Criterion) {
|
||||
let root = tempfile::tempdir().expect("create benchmark disk");
|
||||
let metadata = create_real_xlmeta().expect("create object metadata");
|
||||
let decoded = rustfs_filemeta::FileMeta::load(&metadata).expect("decode fixture");
|
||||
let data_dirs = decoded.get_data_dirs().expect("fixture data directories");
|
||||
for i in 0..OBJECTS {
|
||||
let object_dir = root.path().join("bench-bucket/prefix").join(format!("object-{i:04}"));
|
||||
std::fs::create_dir_all(&object_dir).expect("create object directory");
|
||||
for data_dir in data_dirs.iter().flatten() {
|
||||
std::fs::create_dir_all(object_dir.join(data_dir.to_string())).expect("create data directory");
|
||||
}
|
||||
std::fs::write(object_dir.join("xl.meta"), &metadata).expect("write object metadata");
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(4)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build benchmark runtime");
|
||||
let endpoint = Endpoint::try_from(root.path().to_str().expect("UTF-8 disk path")).expect("disk endpoint");
|
||||
let disk = runtime
|
||||
.block_on(new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
))
|
||||
.expect("open local disk");
|
||||
let mut group = c.benchmark_group("local_walk_dir");
|
||||
group.throughput(Throughput::Elements(u64::try_from(OBJECTS).expect("object count fits")));
|
||||
for (name, recursive) in [("recursive_1000_objects", true), ("non_recursive_1000_objects", false)] {
|
||||
let entries = runtime.block_on(walk_page(&disk, recursive));
|
||||
let objects: Vec<_> = entries.iter().filter(|entry| !entry.metadata.is_empty()).collect();
|
||||
assert_eq!(objects.len(), OBJECTS);
|
||||
for (i, entry) in objects.iter().enumerate() {
|
||||
assert_eq!(entry.name, format!("prefix/object-{i:04}"));
|
||||
}
|
||||
group.bench_function(name, |b| {
|
||||
b.iter(|| black_box(runtime.block_on(walk_page(&disk, recursive))));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_local_walk_dir);
|
||||
criterion_main!(benches);
|
||||
@@ -33,3 +33,7 @@ pub(crate) mod single_block_non_inline {
|
||||
pub(crate) mod multipart {
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskAPI, DiskOption, Endpoint, new_disk};
|
||||
}
|
||||
|
||||
pub(crate) mod local_walk_dir {
|
||||
pub(crate) use rustfs_ecstore::api::disk::{Disk, DiskAPI, DiskOption, Endpoint, WalkDirOptions, new_disk};
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub mod diagnostic {
|
||||
pub use crate::cluster::rpc::runtime_sources::{
|
||||
current_read_version_entry_source, observe_read_version_context_boundary, with_read_version_entry,
|
||||
with_read_version_spawn_context,
|
||||
current_read_version_entry_source, observe_read_version_context_boundary, with_captured_read_version_context,
|
||||
with_read_version_entry, with_read_version_spawn_context,
|
||||
};
|
||||
pub use rustfs_io_metrics::internode_metrics::{ReadVersionContextBoundary, ReadVersionEntrySource};
|
||||
}
|
||||
@@ -341,8 +341,9 @@ pub mod cache {
|
||||
|
||||
pub mod capacity {
|
||||
pub use crate::core::pools::{
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
||||
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, path2_bucket_object,
|
||||
path2_bucket_object_with_base_path,
|
||||
};
|
||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||
}
|
||||
@@ -490,6 +491,8 @@ pub mod global {
|
||||
}
|
||||
|
||||
pub mod runtime {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::runtime::global::set_object_store_resolver;
|
||||
pub use crate::runtime::instance::{InstanceContext, bootstrap_ctx};
|
||||
pub use crate::runtime::sources::{
|
||||
boot_time, bucket_monitor, deployment_id, endpoint_pools, expiry_state_handle, first_cluster_node_is_local,
|
||||
@@ -586,12 +589,14 @@ pub mod rpc {
|
||||
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, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
|
||||
tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation,
|
||||
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
|
||||
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
|
||||
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
|
||||
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
|
||||
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
|
||||
verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
|
||||
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability,
|
||||
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
||||
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2012,7 +2012,7 @@ impl TargetClient {
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(object)
|
||||
.set_version_id(version_id)
|
||||
.set_version_id(resolve_read_api_version_id(version_id))
|
||||
.customize()
|
||||
.map_request(move |mut req| {
|
||||
for (k, v) in headers.clone().into_iter() {
|
||||
@@ -4170,6 +4170,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The worker's convergence HEAD addresses a source null version (nil
|
||||
/// UUID) as `versionId=null`, the spelling every S3 target understands,
|
||||
/// like the proxy and audit reads do.
|
||||
#[tokio::test]
|
||||
async fn head_object_addresses_nil_source_version_as_null() {
|
||||
let (client, request_uris) = recording_target_client();
|
||||
let versioned = Uuid::new_v4().to_string();
|
||||
// Only the request URI matters here; the canned empty response may or
|
||||
// may not parse as a HEAD result.
|
||||
let _ = client
|
||||
.head_object("target-bucket", "object", Some(Uuid::nil().to_string()))
|
||||
.await;
|
||||
let _ = client.head_object("target-bucket", "object", Some(versioned.clone())).await;
|
||||
|
||||
let request_uris = request_uris.lock().expect("recorded request lock should not be poisoned");
|
||||
assert_eq!(request_uris.len(), 2);
|
||||
assert!(
|
||||
request_uris[0].contains("versionId=null") && !request_uris[0].contains(&Uuid::nil().to_string()),
|
||||
"a nil source version must be addressed as the null version: {}",
|
||||
request_uris[0]
|
||||
);
|
||||
assert!(
|
||||
request_uris[1].contains(&format!("versionId={versioned}")),
|
||||
"a concrete source version must pass through unchanged: {}",
|
||||
request_uris[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_object_sends_source_version_id_query_to_target() {
|
||||
// MinIO reads the replicated version only from the `versionId` query
|
||||
|
||||
@@ -879,7 +879,11 @@ fn free_version_cleanup_fences_current(
|
||||
&& free_version_physical_topology_generation(api) == topology_generation
|
||||
}
|
||||
|
||||
async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel: &CancellationToken) -> std::io::Result<bool> {
|
||||
pub(crate) async fn cleanup_free_version_exact(
|
||||
api: Arc<ECStore>,
|
||||
oi: &ObjectInfo,
|
||||
cancel: &CancellationToken,
|
||||
) -> std::io::Result<bool> {
|
||||
const FREE_VERSION_REMOTE_DEADLINE: StdDuration = StdDuration::from_secs(30);
|
||||
|
||||
let topology_generation = free_version_physical_topology_generation(&api);
|
||||
@@ -1038,6 +1042,32 @@ pub struct ExpiryState {
|
||||
recovery_notify: Arc<Notify>,
|
||||
}
|
||||
|
||||
/// Keeps every lifecycle expiry task the process enqueues in its queue until
|
||||
/// released, as if the expiry workers were busy.
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) struct HeldExpiryQueue {
|
||||
state: Arc<RwLock<ExpiryState>>,
|
||||
previous: ExpiryState,
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
impl HeldExpiryQueue {
|
||||
pub(crate) async fn hold() -> Self {
|
||||
let held = Arc::try_unwrap(ExpiryState::new_with_unconsumed_worker_channel(16))
|
||||
.ok()
|
||||
.expect("a new expiry state has a single owner")
|
||||
.into_inner();
|
||||
let state = runtime_sources::expiry_state_handle();
|
||||
let previous = std::mem::replace(&mut *state.write().await, held);
|
||||
Self { state, previous }
|
||||
}
|
||||
|
||||
/// Drops the held tasks unrun and restores the previous expiry state.
|
||||
pub(crate) async fn release(self) {
|
||||
*self.state.write().await = self.previous;
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpiryState {
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new() -> Arc<RwLock<Self>> {
|
||||
@@ -3012,14 +3042,7 @@ fn spawn_tier_free_version_recovery_once_with_options(
|
||||
move |bucket_marker, object_marker, recovery_cancel| {
|
||||
let api = Arc::clone(&api);
|
||||
async move {
|
||||
recover_tier_free_versions_with_cancel(
|
||||
api,
|
||||
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
|
||||
bucket_marker,
|
||||
object_marker,
|
||||
recovery_cancel,
|
||||
)
|
||||
.await
|
||||
recover_configured_tier_free_versions(api, bucket_marker, object_marker, recovery_cancel).await
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -3031,6 +3054,39 @@ fn spawn_tier_free_version_recovery_once_with_options(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn recover_configured_tier_free_versions(
|
||||
api: Arc<ECStore>,
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> crate::error::Result<FreeVersionRecoveryStats> {
|
||||
let tier_config_mgr = api.tier_config_mgr();
|
||||
let has_tiers = select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "tier free-version recovery cancelled").into());
|
||||
}
|
||||
manager = tier_config_mgr.read() => !manager.empty(),
|
||||
};
|
||||
// Startup can launch recovery before tier configuration is loaded. Keep
|
||||
// polling, but release the manager lock before any namespace I/O.
|
||||
if !has_tiers {
|
||||
return Ok(FreeVersionRecoveryStats {
|
||||
scanned: 0,
|
||||
enqueued: 0,
|
||||
failed: 0,
|
||||
next_bucket_marker: None,
|
||||
next_object_marker: None,
|
||||
scanned_entries: 0,
|
||||
buckets_scanned: 0,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
recover_tier_free_versions_with_cancel(api, DEFAULT_FREE_VERSION_RECOVERY_LIMIT, bucket_marker, object_marker, cancel_token)
|
||||
.await
|
||||
}
|
||||
|
||||
fn tier_free_version_recovery_leader_retry_delay() -> StdDuration {
|
||||
StdDuration::from_secs(rand::rng().random_range(15..=30))
|
||||
}
|
||||
@@ -14220,9 +14276,14 @@ mod tests {
|
||||
|
||||
crate::store::init_local_disks(endpoint_pools.clone()).await.unwrap();
|
||||
|
||||
let ecstore = ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new())
|
||||
.await
|
||||
.unwrap();
|
||||
// Tests start the recovery loop they observe. An automatic startup loop
|
||||
// would race that loop for the same unconsumed worker queue.
|
||||
let ecstore = temp_env::async_with_vars(
|
||||
[(super::ENV_TIER_FREE_VERSION_RECOVERY_ENABLED, Some("false"))],
|
||||
ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let buckets = ecstore
|
||||
.list_bucket(&BucketOptions {
|
||||
@@ -14698,10 +14759,232 @@ mod tests {
|
||||
StdDuration::from_millis(10)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_without_tiers_skips_listing_and_resets_cursor() {
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let manager = ecstore.tier_config_mgr();
|
||||
let saved_tiers = std::mem::take(&mut manager.write().await.tiers);
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let recovery_rx = Arc::clone(&runtime_state.read().await.tasks_rx[0]);
|
||||
let bucket = format!("recovery-no-tiers-{}", Uuid::new_v4());
|
||||
let object = "free-version";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
|
||||
let metadata_path = disk_paths[0].join(&bucket).join(object).join(STORAGE_FORMAT_FILE);
|
||||
let metadata_before = fs::read(&metadata_path).await.expect("seeded metadata should be readable");
|
||||
|
||||
let list_started = Arc::new(tokio::sync::Notify::new());
|
||||
let list_wait = set_recovery_bucket_list_wait_hook(Arc::clone(&list_started));
|
||||
let walk_calls = Arc::new(AtomicUsize::new(0));
|
||||
let counted_walks = Arc::clone(&walk_calls);
|
||||
let walk_hook = set_recovery_walk_test_hook(move |_| {
|
||||
counted_walks.fetch_add(1, Ordering::SeqCst);
|
||||
None
|
||||
});
|
||||
let mut schedule = super::TierFreeVersionRecoverySchedule::default();
|
||||
schedule.record_success(&free_version_recovery_stats(1, 0, true));
|
||||
let stats = tokio::select! {
|
||||
biased;
|
||||
_ = list_started.notified() => panic!("empty tier configuration must not list buckets"),
|
||||
result = super::recover_configured_tier_free_versions(
|
||||
Arc::clone(&ecstore),
|
||||
schedule.bucket_marker.clone(),
|
||||
schedule.object_marker.clone(),
|
||||
CancellationToken::new(),
|
||||
) => result.expect("empty tier configuration should be an idle recovery result"),
|
||||
};
|
||||
assert_eq!(
|
||||
stats,
|
||||
FreeVersionRecoveryStats {
|
||||
scanned: 0,
|
||||
enqueued: 0,
|
||||
failed: 0,
|
||||
next_bucket_marker: None,
|
||||
next_object_marker: None,
|
||||
scanned_entries: 0,
|
||||
buckets_scanned: 0,
|
||||
truncated: false,
|
||||
}
|
||||
);
|
||||
schedule.record_success(&stats);
|
||||
assert!(schedule.bucket_marker.is_none());
|
||||
assert!(schedule.object_marker.is_none());
|
||||
assert!(!schedule.follow_up_sweep);
|
||||
assert_eq!(walk_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(matches!(
|
||||
recovery_rx.lock().await.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read(&metadata_path).await.expect("paused recovery must retain metadata"),
|
||||
metadata_before
|
||||
);
|
||||
drop(list_wait);
|
||||
drop(walk_hook);
|
||||
|
||||
remove_seeded_free_version(&disk_paths, &bucket, object).await;
|
||||
ecstore
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("test bucket should be removed");
|
||||
manager.write().await.tiers = saved_tiers;
|
||||
reset_runtime_expiry_state(&ecstore).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_tier_snapshot_wait_is_cancellable() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let manager = ecstore.tier_config_mgr();
|
||||
let _write_guard = manager.write().await;
|
||||
let cancel = CancellationToken::new();
|
||||
let recovery = super::recover_configured_tier_free_versions(ecstore, None, None, cancel.clone());
|
||||
tokio::pin!(recovery);
|
||||
assert!(
|
||||
futures::poll!(&mut recovery).is_pending(),
|
||||
"recovery should wait for the tier manager lock"
|
||||
);
|
||||
cancel.cancel();
|
||||
let err = recovery
|
||||
.await
|
||||
.expect_err("cancellation must not wait for the tier manager writer");
|
||||
assert!(matches!(err, crate::error::Error::Io(ref err) if err.kind() == std::io::ErrorKind::Interrupted));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_loop_observes_tier_configuration_changes() {
|
||||
let automatic_recovery_started = super::TIER_FREE_VERSION_RECOVERY_STARTED.get().copied();
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
assert_eq!(
|
||||
super::TIER_FREE_VERSION_RECOVERY_STARTED.get().copied(),
|
||||
automatic_recovery_started,
|
||||
"fixture must not start an unowned automatic recovery loop"
|
||||
);
|
||||
let manager = ecstore.tier_config_mgr();
|
||||
let saved_tiers = std::mem::take(&mut manager.write().await.tiers);
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let recovery_rx = Arc::clone(&runtime_state.read().await.tasks_rx[0]);
|
||||
let bucket = format!("recovery-dynamic-tier-{}", Uuid::new_v4());
|
||||
let object = "free-version";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
|
||||
|
||||
let page_permits = Arc::new(tokio::sync::Semaphore::new(1));
|
||||
let worker_permits = Arc::clone(&page_permits);
|
||||
let (page_tx, mut page_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let cancel = CancellationToken::new();
|
||||
let loop_cancel = cancel.clone();
|
||||
let recovery_store = Arc::clone(&ecstore);
|
||||
let worker = tokio::spawn(async move {
|
||||
run_tier_free_version_recovery_loop(
|
||||
loop_cancel,
|
||||
runtime_state,
|
||||
std::convert::identity,
|
||||
move |bucket, object, cancel| {
|
||||
let api = Arc::clone(&recovery_store);
|
||||
let page_tx = page_tx.clone();
|
||||
let page_permits = Arc::clone(&worker_permits);
|
||||
async move {
|
||||
// Advance the scheduler clock only while real disk I/O is
|
||||
// parked. Otherwise its timeout can win while the blocking
|
||||
// filesystem operation is still running on wall-clock time.
|
||||
let permit = tokio::select! {
|
||||
_ = cancel.cancelled() => return Err(crate::error::Error::other("test recovery canceled")),
|
||||
permit = page_permits.acquire() => permit.expect("test controls page admission"),
|
||||
};
|
||||
permit.forget();
|
||||
let stats =
|
||||
super::recover_configured_tier_free_versions(api, bucket.clone(), object.clone(), cancel).await?;
|
||||
page_tx
|
||||
.send((bucket, object, stats.clone()))
|
||||
.expect("test should observe each recovery result");
|
||||
Ok(stats)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
let (_, _, idle) = page_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("worker should check the initially empty configuration");
|
||||
assert_eq!(idle.buckets_scanned, 0);
|
||||
assert!(matches!(
|
||||
recovery_rx.lock().await.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
|
||||
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
|
||||
crate::services::tier::test_util::register_mock_tier(&manager, &tier).await;
|
||||
tokio::time::pause();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL).await;
|
||||
tokio::time::resume();
|
||||
page_permits.add_permits(1);
|
||||
let (bucket_marker, object_marker, active) = tokio::time::timeout(StdDuration::from_secs(30), page_rx.recv())
|
||||
.await
|
||||
.expect("new tier should enable the next recovery page")
|
||||
.expect("worker should remain running");
|
||||
assert!(bucket_marker.is_none());
|
||||
assert!(object_marker.is_none());
|
||||
assert_eq!(active.enqueued, 1, "recovery page: {active:?}");
|
||||
let task = recovery_rx
|
||||
.lock()
|
||||
.await
|
||||
.try_recv()
|
||||
.expect("new tier should enable discovery")
|
||||
.expect("queue item should exist");
|
||||
let recovered = task
|
||||
.as_any()
|
||||
.downcast_ref::<FreeVersionTask>()
|
||||
.expect("seeded free version should be queued");
|
||||
assert_eq!(recovered.0.bucket, bucket);
|
||||
assert_eq!(recovered.0.name, object);
|
||||
|
||||
// Simulate a historical or temporarily missing configuration without
|
||||
// bypassing the admin removal path's persisted-reference proof.
|
||||
let configured_tiers = std::mem::take(&mut manager.write().await.tiers);
|
||||
tokio::time::pause();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL).await;
|
||||
tokio::time::resume();
|
||||
page_permits.add_permits(1);
|
||||
let (_, _, idle_again) = tokio::time::timeout(StdDuration::from_secs(30), page_rx.recv())
|
||||
.await
|
||||
.expect("empty configuration should still produce an idle check")
|
||||
.expect("worker should remain running");
|
||||
assert_eq!(idle_again.buckets_scanned, 0);
|
||||
assert_eq!(idle_again.enqueued, 0);
|
||||
assert!(matches!(
|
||||
recovery_rx.lock().await.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
cancel.cancel();
|
||||
worker.await.expect("worker should exit on cancellation");
|
||||
manager.write().await.tiers = configured_tiers;
|
||||
manager
|
||||
.write()
|
||||
.await
|
||||
.remove(&tier, true)
|
||||
.await
|
||||
.expect("mock tier should be removed");
|
||||
manager.write().await.tiers = saved_tiers;
|
||||
remove_seeded_free_version(&disk_paths, &bucket, object).await;
|
||||
ecstore
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("test bucket should be removed");
|
||||
reset_runtime_expiry_state(&ecstore).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_production_entrypoint_enqueues_seeded_item() {
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
|
||||
crate::services::tier::test_util::register_mock_tier(&ecstore.tier_config_mgr(), &tier).await;
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let recovery_rx = {
|
||||
let state = runtime_state.read().await;
|
||||
@@ -14737,6 +15020,13 @@ mod tests {
|
||||
.await
|
||||
.expect_err("aborted production recovery task should report cancellation");
|
||||
assert!(join_error.is_cancelled());
|
||||
ecstore
|
||||
.tier_config_mgr()
|
||||
.write()
|
||||
.await
|
||||
.remove(&tier, true)
|
||||
.await
|
||||
.expect("mock tier should be removed");
|
||||
remove_seeded_free_version(&disk_paths, &bucket, object).await;
|
||||
ecstore
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
|
||||
@@ -63,7 +63,14 @@ pub(crate) async fn get_expiry_configs_for_options(
|
||||
bucket: &str,
|
||||
opts: &crate::object_api::ObjectOptions,
|
||||
) -> Result<LifecycleExpiryConfigs> {
|
||||
let bucket_incarnation_id = metadata_sys::get_bucket_incarnation_id_for_options_in(&api.ctx, bucket, opts).await?;
|
||||
let bucket_incarnation_id = match opts
|
||||
.object_lock_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.validated_bucket_incarnation_for(api.id, bucket, opts.expected_bucket_incarnation_id))
|
||||
{
|
||||
Some(incarnation_id) => incarnation_id,
|
||||
None => metadata_sys::get_bucket_incarnation_id_for_options_in(&api.ctx, bucket, opts).await?,
|
||||
};
|
||||
let metadata = get_authoritative_metadata(api, bucket, bucket_incarnation_id).await?;
|
||||
let table_bucket_enabled = metadata.table_bucket_enabled();
|
||||
|
||||
@@ -174,5 +181,36 @@ mod tests {
|
||||
.unwrap()
|
||||
);
|
||||
assert!(get_expiry_configs(&store_b, bucket).await.unwrap().lifecycle.is_none());
|
||||
|
||||
let snapshot = store_a.object_lock_config_snapshot(bucket).await.unwrap();
|
||||
assert_eq!(
|
||||
snapshot.validated_bucket_incarnation_for(store_a.id, bucket, Some(configs.bucket_incarnation_id)),
|
||||
Some(configs.bucket_incarnation_id)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.validated_bucket_incarnation_for(store_a.id, bucket, Some(uuid::Uuid::new_v4())),
|
||||
None
|
||||
);
|
||||
assert_eq!(snapshot.validated_bucket_incarnation_for(store_b.id, bucket, None), None);
|
||||
let opts = crate::object_api::ObjectOptions {
|
||||
object_lock_config_snapshot: Some(snapshot.clone()),
|
||||
expected_bucket_incarnation_id: Some(configs.bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
get_expiry_configs_for_options(&store_a, bucket, &opts)
|
||||
.await
|
||||
.unwrap()
|
||||
.bucket_incarnation_id,
|
||||
configs.bucket_incarnation_id
|
||||
);
|
||||
let unguarded = crate::object_api::ObjectLockConfigSnapshot::for_store_bucket(
|
||||
store_a.id,
|
||||
bucket,
|
||||
configs.bucket_incarnation_id,
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
|
||||
);
|
||||
assert_eq!(unguarded.validated_bucket_incarnation_for(store_a.id, bucket, None), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use super::{
|
||||
use crate::bucket::metadata_sys::{
|
||||
BucketMetadataSys, update, update_if_incarnation, update_quota_with_sys_if_incarnation_and_revision,
|
||||
};
|
||||
use crate::data_usage::get_bucket_usage_memory;
|
||||
use crate::data_usage::{get_bucket_usage_memory, reload_unknown_bucket_usage};
|
||||
use crate::error::StorageError;
|
||||
use rustfs_config::QUOTA_CONFIG_FILE;
|
||||
use rustfs_scanner_metrics::metrics::Metric;
|
||||
@@ -254,25 +254,23 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
/// Scanner usage for a bucket the reservation ledger admits is reporting
|
||||
/// input only, so a failed lookup degrades to "unknown" and is counted
|
||||
/// input only, so unavailable memory usage is reported as "unknown" and counted
|
||||
/// instead of becoming an error the write path would fail on.
|
||||
async fn report_only_usage(&self, bucket: &str) -> Option<u64> {
|
||||
match self.get_real_time_usage(bucket).await {
|
||||
Ok(usage) => Some(usage),
|
||||
Err(err) => {
|
||||
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
|
||||
debug!(
|
||||
event = EVENT_QUOTA_USAGE_REPORT,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_QUOTA,
|
||||
state = "usage_unknown",
|
||||
bucket = %bucket,
|
||||
error = %err,
|
||||
"durable quota usage report degraded"
|
||||
);
|
||||
None
|
||||
}
|
||||
// The plain lookup: a report must not wait for an early snapshot reload.
|
||||
let usage = get_bucket_usage_memory(bucket).await;
|
||||
if usage.is_none() {
|
||||
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
|
||||
debug!(
|
||||
event = EVENT_QUOTA_USAGE_REPORT,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_QUOTA,
|
||||
state = "usage_unknown",
|
||||
bucket = %bucket,
|
||||
"durable quota usage report degraded"
|
||||
);
|
||||
}
|
||||
usage
|
||||
}
|
||||
|
||||
/// Re-save `bucket`'s quota under the sharded durable reservation
|
||||
@@ -390,8 +388,22 @@ impl QuotaChecker {
|
||||
self.metadata_sys.read().await.get(bucket).await.is_ok()
|
||||
}
|
||||
|
||||
/// Usage for a caller that fails closed without it. In a distributed
|
||||
/// deployment a bucket the cached usage snapshot does not cover yet may
|
||||
/// reload that snapshot before the degraded fallback is consulted.
|
||||
pub async fn get_real_time_usage(&self, bucket: &str) -> Result<u64, QuotaError> {
|
||||
if let Some(usage) = get_bucket_usage_memory(bucket).await {
|
||||
let usage = match get_bucket_usage_memory(bucket).await {
|
||||
Some(usage) => Some(usage),
|
||||
// Boxed like the degraded fallback below: the snapshot reload
|
||||
// would otherwise grow every write's state machine, and it only
|
||||
// runs for a bucket the cached snapshot does not cover.
|
||||
None => Box::pin(reload_unknown_bucket_usage(bucket)).await,
|
||||
};
|
||||
self.usage_or_degraded_baseline(bucket, usage).await
|
||||
}
|
||||
|
||||
async fn usage_or_degraded_baseline(&self, bucket: &str, usage: Option<u64>) -> Result<u64, QuotaError> {
|
||||
if let Some(usage) = usage {
|
||||
return Ok(usage);
|
||||
}
|
||||
|
||||
@@ -528,6 +540,8 @@ mod tests {
|
||||
use serial_test::serial;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod reporting_retirement;
|
||||
|
||||
/// Regression (PR #5307 / s3-tests `test_100_continue_error_retry`): a
|
||||
/// bucket with no persisted metadata has no quota, so the admission check
|
||||
/// must pass and let the request reach its NoSuchBucket answer. With the
|
||||
@@ -695,6 +709,11 @@ mod tests {
|
||||
.await
|
||||
.expect("stats for a ledger-admitted bucket degrade to an absent usage");
|
||||
assert_eq!(usage, None);
|
||||
assert_eq!(
|
||||
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
|
||||
0,
|
||||
"reporting a ledger-admitted bucket's usage must not wait for an early snapshot reload"
|
||||
);
|
||||
|
||||
// Negative control: the ledger, not the scanner, still refuses an
|
||||
// over-limit write with the same error.
|
||||
@@ -718,7 +737,18 @@ mod tests {
|
||||
checker.check_quota(&legacy, QuotaOperation::PutObject, 512).await,
|
||||
Err(QuotaError::UsageUnavailable { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy),
|
||||
1,
|
||||
"fail-closed admission takes the early-reload lookup"
|
||||
);
|
||||
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 2);
|
||||
assert_eq!(
|
||||
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
|
||||
0,
|
||||
"ledger-admitted writes never take the early-reload lookup"
|
||||
);
|
||||
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::disk::DiskAPI as _;
|
||||
use crate::object_api::{ObjectOptions, PutObjReader};
|
||||
use crate::storage_api_contracts::object::ObjectIO as _;
|
||||
use crate::test_metrics::CapturingRecorder;
|
||||
use rustfs_data_usage::{BucketUsageInfo, DataUsageInfo};
|
||||
use std::time::SystemTime;
|
||||
|
||||
async fn stop_reporting_fixture(store: &crate::store::ECStore) {
|
||||
if let Some(cancel) = store.ctx.background_cancel_token() {
|
||||
cancel.cancel();
|
||||
}
|
||||
for pool in &store.pools {
|
||||
for set in &pool.disk_set {
|
||||
let disks = set.disks.read().await.clone();
|
||||
for disk in disks.iter().flatten() {
|
||||
disk.close().await.expect("stop fixture disk monitoring");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_reporting_snapshot(
|
||||
store: Arc<crate::store::ECStore>,
|
||||
bucket: &str,
|
||||
source: &str,
|
||||
size: u64,
|
||||
) -> Option<(String, Vec<u8>)> {
|
||||
if source == "missing" {
|
||||
return None;
|
||||
}
|
||||
let mut info = DataUsageInfo {
|
||||
last_update: Some(SystemTime::now()),
|
||||
scanner_epoch: Some(1),
|
||||
scanner_cycle: Some(1),
|
||||
buckets_count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
info.buckets_usage.insert(
|
||||
bucket.to_owned(),
|
||||
BucketUsageInfo {
|
||||
size,
|
||||
objects_count: 1,
|
||||
versions_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
info.bucket_sizes.insert(bucket.to_owned(), size);
|
||||
info.calculate_totals();
|
||||
let object = match source {
|
||||
"legacy" => rustfs_data_usage::LEGACY_DATA_USAGE_OBJECT_NAME,
|
||||
"incomplete" => rustfs_data_usage::DATA_USAGE_OBJECT_NAME,
|
||||
"complete" => {
|
||||
info.usage_snapshot_complete = true;
|
||||
rustfs_data_usage::DATA_USAGE_OBJECT_NAME
|
||||
}
|
||||
"observed" => {
|
||||
info.usage_snapshot_complete = true;
|
||||
info.usage_snapshot_converged = Some(false);
|
||||
info.usage_snapshot_authoritative_baseline = Some(DataUsageInfo::default().snapshot_identity());
|
||||
rustfs_data_usage::DATA_USAGE_OBSERVED_OBJECT_NAME
|
||||
}
|
||||
_ => panic!("unknown scanner fixture source"),
|
||||
};
|
||||
let path = format!("{}/{object}", crate::disk::BUCKET_META_PREFIX);
|
||||
let bytes = serde_json::to_vec(&info).expect("scanner fixture encoding");
|
||||
crate::config::com::save_config(store, &path, bytes.clone())
|
||||
.await
|
||||
.expect("persist scanner fixture");
|
||||
if source == "complete" {
|
||||
crate::data_usage::replace_bucket_usage_memory_from_info(&info).await;
|
||||
}
|
||||
Some((path, bytes))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn durable_quota_reporting_retires_degraded_floors_without_changing_ledger_admission() {
|
||||
let recorder = CapturingRecorder::default();
|
||||
let _recorder = metrics::set_default_local_recorder(&recorder);
|
||||
for protocol in [1, 2] {
|
||||
for source in ["missing", "legacy", "incomplete", "observed", "complete"] {
|
||||
let (_dirs, store, sys) = isolated_durable_quota_store().await;
|
||||
let checker = QuotaChecker::new(sys);
|
||||
let bucket = format!("quota-report-{protocol}-{}", Uuid::new_v4().simple());
|
||||
let quota = if protocol == 1 {
|
||||
BucketQuota::new(Some(4096))
|
||||
} else {
|
||||
BucketQuota::new_sharded(4096)
|
||||
};
|
||||
create_bucket_with_quota_json(&store, &bucket, serde_json::to_vec("a).expect("quota fixture encoding")).await;
|
||||
let snapshot = install_reporting_snapshot(store.clone(), &bucket, source, 1_000_000).await;
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
let unavailable_before = recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]);
|
||||
let check = checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, 512)
|
||||
.await
|
||||
.expect("reporting cannot fail ledger admission");
|
||||
let (_, usage) = checker.get_quota_stats(&bucket).await.expect("best-effort quota statistics");
|
||||
let preview = checker
|
||||
.check_quota_with_usage_reporting(&bucket, QuotaOperation::PutObject, 512, true)
|
||||
.await
|
||||
.expect("admin preview uses the same optional report");
|
||||
|
||||
// Exercise the real storage entry point before testing the new
|
||||
// reporting semantics: the same ledger must admit and refuse
|
||||
// these writes even when a scanner floor claims a million bytes.
|
||||
let mut too_large = PutObjReader::from_vec(vec![0x5a; 8192]);
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.put_object(&bucket, "too-large", &mut too_large, &ObjectOptions::default())
|
||||
.await,
|
||||
Err(StorageError::QuotaExceeded { .. })
|
||||
),
|
||||
"protocol={protocol}, source={source}"
|
||||
);
|
||||
let mut fits = PutObjReader::from_vec(vec![0x5a; 512]);
|
||||
store
|
||||
.put_object(&bucket, "fits", &mut fits, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("ledger admits 512 bytes");
|
||||
let mut fills = PutObjReader::from_vec(vec![0x5a; 3584]);
|
||||
store
|
||||
.put_object(&bucket, "fills", &mut fills, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("ledger admits exactly the remaining quota");
|
||||
let mut exceeds = PutObjReader::from_vec(vec![0x5a; 1]);
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.put_object(&bucket, "exceeds", &mut exceeds, &ObjectOptions::default())
|
||||
.await,
|
||||
Err(StorageError::QuotaExceeded { .. })
|
||||
),
|
||||
"ledger cannot undercount protocol={protocol}, source={source}"
|
||||
);
|
||||
|
||||
assert!(check.allowed && check.uses_durable_reservations);
|
||||
let expected_usage = (source == "complete").then_some(1_000_000);
|
||||
assert_eq!(
|
||||
check.current_usage, expected_usage,
|
||||
"protocol={protocol}, source={source}: a floor is not a current report"
|
||||
);
|
||||
assert_eq!(check.remaining, expected_usage.map(|_| 0));
|
||||
assert_eq!(usage, expected_usage, "statistics use the same best-effort reporting contract");
|
||||
assert_eq!(preview.current_usage, expected_usage);
|
||||
assert_eq!(preview.remaining, expected_usage.map(|_| 0));
|
||||
assert_eq!(preview.allowed, source != "complete", "unknown preview is advisory, not a reservation");
|
||||
assert_eq!(
|
||||
recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]) - unavailable_before,
|
||||
if source == "complete" { 0 } else { 3 },
|
||||
"each unknown check/statistics/preview report is counted"
|
||||
);
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
|
||||
assert_eq!(persisted_quota(&store, &bucket).await.reservation_protocol, Some(protocol));
|
||||
if let Some((path, original)) = snapshot {
|
||||
assert_eq!(
|
||||
crate::config::com::read_config(store.clone(), &path)
|
||||
.await
|
||||
.expect("retained scanner evidence"),
|
||||
original
|
||||
);
|
||||
}
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("fixture cache cleanup");
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
stop_reporting_fixture(&store).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn snapshot_quota_admission_retains_degraded_floors_until_migration() {
|
||||
for source in ["missing", "legacy", "incomplete", "observed"] {
|
||||
let (_dirs, store, _) = isolated_durable_quota_store().await;
|
||||
let bucket = format!("quota-compat-{}", Uuid::new_v4().simple());
|
||||
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
|
||||
// An unregistered checker models a snapshot-protocol reader without
|
||||
// starting the optional background migration during this assertion.
|
||||
let checker = QuotaChecker::new(Arc::new(RwLock::new(BucketMetadataSys::new(store.clone()))));
|
||||
install_reporting_snapshot(store.clone(), &bucket, source, 1000).await;
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
let check = checker.check_quota(&bucket, QuotaOperation::PutObject, 512).await;
|
||||
if source == "missing" {
|
||||
assert!(matches!(check, Err(QuotaError::UsageUnavailable { .. })));
|
||||
} else {
|
||||
let check = check.expect("legacy admission retains its grounded compatibility fallback");
|
||||
assert_eq!(check.current_usage, Some(1000));
|
||||
assert!(check.allowed && !check.uses_durable_reservations);
|
||||
assert!(
|
||||
!checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, 4096)
|
||||
.await
|
||||
.expect("legacy quota decision")
|
||||
.allowed
|
||||
);
|
||||
}
|
||||
assert_eq!(persisted_quota(&store, &bucket).await.reservation_protocol, None);
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("fixture cache cleanup");
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
stop_reporting_fixture(&store).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn quota_without_limit_keeps_the_zero_usage_io_fast_path() {
|
||||
let (_dirs, store, sys) = isolated_durable_quota_store().await;
|
||||
let bucket = format!("quota-unlimited-{}", Uuid::new_v4().simple());
|
||||
create_bucket_with_quota_json(
|
||||
&store,
|
||||
&bucket,
|
||||
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
|
||||
)
|
||||
.await;
|
||||
let recorder = CapturingRecorder::default();
|
||||
let _recorder = metrics::set_default_local_recorder(&recorder);
|
||||
let check = QuotaChecker::new(sys)
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, u64::MAX)
|
||||
.await
|
||||
.expect("unlimited quota fast path");
|
||||
assert!(check.allowed);
|
||||
assert_eq!(check.current_usage, None);
|
||||
assert_eq!(check.quota_limit, None);
|
||||
assert_eq!(recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]), 0);
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
|
||||
stop_reporting_fixture(&store).await;
|
||||
}
|
||||
@@ -595,7 +595,7 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
|
||||
let allow_create = shard_may_be_created(&allocator, data.shard_index, bootstrap);
|
||||
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
|
||||
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
|
||||
let mut shard = load_current_shard_locked(
|
||||
let (mut shard, shard_created) = load_current_shard_locked(
|
||||
Arc::clone(&store),
|
||||
&data.shard_object,
|
||||
bucket_incarnation,
|
||||
@@ -640,7 +640,7 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
|
||||
});
|
||||
}
|
||||
let shard_was_empty = shard.grants.is_empty() && shard.reservations.is_empty() && shard.accounted_usage == 0;
|
||||
if shard_was_empty {
|
||||
if shard_created || shard_was_empty {
|
||||
fence_namespace_mutations(&store, RUSTFS_META_BUCKET, &data.shard_object, None).await?;
|
||||
save_shard_locked(Arc::clone(&store), &data.shard_object, &shard, &shard_guard).await?;
|
||||
}
|
||||
@@ -1510,18 +1510,18 @@ async fn load_current_shard_locked(
|
||||
shard_index: u16,
|
||||
allow_create: bool,
|
||||
allocator: &QuotaAllocatorLedger,
|
||||
) -> Result<QuotaShardLedger> {
|
||||
let mut shard = match load_shard_locked(Arc::clone(&store), object).await {
|
||||
Ok(shard) if shard.matches(bucket_incarnation, quota_revision, shard_index) => shard,
|
||||
) -> Result<(QuotaShardLedger, bool)> {
|
||||
let (mut shard, created) = match load_shard_locked(Arc::clone(&store), object).await {
|
||||
Ok(shard) if shard.matches(bucket_incarnation, quota_revision, shard_index) => (shard, false),
|
||||
Ok(_) => return Err(StorageError::PartMissingOrCorrupt),
|
||||
Err(StorageError::ConfigNotFound) if allow_create => {
|
||||
QuotaShardLedger::new(bucket_incarnation, quota_revision, shard_index)
|
||||
(QuotaShardLedger::new(bucket_incarnation, quota_revision, shard_index), true)
|
||||
}
|
||||
Err(StorageError::ConfigNotFound) => return Err(StorageError::PartMissingOrCorrupt),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
shard.adopt_grants(allocator)?;
|
||||
Ok(shard)
|
||||
Ok((shard, created))
|
||||
}
|
||||
|
||||
async fn save_allocator_locked(
|
||||
@@ -1646,7 +1646,7 @@ async fn reap_sharded_reservations(
|
||||
.await?;
|
||||
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
|
||||
let shard_guard = shard_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
let shard = load_current_shard_locked(
|
||||
let (shard, _) = load_current_shard_locked(
|
||||
Arc::clone(&store),
|
||||
&data.shard_object,
|
||||
bucket_incarnation,
|
||||
@@ -1709,7 +1709,7 @@ async fn reap_sharded_reservations(
|
||||
.await?;
|
||||
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
|
||||
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
|
||||
let mut shard = load_current_shard_locked(
|
||||
let (mut shard, _) = load_current_shard_locked(
|
||||
Arc::clone(&store),
|
||||
&data.shard_object,
|
||||
bucket_incarnation,
|
||||
@@ -1774,7 +1774,7 @@ async fn mark_commit_started_sharded(data: &ShardedReservationData) -> Result<()
|
||||
let allocator = load_allocator_locked(Arc::clone(&store), &data.allocator_object).await?;
|
||||
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
|
||||
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
|
||||
let mut shard = load_current_shard_locked(
|
||||
let (mut shard, _) = load_current_shard_locked(
|
||||
Arc::clone(&store),
|
||||
&data.shard_object,
|
||||
data.bucket_incarnation,
|
||||
@@ -1819,7 +1819,7 @@ async fn settle_sharded(data: &ShardedReservationData, committed: bool) -> Resul
|
||||
let allocator = load_allocator_locked(Arc::clone(&store), &data.allocator_object).await?;
|
||||
let shard_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &data.shard_object).await?;
|
||||
let shard_guard = Arc::new(shard_lock.get_write_lock(get_lock_acquire_timeout()).await?);
|
||||
let mut shard = load_current_shard_locked(
|
||||
let (mut shard, _) = load_current_shard_locked(
|
||||
Arc::clone(&store),
|
||||
&data.shard_object,
|
||||
data.bucket_incarnation,
|
||||
@@ -2459,7 +2459,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Its writes read the process-wide usage snapshot cache through the
|
||||
// degraded-baseline fallback, which the data-usage cache tests rewrite.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn sharded_reservation_reloads_allocator_after_concurrent_refill() {
|
||||
use crate::object_api::PutObjReader;
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
||||
@@ -2532,4 +2535,70 @@ mod tests {
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn sharded_reservation_persists_a_populated_lazy_shard_before_refill() {
|
||||
use crate::object_api::PutObjReader;
|
||||
use crate::storage_api_contracts::object::ObjectIO;
|
||||
|
||||
let (_directories, store) = metadata_sys::test_support::isolated_store_over_temp_disks().await;
|
||||
metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
||||
let bucket = format!("quota-populated-bootstrap-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create quota bucket");
|
||||
|
||||
let mut seed_shard = None;
|
||||
let mut seed_name = None;
|
||||
let mut candidate_name = None;
|
||||
let mut bootstrap_name = None;
|
||||
for index in 0..10_000 {
|
||||
let object = format!("quota-populated-object-{index}");
|
||||
let object_shard = shard_index(&object);
|
||||
if seed_shard.is_none() {
|
||||
seed_shard = Some(object_shard);
|
||||
seed_name = Some(object);
|
||||
} else if Some(object_shard) == seed_shard && candidate_name.is_none() {
|
||||
candidate_name = Some(object);
|
||||
} else if Some(object_shard) != seed_shard && bootstrap_name.is_none() {
|
||||
bootstrap_name = Some(object);
|
||||
}
|
||||
if candidate_name.is_some() && bootstrap_name.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let seed_name = seed_name.expect("find a seeded shard");
|
||||
let candidate_name = candidate_name.expect("find a second object in the seeded shard");
|
||||
let bootstrap_name = bootstrap_name.expect("find an empty bootstrap shard");
|
||||
|
||||
let mut seed = PutObjReader::from_vec(vec![1]);
|
||||
store
|
||||
.put_object(&bucket, &seed_name, &mut seed, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("seed existing usage before enabling v2");
|
||||
store
|
||||
.update_bucket_metadata_config(
|
||||
&bucket,
|
||||
"quota.json",
|
||||
serde_json::to_vec(&super::super::BucketQuota::new_sharded(100_000)).expect("serialize v2 quota"),
|
||||
)
|
||||
.await
|
||||
.expect("enable v2 quota");
|
||||
|
||||
let mut bootstrap = PutObjReader::from_vec(vec![2]);
|
||||
store
|
||||
.put_object(&bucket, &bootstrap_name, &mut bootstrap, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("bootstrap an empty shard");
|
||||
|
||||
let mut candidate = PutObjReader::from_vec(vec![3]);
|
||||
let result = store
|
||||
.put_object(&bucket, &candidate_name, &mut candidate, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("a populated shard must persist before refill and accept its first growth");
|
||||
assert_eq!(result.size, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ pub use replication_queue_boundary::{
|
||||
};
|
||||
pub use replication_resync_boundary::ResyncStatusType;
|
||||
pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus};
|
||||
#[cfg(test)]
|
||||
pub(crate) use replication_resyncer::read_source_delete_marker;
|
||||
pub use replication_scanner_bridge::ReplicationScannerBridge;
|
||||
pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
|
||||
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
|
||||
|
||||
@@ -2475,19 +2475,8 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
|
||||
if dobj.delete_object.delete_marker
|
||||
&& let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id
|
||||
{
|
||||
let source_marker_state = storage
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
&dobj.delete_object.object_name,
|
||||
&ObjectOptions {
|
||||
version_id: Some(delete_marker_version_id.to_string()),
|
||||
versioned: ReplicationVersioningStore::prefix_enabled(&bucket, &dobj.delete_object.object_name).await,
|
||||
version_suspended: ReplicationVersioningStore::prefix_suspended(&bucket, &dobj.delete_object.object_name)
|
||||
.await,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let source_marker_state =
|
||||
read_source_delete_marker(&*storage, &bucket, &dobj.delete_object.object_name, delete_marker_version_id).await;
|
||||
|
||||
match source_marker_state {
|
||||
Ok(info) if info.delete_marker && info.version_id == Some(delete_marker_version_id) => {}
|
||||
@@ -2858,14 +2847,18 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
|
||||
)
|
||||
}
|
||||
|
||||
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
||||
/// Reads the source version addressed by `delete_marker_version_id` so a
|
||||
/// delete-marker replication can confirm the marker still exists. Plain
|
||||
/// `get_object_info` rejects explicit marker version reads on multi-pool
|
||||
/// stores with `MethodNotAllowed`, which would fail every marker (rustfs#8746).
|
||||
pub(crate) async fn read_source_delete_marker<S: EcstoreObjectOperations>(
|
||||
storage: &S,
|
||||
bucket: &str,
|
||||
object_name: &str,
|
||||
delete_marker_version_id: Uuid,
|
||||
) -> bool {
|
||||
match storage
|
||||
.get_object_info(
|
||||
) -> Result<ObjectInfo> {
|
||||
storage
|
||||
.get_object_info_for_delete(
|
||||
bucket,
|
||||
object_name,
|
||||
&ObjectOptions {
|
||||
@@ -2876,7 +2869,15 @@ async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
}
|
||||
|
||||
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
||||
storage: &S,
|
||||
bucket: &str,
|
||||
object_name: &str,
|
||||
delete_marker_version_id: Uuid,
|
||||
) -> bool {
|
||||
match read_source_delete_marker(storage, bucket, object_name, delete_marker_version_id).await {
|
||||
Ok(info) => !info.delete_marker || info.version_id != Some(delete_marker_version_id),
|
||||
Err(err) => is_err_object_not_found(&err) || is_err_version_not_found(&err),
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::{
|
||||
future::Future,
|
||||
io::ErrorKind,
|
||||
pin::Pin,
|
||||
sync::{Arc, OnceLock, atomic::AtomicBool},
|
||||
sync::{Arc, OnceLock},
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -40,7 +40,7 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_METACACHE: &str = "metacache";
|
||||
const EVENT_METACACHE_LISTING: &str = "metacache_listing";
|
||||
|
||||
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = Result<(), DiskError>> + Send>> + Send + 'static>;
|
||||
pub type PartialFn =
|
||||
Box<dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||
pub(crate) type PartialResultFn = Box<
|
||||
@@ -198,6 +198,8 @@ pub(crate) enum TestReaderBehavior {
|
||||
stopped: Arc<tokio::sync::Notify>,
|
||||
},
|
||||
IgnoreCancel,
|
||||
/// Entries followed by a terminator trailer saying the producer stopped at its limit.
|
||||
LimitReached(Vec<MetaCacheEntry>),
|
||||
ProducerError(DiskError),
|
||||
PrimaryErrorThenFallback(DiskError),
|
||||
PartialThenTimeout(Vec<MetaCacheEntry>),
|
||||
@@ -236,8 +238,6 @@ pub struct ListPathRawOptions {
|
||||
pub skip_walkdir_total_timeout: bool,
|
||||
pub walkdir_timeout: Option<Duration>,
|
||||
pub walkdir_stall_timeout: Option<Duration>,
|
||||
/// Shared terminal state for bounded producer walks.
|
||||
pub producer_limit_reached: Option<Arc<AtomicBool>>,
|
||||
pub agreed: Option<AgreedFn>,
|
||||
pub partial: Option<PartialFn>,
|
||||
pub finished: Option<FinishedFn>,
|
||||
@@ -248,7 +248,7 @@ pub struct ListPathRawOptions {
|
||||
#[cfg(test)]
|
||||
pub(crate) peek_timeout: Option<Duration>,
|
||||
#[cfg(test)]
|
||||
pub(crate) test_disk_io_context_observed: Option<Arc<AtomicBool>>,
|
||||
pub(crate) test_disk_io_context_observed: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
// pub agreed: Option<Arc<dyn Fn(MetaCacheEntry) + Send + Sync>>,
|
||||
// pub partial: Option<Arc<dyn Fn(MetaCacheEntries, &[Option<Error>]) + Send + Sync>>,
|
||||
// pub finished: Option<Arc<dyn Fn(&[Option<Error>]) + Send + Sync>>,
|
||||
@@ -273,7 +273,6 @@ impl Clone for ListPathRawOptions {
|
||||
skip_walkdir_total_timeout: self.skip_walkdir_total_timeout,
|
||||
walkdir_timeout: self.walkdir_timeout,
|
||||
walkdir_stall_timeout: self.walkdir_stall_timeout,
|
||||
producer_limit_reached: self.producer_limit_reached.clone(),
|
||||
#[cfg(test)]
|
||||
test_reader_behaviors: self.test_reader_behaviors.clone(),
|
||||
#[cfg(test)]
|
||||
@@ -301,22 +300,31 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
|
||||
skip_total_timeout: opts.skip_walkdir_total_timeout,
|
||||
timeout_ms: opts.walkdir_timeout.map(duration_millis),
|
||||
stall_timeout_ms: opts.walkdir_stall_timeout.map(duration_millis),
|
||||
producer_limit_reached: opts.producer_limit_reached.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// How a merged raw listing stream ended.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ListPathRawOutcome {
|
||||
/// At least one producer stopped at its per-disk limit instead of at the
|
||||
/// end of its namespace, so the closed stream is not proof of exhaustion.
|
||||
/// Carried in-band by the metacache stream trailer, so remote producers
|
||||
/// report it as well.
|
||||
pub limit_reached: bool,
|
||||
}
|
||||
|
||||
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
list_path_raw_inner(rx, opts, None, None).await
|
||||
list_path_raw_inner(rx, opts, None, None).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_path_raw_with_claim_tracker(
|
||||
rx: CancellationToken,
|
||||
opts: ListPathRawOptions,
|
||||
claim_tracker: FallbackClaimTracker,
|
||||
) -> disk::error::Result<()> {
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
list_path_raw_inner(rx, opts, Some(claim_tracker), None).await
|
||||
@@ -327,7 +335,7 @@ pub(crate) async fn list_path_raw_with_partial_result(
|
||||
opts: ListPathRawOptions,
|
||||
claim_tracker: FallbackClaimTracker,
|
||||
partial_result: PartialResultFn,
|
||||
) -> disk::error::Result<()> {
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
list_path_raw_inner(rx, opts, Some(claim_tracker), Some(partial_result)).await
|
||||
@@ -338,7 +346,7 @@ async fn list_path_raw_inner(
|
||||
opts: ListPathRawOptions,
|
||||
fallback_claim_tracker: Option<FallbackClaimTracker>,
|
||||
partial_result: Option<PartialResultFn>,
|
||||
) -> disk::error::Result<()> {
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
if opts.disks.is_empty() {
|
||||
return Err(DiskError::ErasureReadQuorum);
|
||||
}
|
||||
@@ -372,7 +380,7 @@ async fn list_path_raw_inner(
|
||||
let cancel_rx_clone = cancel_rx.clone();
|
||||
let producer_errs_clone = producer_errs.clone();
|
||||
let disk_io_context_clone = disk_io_context.clone();
|
||||
let (rd, wr) = tokio::io::duplex(64);
|
||||
let (rd, wr) = tokio::io::duplex(64 * 1024);
|
||||
readers.push(MetacacheReader::new(rd));
|
||||
jobs.spawn(with_optional_disk_io_context(disk_io_context_clone, async move {
|
||||
let mut wr = PublishedBytesWriter::new(wr);
|
||||
@@ -393,6 +401,14 @@ async fn list_path_raw_inner(
|
||||
out.close().await.expect("test entries should close");
|
||||
return Ok(());
|
||||
}
|
||||
TestReaderBehavior::LimitReached(entries) => {
|
||||
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
|
||||
out.write(&entries).await.expect("test entries should be written");
|
||||
out.close_with_limit_reached(true)
|
||||
.await
|
||||
.expect("test entries should close at the limit");
|
||||
return Ok(());
|
||||
}
|
||||
TestReaderBehavior::Stall => {
|
||||
let _held_writer = wr;
|
||||
cancel_rx_clone.cancelled().await;
|
||||
@@ -531,6 +547,16 @@ async fn list_path_raw_inner(
|
||||
last_err = None;
|
||||
continue;
|
||||
}
|
||||
TestReaderBehavior::LimitReached(entries) => {
|
||||
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
|
||||
out.write(&entries).await.expect("test fallback entries should be written");
|
||||
out.close_with_limit_reached(true)
|
||||
.await
|
||||
.expect("test fallback entries should close at the limit");
|
||||
need_fallback = false;
|
||||
last_err = None;
|
||||
continue;
|
||||
}
|
||||
TestReaderBehavior::ProducerError(err) | TestReaderBehavior::PrimaryErrorThenFallback(err) => {
|
||||
last_err = Some(err);
|
||||
continue;
|
||||
@@ -699,9 +725,13 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
let mut pending_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
|
||||
let mut peek_outcomes: Vec<Option<PeekOutcome>> = std::iter::repeat_with(|| None).take(readers.len()).collect();
|
||||
// Sticky across rounds: once any reader's trailer says its producer
|
||||
// stopped at the per-disk limit, the merged stream may hide more keys.
|
||||
let mut limit_reached = false;
|
||||
|
||||
loop {
|
||||
let mut current = MetaCacheEntry::default();
|
||||
let mut current_idx: Option<usize> = None;
|
||||
|
||||
// warn!(
|
||||
// "list_path_raw: loop start, bucket: {}, path: {}, current: {:?}",
|
||||
@@ -709,7 +739,7 @@ async fn list_path_raw_inner(
|
||||
// );
|
||||
|
||||
if revjob_rx.is_cancelled() {
|
||||
return Ok(());
|
||||
return Ok(ListPathRawOutcome { limit_reached });
|
||||
}
|
||||
|
||||
let mut top_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
|
||||
@@ -759,6 +789,7 @@ async fn list_path_raw_inner(
|
||||
continue;
|
||||
}
|
||||
// eof
|
||||
limit_reached |= r.limit_reached() == Some(true);
|
||||
at_eof += 1;
|
||||
// warn!("list_path_raw: peek eof, disk: {}", i);
|
||||
continue;
|
||||
@@ -832,7 +863,7 @@ async fn list_path_raw_inner(
|
||||
*r = MetacacheReader::new(detached_rd);
|
||||
continue;
|
||||
}
|
||||
PeekOutcome::Cancelled => return Ok(()),
|
||||
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -840,30 +871,32 @@ async fn list_path_raw_inner(
|
||||
|
||||
// If no current, add it.
|
||||
if current.name.is_empty() {
|
||||
top_entries[i] = Some(entry.clone());
|
||||
current_idx = Some(i);
|
||||
current = entry;
|
||||
agree += 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
// If exact match, we agree.
|
||||
if let (_, true) = current.matches(Some(&entry), true) {
|
||||
top_entries[i] = Some(entry);
|
||||
agree += 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
// If only the name matches we didn't agree, but add it for resolution.
|
||||
if entry.name == current.name {
|
||||
top_entries[i] = Some(entry);
|
||||
continue;
|
||||
}
|
||||
// We got different entries
|
||||
if entry.name > current.name {
|
||||
pending_entries[i] = Some(entry);
|
||||
continue;
|
||||
}
|
||||
if entry.name == current.name {
|
||||
// If exact match, we agree.
|
||||
if current.matches_strict(&entry) {
|
||||
top_entries[i] = Some(entry);
|
||||
agree += 1;
|
||||
} else {
|
||||
// If only the name matches we didn't agree, but add it for resolution.
|
||||
top_entries[i] = Some(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(idx) = current_idx.take() {
|
||||
pending_entries[idx] = Some(std::mem::take(&mut current));
|
||||
}
|
||||
for (idx, item) in top_entries.iter_mut().enumerate().take(i) {
|
||||
if let Some(entry) = item.take() {
|
||||
pending_entries[idx] = Some(entry);
|
||||
@@ -871,7 +904,7 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
|
||||
agree = 1;
|
||||
top_entries[i] = Some(entry.clone());
|
||||
current_idx = Some(i);
|
||||
current = entry;
|
||||
}
|
||||
|
||||
@@ -889,7 +922,7 @@ async fn list_path_raw_inner(
|
||||
if let Some(finished_fn) = opts.finished.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = finished_fn(&errs) => {}
|
||||
}
|
||||
}
|
||||
@@ -946,7 +979,7 @@ async fn list_path_raw_inner(
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = finished_fn(&errs) => {}
|
||||
}
|
||||
}
|
||||
@@ -966,8 +999,8 @@ async fn list_path_raw_inner(
|
||||
// warn!("list_path_raw: agreed_fn start, current: {:?}", ¤t.name);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = agreed_fn(current) => {}
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
result = agreed_fn(current) => result?
|
||||
}
|
||||
// warn!("list_path_raw: agreed_fn done");
|
||||
}
|
||||
@@ -977,6 +1010,10 @@ async fn list_path_raw_inner(
|
||||
|
||||
// warn!("list_path_raw: skip start, current: {:?}", ¤t.name);
|
||||
|
||||
if let Some(idx) = current_idx.take() {
|
||||
top_entries[idx] = Some(current);
|
||||
}
|
||||
|
||||
for (i, r) in readers.iter_mut().enumerate() {
|
||||
if top_entries[i].is_some() {
|
||||
let _ = r.skip(1).await;
|
||||
@@ -986,58 +1023,61 @@ async fn list_path_raw_inner(
|
||||
if let Some(partial_fn) = partial_result.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
result = partial_fn(MetaCacheEntries(top_entries), &errs) => result?,
|
||||
}
|
||||
} else if let Some(partial_fn) = opts.partial.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = partial_fn(MetaCacheEntries(top_entries), &errs) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(ListPathRawOutcome { limit_reached })
|
||||
});
|
||||
|
||||
let merge_started = std::time::Instant::now();
|
||||
let Some(revjob) = revjobs.join_next().await else {
|
||||
return Err(DiskError::Unexpected);
|
||||
};
|
||||
if let Err(err) = revjob.map_err(std::io::Error::other)? {
|
||||
rustfs_io_metrics::record_stage_duration("metacache_merge_failed", merge_started.elapsed().as_secs_f64() * 1000.0);
|
||||
if is_missing_path_error(&err) {
|
||||
debug!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %log_bucket,
|
||||
path = %log_path,
|
||||
state = "merge_job_missing_path",
|
||||
error = ?err,
|
||||
"Metacache merge job missing path skipped"
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %log_bucket,
|
||||
path = %log_path,
|
||||
state = "merge_job_failed",
|
||||
error = ?err,
|
||||
"Metacache merge job failed"
|
||||
);
|
||||
}
|
||||
cancel_rx.cancel();
|
||||
jobs.abort_all();
|
||||
let outcome = match revjob.map_err(std::io::Error::other)? {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
rustfs_io_metrics::record_stage_duration("metacache_merge_failed", merge_started.elapsed().as_secs_f64() * 1000.0);
|
||||
if is_missing_path_error(&err) {
|
||||
debug!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %log_bucket,
|
||||
path = %log_path,
|
||||
state = "merge_job_missing_path",
|
||||
error = ?err,
|
||||
"Metacache merge job missing path skipped"
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %log_bucket,
|
||||
path = %log_path,
|
||||
state = "merge_job_failed",
|
||||
error = ?err,
|
||||
"Metacache merge job failed"
|
||||
);
|
||||
}
|
||||
cancel_rx.cancel();
|
||||
jobs.abort_all();
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if rx.is_cancelled() {
|
||||
cancel_rx.cancel();
|
||||
jobs.abort_all();
|
||||
return Ok(());
|
||||
return Ok(outcome);
|
||||
}
|
||||
rustfs_io_metrics::record_stage_duration("metacache_merge", merge_started.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
@@ -1106,7 +1146,7 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
|
||||
// warn!("list_path_raw: done");
|
||||
Ok(())
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -1125,6 +1165,7 @@ mod tests {
|
||||
use rustfs_filemeta::MetacacheWriter;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta, MetadataResolutionParams};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -1245,6 +1286,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_reports_limit_reached_from_any_reader_trailer() {
|
||||
let first = fallback_test_entry();
|
||||
let second = MetaCacheEntry {
|
||||
name: "bucket/object-2".to_string(),
|
||||
..fallback_test_entry()
|
||||
};
|
||||
// One bounded producer, one that ends naturally, one legacy empty
|
||||
// stream without any trailer.
|
||||
let outcome = list_path_raw_inner(
|
||||
CancellationToken::new(),
|
||||
ListPathRawOptions {
|
||||
disks: vec![None, None, None],
|
||||
min_disks: 1,
|
||||
test_reader_behaviors: vec![
|
||||
TestReaderBehavior::LimitReached(vec![first.clone()]),
|
||||
TestReaderBehavior::Entries(vec![first, second]),
|
||||
TestReaderBehavior::Eof,
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("listing should complete");
|
||||
|
||||
assert!(
|
||||
outcome.limit_reached,
|
||||
"a reader whose trailer reports a limit stop must mark the merged stream as possibly incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_reports_natural_end_when_no_reader_trailer_says_limit() {
|
||||
let outcome = list_path_raw_inner(
|
||||
CancellationToken::new(),
|
||||
ListPathRawOptions {
|
||||
disks: vec![None, None],
|
||||
min_disks: 1,
|
||||
test_reader_behaviors: vec![
|
||||
TestReaderBehavior::Entries(vec![fallback_test_entry()]),
|
||||
TestReaderBehavior::Eof,
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("listing should complete");
|
||||
|
||||
assert!(!outcome.limit_reached, "natural and legacy stream ends must not claim a limit stop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_returns_typed_quorum_error_for_multiple_drive_failures() {
|
||||
let err = list_path_raw(
|
||||
@@ -1703,6 +1799,34 @@ mod tests {
|
||||
.expect("aborting the listing should drop the blocked producer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_propagates_agreed_callback_failure() {
|
||||
let entry = MetaCacheEntry {
|
||||
name: "bucket/object".to_owned(),
|
||||
metadata: vec![1],
|
||||
..Default::default()
|
||||
};
|
||||
let result = timeout(
|
||||
Duration::from_secs(1),
|
||||
list_path_raw(
|
||||
CancellationToken::new(),
|
||||
ListPathRawOptions {
|
||||
disks: vec![None],
|
||||
min_disks: 1,
|
||||
test_reader_behaviors: vec![TestReaderBehavior::Entries(vec![entry])],
|
||||
agreed: Some(Box::new(|_| Box::pin(async { Err(DiskError::ErasureReadQuorum) }))),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("callback failure must terminate the walk");
|
||||
assert_eq!(
|
||||
result.expect_err("agreed resolution must not silently omit the key"),
|
||||
DiskError::ErasureReadQuorum
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_propagates_partial_callback_failure() {
|
||||
let first = MetaCacheEntry {
|
||||
@@ -1804,6 +1928,7 @@ mod tests {
|
||||
Box::pin(async move {
|
||||
callback_started.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
@@ -1955,6 +2080,7 @@ mod tests {
|
||||
let seen = seen_clone.clone();
|
||||
Box::pin(async move {
|
||||
seen.lock().expect("seen mutex poisoned").push(entry.name);
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
@@ -1997,6 +2123,7 @@ mod tests {
|
||||
let seen = seen_clone.clone();
|
||||
Box::pin(async move {
|
||||
seen.lock().expect("seen mutex poisoned").push(entry.name);
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
@@ -2069,6 +2196,7 @@ mod tests {
|
||||
let seen = agreed_seen.clone();
|
||||
Box::pin(async move {
|
||||
seen.lock().expect("seen mutex poisoned").push(entry.name);
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
partial: Some(Box::new(move |entries, _| {
|
||||
@@ -2180,4 +2308,257 @@ mod tests {
|
||||
|
||||
assert_eq!(err, DiskError::FileAccessDenied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_demotes_current_and_agreed_peers_across_cascading_smaller_keys() {
|
||||
fn make_entry(name: &str, vid: u128, etag: &str) -> MetaCacheEntry {
|
||||
let mut meta = FileMeta::default();
|
||||
let mut fi = FileInfo::new(name, 2, 2);
|
||||
fi.version_id = Some(Uuid::from_u128(vid));
|
||||
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
|
||||
fi.metadata.insert("etag".to_string(), etag.to_string());
|
||||
meta.add_version(fi).expect("add version");
|
||||
MetaCacheEntry {
|
||||
name: name.to_string(),
|
||||
metadata: meta.marshal_msg().expect("marshal metadata"),
|
||||
cached: None,
|
||||
reusable: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Round 1: Disks 0 and 1 BOTH start at "key-d" and strictly agree, populating `current_idx = Some(0)`
|
||||
// and `top_entries[1] = Some(key-d)`. Disk 2 then yields "key-b" (< "key-d"), forcing demotion of
|
||||
// BOTH `current_idx` (`pending_entries[0]`) AND `top_entries[1]` (`pending_entries[1]`).
|
||||
// Disk 3 then yields "key-a" (< "key-b"), forcing a second cascading demotion of Disk 2 to `pending_entries[2]`.
|
||||
// Later at "key-e", all 4 disks present the same name, version_id, and mod_time, but Disk 2 has a different
|
||||
// ETag ("etag-e2" vs "etag-e1"), proving `matches_strict` routes all 4 replicas to `partial` instead of `agreed`.
|
||||
let k_a = make_entry("key-a", 1, "etag-a");
|
||||
let k_b = make_entry("key-b", 2, "etag-b");
|
||||
let k_d = make_entry("key-d", 4, "etag-d");
|
||||
let k_e_v1 = make_entry("key-e", 5, "etag-e1");
|
||||
let k_e_v2 = make_entry("key-e", 5, "etag-e2");
|
||||
|
||||
let disk0 = vec![k_d.clone(), k_e_v1.clone()];
|
||||
let disk1 = vec![k_d.clone(), k_e_v1.clone()];
|
||||
let disk2 = vec![k_b.clone(), k_d.clone(), k_e_v2];
|
||||
let disk3 = vec![k_a, k_b, k_d, k_e_v1];
|
||||
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let agreed_events = events.clone();
|
||||
let partial_events = events.clone();
|
||||
|
||||
list_path_raw(
|
||||
CancellationToken::new(),
|
||||
ListPathRawOptions {
|
||||
disks: vec![None, None, None, None],
|
||||
min_disks: 1,
|
||||
test_reader_behaviors: vec![
|
||||
TestReaderBehavior::Entries(disk0),
|
||||
TestReaderBehavior::Entries(disk1),
|
||||
TestReaderBehavior::Entries(disk2),
|
||||
TestReaderBehavior::Entries(disk3),
|
||||
],
|
||||
agreed: Some(Box::new(move |entry| {
|
||||
let agreed_events = agreed_events.clone();
|
||||
Box::pin(async move {
|
||||
agreed_events.lock().expect("events lock").push((entry.name, 4usize, true));
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
partial: Some(Box::new(move |entries, _| {
|
||||
let partial_events = partial_events.clone();
|
||||
Box::pin(async move {
|
||||
let present = entries.0.iter().flatten().count();
|
||||
let name = entries
|
||||
.0
|
||||
.iter()
|
||||
.flatten()
|
||||
.next()
|
||||
.expect("at least one entry in partial")
|
||||
.name
|
||||
.clone();
|
||||
partial_events.lock().expect("events lock").push((name, present, false));
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("cascading demotion listing should succeed");
|
||||
|
||||
assert_eq!(
|
||||
events.lock().expect("events lock").as_slice(),
|
||||
&[
|
||||
("key-a".to_string(), 1, false),
|
||||
("key-b".to_string(), 2, false),
|
||||
("key-d".to_string(), 4, true),
|
||||
("key-e".to_string(), 4, false),
|
||||
],
|
||||
"cascading demotions must preserve pending_entries from both current_idx and top_entries, and 4-disk ETag divergence must take partial"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_seeded_multi_disk_streams_match_reference_oracle() {
|
||||
type Step = (String, Vec<Option<String>>, bool);
|
||||
|
||||
fn make_entry(name: &str, variant: u64) -> MetaCacheEntry {
|
||||
if name.ends_with('/') {
|
||||
return MetaCacheEntry {
|
||||
name: name.to_string(),
|
||||
metadata: Vec::new(),
|
||||
cached: None,
|
||||
reusable: false,
|
||||
};
|
||||
}
|
||||
let mut meta = FileMeta::default();
|
||||
let mut fi = FileInfo::new(name, 2, 2);
|
||||
fi.version_id = Some(Uuid::from_u128(1));
|
||||
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
|
||||
fi.metadata.insert("etag".to_string(), format!("etag-{variant}"));
|
||||
meta.add_version(fi).expect("add version");
|
||||
MetaCacheEntry {
|
||||
name: name.to_string(),
|
||||
metadata: meta.marshal_msg().expect("marshal metadata"),
|
||||
cached: None,
|
||||
reusable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_entry(entry: &MetaCacheEntry) -> String {
|
||||
if entry.is_dir() {
|
||||
return format!("{}:0", entry.name);
|
||||
}
|
||||
let fi = entry.to_fileinfo("bucket").expect("decode test entry");
|
||||
let etag = fi.metadata.get("etag").expect("etag present");
|
||||
let v = etag.strip_prefix("etag-").expect("etag prefix");
|
||||
format!("{}:{v}", entry.name)
|
||||
}
|
||||
|
||||
let universe = ["a", "a/", "a/b", "a/b/", "a/c", "a0", "b", "b/", "b/1", "c", "d/", "e"];
|
||||
|
||||
// Deterministic LCG seed using high bits across 24 random 4-disk stream schedules.
|
||||
let mut seed = 0x5eed_cafe_1234_5678_u64;
|
||||
let mut next_rand = || {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
seed >> 33
|
||||
};
|
||||
|
||||
let mut total_agreed = 0usize;
|
||||
let mut total_partial = 0usize;
|
||||
let mut total_preserved_all_four = 0usize;
|
||||
|
||||
for round in 0..24 {
|
||||
let preserve_replica_metadata = round % 3 == 0;
|
||||
let mut disk_streams: Vec<Vec<MetaCacheEntry>> = vec![Vec::new(); 4];
|
||||
let mut expected_steps: Vec<Step> = Vec::new();
|
||||
|
||||
for &key in &universe {
|
||||
// High-bit mask in 1..=15; force mask 15 on every 4th key so 4-disk agreement/divergence is heavily exercised.
|
||||
let mask = if next_rand() % 4 == 0 {
|
||||
0b1111_u64
|
||||
} else {
|
||||
(next_rand() % 15) + 1
|
||||
};
|
||||
let mut slot_variants: Vec<Option<u64>> = Vec::with_capacity(4);
|
||||
let mut first_variant = None;
|
||||
let mut all_present_and_equal = true;
|
||||
|
||||
for (disk_idx, stream) in disk_streams.iter_mut().enumerate() {
|
||||
if (mask & (1_u64 << disk_idx)) != 0 {
|
||||
// Directory entries carry no metadata, so their variant is always 0.
|
||||
let v = if key.ends_with('/') {
|
||||
first_variant = Some(0);
|
||||
0
|
||||
} else if first_variant.is_none() || (next_rand() % 4 != 0) {
|
||||
let v = first_variant.unwrap_or_else(|| next_rand() % 3);
|
||||
first_variant = Some(v);
|
||||
v
|
||||
} else {
|
||||
all_present_and_equal = false;
|
||||
(first_variant.unwrap_or(0) + 1) % 3
|
||||
};
|
||||
stream.push(make_entry(key, v));
|
||||
slot_variants.push(Some(v));
|
||||
} else {
|
||||
all_present_and_equal = false;
|
||||
slot_variants.push(None);
|
||||
}
|
||||
}
|
||||
|
||||
let expect_agreed = all_present_and_equal && !preserve_replica_metadata;
|
||||
if expect_agreed {
|
||||
total_agreed += 1;
|
||||
} else {
|
||||
total_partial += 1;
|
||||
if all_present_and_equal && preserve_replica_metadata {
|
||||
total_preserved_all_four += 1;
|
||||
}
|
||||
}
|
||||
let expected_slots = slot_variants
|
||||
.into_iter()
|
||||
.map(|opt| opt.map(|v| format!("{key}:{v}")))
|
||||
.collect();
|
||||
expected_steps.push((key.to_string(), expected_slots, expect_agreed));
|
||||
}
|
||||
|
||||
let observed: Arc<Mutex<Vec<Step>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let agreed_obs = observed.clone();
|
||||
let partial_obs = observed.clone();
|
||||
|
||||
list_path_raw(
|
||||
CancellationToken::new(),
|
||||
ListPathRawOptions {
|
||||
disks: vec![None; 4],
|
||||
min_disks: 1,
|
||||
preserve_replica_metadata,
|
||||
test_reader_behaviors: disk_streams.into_iter().map(TestReaderBehavior::Entries).collect(),
|
||||
agreed: Some(Box::new(move |entry| {
|
||||
let agreed_obs = agreed_obs.clone();
|
||||
let desc = describe_entry(&entry);
|
||||
let name = entry.name;
|
||||
Box::pin(async move {
|
||||
agreed_obs.lock().expect("obs lock").push((name, vec![Some(desc); 4], true));
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
partial: Some(Box::new(move |entries, _| {
|
||||
let partial_obs = partial_obs.clone();
|
||||
let name = entries
|
||||
.0
|
||||
.iter()
|
||||
.flatten()
|
||||
.next()
|
||||
.expect("partial must contain at least one entry")
|
||||
.name
|
||||
.clone();
|
||||
let slots = entries
|
||||
.0
|
||||
.iter()
|
||||
.map(|opt| opt.as_ref().map(describe_entry))
|
||||
.collect::<Vec<_>>();
|
||||
Box::pin(async move {
|
||||
partial_obs.lock().expect("obs lock").push((name, slots, false));
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seeded list_path_raw must succeed");
|
||||
|
||||
assert_eq!(
|
||||
*observed.lock().expect("obs lock"),
|
||||
expected_steps,
|
||||
"round={round}, preserve_replica_metadata={preserve_replica_metadata}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(total_agreed > 0, "seeded schedule must exercise agreed fast-path");
|
||||
assert!(total_partial > 0, "seeded schedule must exercise partial path");
|
||||
assert!(
|
||||
total_preserved_all_four > 0,
|
||||
"seeded schedule must exercise preserve_replica_metadata on 4-disk agreement"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ const REPLAY_CACHE_AUTO_MEMORY_PERCENT: u64 = 13;
|
||||
const REPLAY_CACHE_AUTO_RPC_RPS_PER_CPU: usize = 4096;
|
||||
const REPLAY_CACHE_AUTO_MAX_CAPACITY: usize = 33_554_432;
|
||||
const NS_SCANNER_CAPABILITY_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-capability-v3";
|
||||
const NS_SCANNER_CHECKPOINT_COVERAGE_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-checkpoint-coverage-v1";
|
||||
const NS_SCANNER_TIER_REGISTRY_GENERATION_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-tier-registry-generation-v1";
|
||||
pub const TONIC_RPC_PREFIX: &str = "/node_service.NodeService";
|
||||
static INTERNODE_RPC_SIGNATURE_STRICT: LazyLock<bool> = LazyLock::new(|| {
|
||||
@@ -642,6 +643,7 @@ fn update_ns_scanner_capability_mac(
|
||||
challenge: Uuid,
|
||||
server_epoch: Uuid,
|
||||
supports_tier_registry_generation: bool,
|
||||
supports_checkpoint_coverage: bool,
|
||||
) {
|
||||
mac.update(NS_SCANNER_CAPABILITY_AUTH_DOMAIN);
|
||||
mac.update(&NS_SCANNER_PROTOCOL_VERSION.to_be_bytes());
|
||||
@@ -653,6 +655,9 @@ fn update_ns_scanner_capability_mac(
|
||||
// that receives generation-fenced scanner work.
|
||||
mac.update(NS_SCANNER_TIER_REGISTRY_GENERATION_AUTH_DOMAIN);
|
||||
}
|
||||
if supports_checkpoint_coverage {
|
||||
mac.update(NS_SCANNER_CHECKPOINT_COVERAGE_AUTH_DOMAIN);
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_ns_scanner_capability_proof(
|
||||
@@ -660,13 +665,20 @@ fn generate_ns_scanner_capability_proof(
|
||||
challenge: Uuid,
|
||||
server_epoch: Uuid,
|
||||
supports_tier_registry_generation: bool,
|
||||
supports_checkpoint_coverage: bool,
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if challenge.is_nil() || server_epoch.is_nil() {
|
||||
return Err(std::io::Error::other("Invalid namespace scanner capability scope"));
|
||||
}
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_ns_scanner_capability_mac(&mut mac, challenge, server_epoch, supports_tier_registry_generation);
|
||||
update_ns_scanner_capability_mac(
|
||||
&mut mac,
|
||||
challenge,
|
||||
server_epoch,
|
||||
supports_tier_registry_generation,
|
||||
supports_checkpoint_coverage,
|
||||
);
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
@@ -676,13 +688,20 @@ fn verify_ns_scanner_capability_proof(
|
||||
server_epoch: Uuid,
|
||||
proof: &[u8],
|
||||
supports_tier_registry_generation: bool,
|
||||
supports_checkpoint_coverage: bool,
|
||||
) -> std::io::Result<()> {
|
||||
if challenge.is_nil() || server_epoch.is_nil() {
|
||||
return Err(std::io::Error::other("Invalid namespace scanner capability scope"));
|
||||
}
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_ns_scanner_capability_mac(&mut mac, challenge, server_epoch, supports_tier_registry_generation);
|
||||
update_ns_scanner_capability_mac(
|
||||
&mut mac,
|
||||
challenge,
|
||||
server_epoch,
|
||||
supports_tier_registry_generation,
|
||||
supports_checkpoint_coverage,
|
||||
);
|
||||
mac.verify_slice(proof)
|
||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Invalid namespace scanner capability proof"))
|
||||
}
|
||||
@@ -700,7 +719,22 @@ pub fn sign_ns_scanner_capability_with_tier_registry_generation(
|
||||
server_epoch: Uuid,
|
||||
supports_tier_registry_generation: bool,
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
generate_ns_scanner_capability_proof(&get_shared_secret()?, challenge, server_epoch, supports_tier_registry_generation)
|
||||
sign_ns_scanner_capability_with_checkpoint_coverage(challenge, server_epoch, supports_tier_registry_generation, false)
|
||||
}
|
||||
|
||||
pub fn sign_ns_scanner_capability_with_checkpoint_coverage(
|
||||
challenge: Uuid,
|
||||
server_epoch: Uuid,
|
||||
supports_tier_registry_generation: bool,
|
||||
supports_checkpoint_coverage: bool,
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
generate_ns_scanner_capability_proof(
|
||||
&get_shared_secret()?,
|
||||
challenge,
|
||||
server_epoch,
|
||||
supports_tier_registry_generation,
|
||||
supports_checkpoint_coverage,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_ns_scanner_capability_with_tier_registry_generation(
|
||||
@@ -709,7 +743,30 @@ pub fn verify_ns_scanner_capability_with_tier_registry_generation(
|
||||
proof: &[u8],
|
||||
supports_tier_registry_generation: bool,
|
||||
) -> std::io::Result<()> {
|
||||
verify_ns_scanner_capability_proof(&get_shared_secret()?, challenge, server_epoch, proof, supports_tier_registry_generation)
|
||||
verify_ns_scanner_capability_with_checkpoint_coverage(
|
||||
challenge,
|
||||
server_epoch,
|
||||
proof,
|
||||
supports_tier_registry_generation,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_ns_scanner_capability_with_checkpoint_coverage(
|
||||
challenge: Uuid,
|
||||
server_epoch: Uuid,
|
||||
proof: &[u8],
|
||||
supports_tier_registry_generation: bool,
|
||||
supports_checkpoint_coverage: bool,
|
||||
) -> std::io::Result<()> {
|
||||
verify_ns_scanner_capability_proof(
|
||||
&get_shared_secret()?,
|
||||
challenge,
|
||||
server_epoch,
|
||||
proof,
|
||||
supports_tier_registry_generation,
|
||||
supports_checkpoint_coverage,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1776,13 +1833,30 @@ mod tests {
|
||||
let secret = "test-scanner-capability-secret";
|
||||
let challenge = Uuid::new_v4();
|
||||
let server_epoch = Uuid::new_v4();
|
||||
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false)
|
||||
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false, false)
|
||||
.expect("capability proof should be generated");
|
||||
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false).is_ok());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, false).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, Uuid::new_v4(), &proof, false).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof("different-secret", challenge, server_epoch, &proof, false).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, false).is_ok());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, false, false).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, Uuid::new_v4(), &proof, false, false).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof("different-secret", challenge, server_epoch, &proof, false, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_scanner_capability_proof_binds_checkpoint_coverage_support() {
|
||||
let secret = "checkpoint-capability-test-secret";
|
||||
let challenge = Uuid::new_v4();
|
||||
let server_epoch = Uuid::new_v4();
|
||||
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, true)
|
||||
.expect("checkpoint capability proof");
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, true).is_ok());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, false).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, true).is_err());
|
||||
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, false)
|
||||
.expect("legacy generation capability proof");
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true, true).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &[], true, true).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof, true, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1790,14 +1864,14 @@ mod tests {
|
||||
let secret = "test-scanner-capability-secret";
|
||||
let challenge = Uuid::new_v4();
|
||||
let server_epoch = Uuid::new_v4();
|
||||
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true)
|
||||
let proof = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, true, false)
|
||||
.expect("generation capability proof should be generated");
|
||||
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true).is_ok());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false).is_err());
|
||||
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false)
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, true, false).is_ok());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof, false, false).is_err());
|
||||
let legacy = generate_ns_scanner_capability_proof(secret, challenge, server_epoch, false, false)
|
||||
.expect("legacy capability proof should be generated");
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &legacy, true, false).is_err());
|
||||
}
|
||||
|
||||
/// Security regression for GHSA-r5qv-rc46-hv8q (internode RPC fail-closed,
|
||||
|
||||
@@ -13,18 +13,19 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::cluster::rpc::{
|
||||
build_auth_headers, build_put_file_auth_trailer, verify_ns_scanner_capability_with_tier_registry_generation,
|
||||
verify_put_file_capability,
|
||||
build_auth_headers, build_put_file_auth_trailer, verify_ns_scanner_capability_with_checkpoint_coverage,
|
||||
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_capability,
|
||||
};
|
||||
use crate::disk::error::{Error, Result};
|
||||
use crate::disk::{FileReader, FileWriter};
|
||||
use crate::storage_api_contracts::internode::{
|
||||
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CYCLE_QUERY, NS_SCANNER_LEADER_EPOCH_QUERY,
|
||||
NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY, NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY,
|
||||
NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY, NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY,
|
||||
NsScannerCapabilityResponse, PUT_FILE_AUTH_QUERY, 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, WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY, WALK_DIR_STREAM_COMPLETION_V1,
|
||||
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CHECKPOINT_COVERAGE_QUERY,
|
||||
NS_SCANNER_CYCLE_QUERY, NS_SCANNER_LEADER_EPOCH_QUERY, NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY,
|
||||
NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY, NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY,
|
||||
NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY, NsScannerCapabilityResponse, PUT_FILE_AUTH_QUERY, 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, WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY,
|
||||
WALK_DIR_STREAM_COMPLETION_V1,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use http::{HeaderMap, HeaderValue, Method, header::CONTENT_TYPE};
|
||||
@@ -250,6 +251,7 @@ pub struct NsScannerStreamRequest {
|
||||
pub struct NsScannerCapabilityRequest {
|
||||
pub endpoint: String,
|
||||
pub supports_tier_registry_generation: bool,
|
||||
pub supports_checkpoint_coverage: bool,
|
||||
}
|
||||
|
||||
/// Data-plane stream opener used by `RemoteDisk`.
|
||||
@@ -289,6 +291,7 @@ pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
|
||||
server_epoch,
|
||||
proof: Vec::new(),
|
||||
supports_tier_registry_generation: None,
|
||||
supports_checkpoint_coverage: None,
|
||||
})
|
||||
}
|
||||
// Interface facet nobody calls yet: every transport implements both, but no
|
||||
@@ -379,6 +382,18 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
|
||||
}
|
||||
|
||||
async fn probe_ns_scanner_capability(&self, request: NsScannerCapabilityRequest) -> Result<NsScannerCapabilityResponse> {
|
||||
let request = if request.supports_checkpoint_coverage {
|
||||
match self.probe_ns_scanner_capability_once(&request).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(error) if ns_scanner_capability_error_allows_legacy(&error) => NsScannerCapabilityRequest {
|
||||
supports_checkpoint_coverage: false,
|
||||
..request
|
||||
},
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
} else {
|
||||
request
|
||||
};
|
||||
if request.supports_tier_registry_generation {
|
||||
return match self.probe_ns_scanner_capability_once(&request).await {
|
||||
Ok(response) => Ok(response),
|
||||
@@ -390,6 +405,7 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
|
||||
let legacy_request = NsScannerCapabilityRequest {
|
||||
endpoint: request.endpoint.clone(),
|
||||
supports_tier_registry_generation: false,
|
||||
supports_checkpoint_coverage: false,
|
||||
};
|
||||
match self.probe_ns_scanner_capability_once(&legacy_request).await {
|
||||
Ok(mut response) => {
|
||||
@@ -447,18 +463,32 @@ impl TcpHttpInternodeDataTransport {
|
||||
if response.version != NS_SCANNER_PROTOCOL_VERSION || response.server_epoch.is_nil() {
|
||||
return Err(Error::other("incompatible remote namespace scanner capability response"));
|
||||
}
|
||||
if let Err(err) = verify_ns_scanner_capability_with_tier_registry_generation(
|
||||
if let Err(err) = verify_ns_scanner_capability_with_checkpoint_coverage(
|
||||
challenge,
|
||||
response.server_epoch,
|
||||
&response.proof,
|
||||
request.supports_tier_registry_generation,
|
||||
request.supports_checkpoint_coverage,
|
||||
) {
|
||||
if request.supports_checkpoint_coverage
|
||||
&& verify_ns_scanner_capability_with_tier_registry_generation(
|
||||
challenge,
|
||||
response.server_epoch,
|
||||
&response.proof,
|
||||
request.supports_tier_registry_generation,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
normalize_ns_scanner_capability_response(&mut response, request.supports_tier_registry_generation, false);
|
||||
return Ok(response);
|
||||
}
|
||||
// A permissive older peer can ignore the additive marker and
|
||||
// return a valid legacy-scope proof with HTTP 200. Accept that
|
||||
// response only after independently authenticating the legacy
|
||||
// scope; all other verification failures remain fail-closed.
|
||||
if request.supports_tier_registry_generation && ns_scanner_capability_legacy_proof_is_valid(challenge, &response) {
|
||||
response.supports_tier_registry_generation = None;
|
||||
response.supports_checkpoint_coverage = None;
|
||||
return Ok(response);
|
||||
}
|
||||
return Err(Error::other(format!("remote namespace scanner capability authentication failed: {err}")));
|
||||
@@ -468,7 +498,11 @@ impl TcpHttpInternodeDataTransport {
|
||||
// verified scope so an intermediary cannot strip or rewrite the field
|
||||
// and force a silent downgrade after a successful generation-bound
|
||||
// handshake.
|
||||
normalize_ns_scanner_capability_response(&mut response, request.supports_tier_registry_generation);
|
||||
normalize_ns_scanner_capability_response(
|
||||
&mut response,
|
||||
request.supports_tier_registry_generation,
|
||||
request.supports_checkpoint_coverage,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -798,8 +832,13 @@ fn build_walk_dir_url(request: &WalkDirStreamRequest) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_ns_scanner_capability_response(response: &mut NsScannerCapabilityResponse, requested_generation_support: bool) {
|
||||
fn normalize_ns_scanner_capability_response(
|
||||
response: &mut NsScannerCapabilityResponse,
|
||||
requested_generation_support: bool,
|
||||
requested_checkpoint_coverage: bool,
|
||||
) {
|
||||
response.supports_tier_registry_generation = requested_generation_support.then_some(true);
|
||||
response.supports_checkpoint_coverage = requested_checkpoint_coverage.then_some(true);
|
||||
}
|
||||
|
||||
fn ns_scanner_capability_legacy_proof_is_valid(challenge: Uuid, response: &NsScannerCapabilityResponse) -> bool {
|
||||
@@ -832,7 +871,7 @@ fn build_ns_scanner_url(request: &NsScannerStreamRequest) -> String {
|
||||
|
||||
fn build_ns_scanner_capability_url(request: &NsScannerCapabilityRequest, challenge: Uuid) -> String {
|
||||
format!(
|
||||
"{}{}?{}={}&{}={}{}",
|
||||
"{}{}?{}={}&{}={}{}{}",
|
||||
request.endpoint,
|
||||
NS_SCANNER_PATH,
|
||||
NS_SCANNER_PROTOCOL_VERSION_QUERY,
|
||||
@@ -843,6 +882,11 @@ fn build_ns_scanner_capability_url(request: &NsScannerCapabilityRequest, challen
|
||||
format!("&{}=true", NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if request.supports_checkpoint_coverage {
|
||||
format!("&{}=true", NS_SCANNER_CHECKPOINT_COVERAGE_QUERY)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1001,6 +1045,7 @@ mod tests {
|
||||
.probe_ns_scanner(NsScannerCapabilityRequest {
|
||||
endpoint: "http://node1:9000".to_string(),
|
||||
supports_tier_registry_generation: false,
|
||||
supports_checkpoint_coverage: false,
|
||||
})
|
||||
.await
|
||||
.expect_err("legacy transport should report namespace scanner as unsupported");
|
||||
@@ -1027,6 +1072,97 @@ mod tests {
|
||||
assert!(matches!(open_err, Error::MethodNotAllowed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_scanner_checkpoint_capability_authenticates_new_and_legacy_peers() {
|
||||
crate::runtime::sources::ensure_test_rpc_secret();
|
||||
for peer in ["current", "strict-old", "permissive-old", "invalid-proof", "missing-proof"] {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("capability listener");
|
||||
let address = listener.local_addr().expect("capability listener address");
|
||||
let server_epoch = Uuid::new_v4();
|
||||
let server = tokio::spawn(async move {
|
||||
let requests = if peer == "strict-old" { 2 } else { 1 };
|
||||
for index in 0..requests {
|
||||
let (mut socket, _) = listener.accept().await.expect("capability request");
|
||||
let mut bytes = Vec::new();
|
||||
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let mut chunk = [0; 1024];
|
||||
let read = socket.read(&mut chunk).await.expect("capability headers");
|
||||
assert!(read > 0 && bytes.len() < 16 * 1024, "bounded complete capability headers");
|
||||
bytes.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
let request = std::str::from_utf8(&bytes).expect("HTTP headers");
|
||||
let target = request.split_whitespace().nth(1).expect("HTTP request target");
|
||||
let url = reqwest::Url::parse(&format!("http://{address}{target}")).expect("capability URL");
|
||||
let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
assert_eq!(query.get(NS_SCANNER_TIER_REGISTRY_GENERATION_QUERY).map(String::as_str), Some("true"));
|
||||
assert_eq!(query.contains_key(NS_SCANNER_CHECKPOINT_COVERAGE_QUERY), index == 0);
|
||||
if peer == "strict-old" && index == 0 {
|
||||
socket
|
||||
.write_all(b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.await
|
||||
.expect("legacy parser rejection");
|
||||
continue;
|
||||
}
|
||||
let challenge = query[NS_SCANNER_CAPABILITY_CHALLENGE_QUERY].parse().expect("challenge UUID");
|
||||
let proof = match peer {
|
||||
"invalid-proof" => vec![0; 32],
|
||||
"missing-proof" => Vec::new(),
|
||||
_ => crate::cluster::rpc::sign_ns_scanner_capability_with_checkpoint_coverage(
|
||||
challenge,
|
||||
server_epoch,
|
||||
true,
|
||||
peer == "current",
|
||||
)
|
||||
.expect("server capability proof"),
|
||||
};
|
||||
let body = rmp_serde::to_vec_named(&NsScannerCapabilityResponse {
|
||||
version: NS_SCANNER_PROTOCOL_VERSION,
|
||||
server_epoch,
|
||||
proof,
|
||||
supports_tier_registry_generation: Some(true),
|
||||
// Strip a supported bit, or forge it for an older peer.
|
||||
supports_checkpoint_coverage: (peer != "current").then_some(true),
|
||||
})
|
||||
.expect("capability response");
|
||||
socket
|
||||
.write_all(
|
||||
format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", body.len()).as_bytes(),
|
||||
)
|
||||
.await
|
||||
.expect("capability response headers");
|
||||
socket.write_all(&body).await.expect("capability response body");
|
||||
}
|
||||
requests
|
||||
});
|
||||
let response = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
TcpHttpInternodeDataTransport.probe_ns_scanner_capability(NsScannerCapabilityRequest {
|
||||
endpoint: format!("http://{address}"),
|
||||
supports_tier_registry_generation: true,
|
||||
supports_checkpoint_coverage: true,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("bounded capability probe");
|
||||
if peer.ends_with("proof") {
|
||||
assert!(
|
||||
response
|
||||
.expect_err("unverified capability must fail closed")
|
||||
.to_string()
|
||||
.contains("authentication failed")
|
||||
);
|
||||
} else {
|
||||
let response = response.expect("authenticated capability");
|
||||
assert_eq!(response.server_epoch, server_epoch);
|
||||
assert_eq!(response.supports_tier_registry_generation, Some(true));
|
||||
assert_eq!(response.supports_checkpoint_coverage, (peer == "current").then_some(true));
|
||||
}
|
||||
assert_eq!(server.await.expect("capability server"), if peer == "strict-old" { 2 } else { 1 });
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn walk_dir_restores_explicit_remote_missing_error() {
|
||||
let _ = rustfs_credentials::set_global_rpc_secret("walk-dir-error-test-secret".to_string());
|
||||
@@ -1769,6 +1905,7 @@ mod tests {
|
||||
&NsScannerCapabilityRequest {
|
||||
endpoint: "http://node1:9000".to_string(),
|
||||
supports_tier_registry_generation: false,
|
||||
supports_checkpoint_coverage: false,
|
||||
},
|
||||
challenge,
|
||||
);
|
||||
@@ -1788,6 +1925,7 @@ mod tests {
|
||||
&NsScannerCapabilityRequest {
|
||||
endpoint: "http://node1:9000".to_string(),
|
||||
supports_tier_registry_generation: true,
|
||||
supports_checkpoint_coverage: false,
|
||||
},
|
||||
challenge,
|
||||
);
|
||||
@@ -1826,13 +1964,14 @@ mod tests {
|
||||
server_epoch: Uuid::new_v4(),
|
||||
proof: Vec::new(),
|
||||
supports_tier_registry_generation: None,
|
||||
supports_checkpoint_coverage: None,
|
||||
};
|
||||
|
||||
normalize_ns_scanner_capability_response(&mut response, true);
|
||||
normalize_ns_scanner_capability_response(&mut response, true, true);
|
||||
assert_eq!(response.supports_tier_registry_generation, Some(true));
|
||||
|
||||
response.supports_tier_registry_generation = Some(false);
|
||||
normalize_ns_scanner_capability_response(&mut response, false);
|
||||
normalize_ns_scanner_capability_response(&mut response, false, false);
|
||||
assert_eq!(response.supports_tier_registry_generation, None);
|
||||
}
|
||||
|
||||
@@ -1846,6 +1985,7 @@ mod tests {
|
||||
proof: crate::cluster::rpc::sign_ns_scanner_capability(challenge, Uuid::new_v4())
|
||||
.expect("placeholder proof should be generated"),
|
||||
supports_tier_registry_generation: None,
|
||||
supports_checkpoint_coverage: None,
|
||||
};
|
||||
// A proof bound to a different challenge cannot authorize the legacy
|
||||
// fallback, even though the response has the expected shape.
|
||||
|
||||
@@ -36,12 +36,13 @@ pub use http_auth::{
|
||||
TONIC_RPC_PREFIX, build_auth_headers, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, gen_signature_headers,
|
||||
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
|
||||
set_tonic_mutation_body_digest, set_tonic_rolling_canonical_body_digest, set_tonic_rolling_mutation_body_digest,
|
||||
sign_ns_scanner_capability, sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability,
|
||||
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
|
||||
verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer,
|
||||
verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
||||
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
|
||||
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
|
||||
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_ns_scanner_capability,
|
||||
verify_ns_scanner_capability_with_checkpoint_coverage, verify_ns_scanner_capability_with_tier_registry_generation,
|
||||
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
|
||||
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
|
||||
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
|
||||
|
||||
@@ -417,8 +417,10 @@ impl S3PeerSys {
|
||||
let errs = join_all(futures).await;
|
||||
|
||||
let mut pool_errs = Vec::new();
|
||||
let mut all_pools_have_participants = true;
|
||||
for pool_idx in 0..self.pools_count {
|
||||
let per_pool_errs = pool_participant_errors(&self.clients, &errs, pool_idx);
|
||||
all_pools_have_participants &= !per_pool_errs.is_empty();
|
||||
pool_errs.push(reduce_pool_write_quorum_errs(&per_pool_errs));
|
||||
}
|
||||
|
||||
@@ -451,6 +453,17 @@ impl S3PeerSys {
|
||||
for pool_idx in 0..self.pools_count {
|
||||
let per_pool_errs = pool_participant_errors(&self.clients, &errs, pool_idx);
|
||||
if let Some(pool_err) = reduce_pool_write_quorum_errs(&per_pool_errs) {
|
||||
// A success/busy split can miss quorum without a majority error.
|
||||
// Keep that refusal retryable without masking other peer failures.
|
||||
let pool_err = if pool_err == Error::ErasureWriteQuorum
|
||||
&& all_pools_have_participants
|
||||
&& per_pool_errs.iter().any(|err| err.as_ref() == Some(&Error::TooManyOpenFiles))
|
||||
&& errs.iter().flatten().all(|err| *err == Error::TooManyOpenFiles)
|
||||
{
|
||||
Error::TooManyOpenFiles
|
||||
} else {
|
||||
pool_err
|
||||
};
|
||||
tracing::error!("heal_bucket per_pool_errs: {per_pool_errs:?}");
|
||||
tracing::error!("heal_bucket reduce_write_quorum_errs: {pool_err}");
|
||||
return Err(pool_err);
|
||||
@@ -1810,6 +1823,7 @@ mod tests {
|
||||
#[derive(Debug)]
|
||||
struct TestPeerS3Client {
|
||||
pools: Option<Vec<usize>>,
|
||||
heal_bucket_result: Result<HealResultItem>,
|
||||
make_bucket_result: Result<()>,
|
||||
list_bucket_result: Result<Vec<BucketInfo>>,
|
||||
delete_bucket_result: Result<()>,
|
||||
@@ -1819,7 +1833,7 @@ mod tests {
|
||||
#[async_trait]
|
||||
impl PeerS3Client for TestPeerS3Client {
|
||||
async fn heal_bucket(&self, _bucket: &str, _opts: &HealOpts) -> Result<HealResultItem> {
|
||||
unreachable!("not used by quorum tests")
|
||||
self.heal_bucket_result.clone()
|
||||
}
|
||||
|
||||
async fn make_bucket(&self, _bucket: &str, _opts: &MakeBucketOptions) -> Result<()> {
|
||||
@@ -1835,8 +1849,11 @@ mod tests {
|
||||
self.delete_bucket_result.clone()
|
||||
}
|
||||
|
||||
async fn get_bucket_info(&self, _bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
|
||||
unreachable!("not used by quorum tests")
|
||||
async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
|
||||
Ok(BucketInfo {
|
||||
name: bucket.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn get_pools(&self) -> Option<Vec<usize>> {
|
||||
@@ -1848,6 +1865,17 @@ mod tests {
|
||||
test_peer_with_make_bucket(pools, Ok(()))
|
||||
}
|
||||
|
||||
fn test_peer_with_heal_bucket(pools: &[usize], heal_bucket_result: Result<HealResultItem>) -> Client {
|
||||
Arc::new(Box::new(TestPeerS3Client {
|
||||
pools: Some(pools.to_vec()),
|
||||
heal_bucket_result,
|
||||
make_bucket_result: Ok(()),
|
||||
list_bucket_result: Ok(Vec::new()),
|
||||
delete_bucket_result: Ok(()),
|
||||
make_bucket_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}))
|
||||
}
|
||||
|
||||
fn test_peer_with_make_bucket(pools: &[usize], make_bucket_result: Result<()>) -> Client {
|
||||
test_peer_with_results(pools, make_bucket_result, Ok(Vec::new()))
|
||||
}
|
||||
@@ -1885,6 +1913,7 @@ mod tests {
|
||||
) -> Client {
|
||||
Arc::new(Box::new(TestPeerS3Client {
|
||||
pools: Some(pools.to_vec()),
|
||||
heal_bucket_result: Ok(HealResultItem::default()),
|
||||
make_bucket_result,
|
||||
list_bucket_result,
|
||||
delete_bucket_result,
|
||||
@@ -2634,6 +2663,119 @@ mod tests {
|
||||
assert_eq!(err, Error::VolumeExists);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_heal_bucket_preserves_busy_refusal_below_error_quorum() {
|
||||
for busy_count in 2..=4 {
|
||||
let peer_sys = S3PeerSys {
|
||||
clients: (0..4)
|
||||
.map(|peer| {
|
||||
test_peer_with_heal_bucket(
|
||||
&[0],
|
||||
if peer < busy_count {
|
||||
Err(Error::TooManyOpenFiles)
|
||||
} else {
|
||||
Ok(HealResultItem::default())
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
pools_count: 1,
|
||||
};
|
||||
|
||||
let error = peer_sys
|
||||
.heal_bucket("bucket", &HealOpts::default())
|
||||
.await
|
||||
.expect_err("busy peers must not turn a failed heal into success");
|
||||
assert_eq!(error, Error::TooManyOpenFiles, "busy peers: {busy_count}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_heal_bucket_busy_peer_preserves_success_quorum() {
|
||||
let peer_sys = S3PeerSys {
|
||||
clients: vec![
|
||||
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
|
||||
test_peer(&[0]),
|
||||
test_peer(&[0]),
|
||||
test_peer(&[0]),
|
||||
],
|
||||
pools_count: 1,
|
||||
};
|
||||
|
||||
peer_sys
|
||||
.heal_bucket("bucket", &HealOpts::default())
|
||||
.await
|
||||
.expect("three successful peers still satisfy the four-peer write quorum");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_heal_bucket_busy_peers_do_not_hide_other_failures() {
|
||||
for failure in [Error::FaultyDisk, Error::VolumeNotFound, Error::ErasureWriteQuorum] {
|
||||
let peer_sys = S3PeerSys {
|
||||
clients: vec![
|
||||
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
|
||||
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
|
||||
test_peer_with_heal_bucket(&[0], Err(failure.clone())),
|
||||
test_peer(&[0]),
|
||||
],
|
||||
pools_count: 1,
|
||||
};
|
||||
|
||||
let error = peer_sys
|
||||
.heal_bucket("bucket", &HealOpts::default())
|
||||
.await
|
||||
.expect_err("a mixed failure must still fail write quorum");
|
||||
assert_eq!(error, Error::ErasureWriteQuorum, "non-busy failure: {failure}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_heal_bucket_busy_pool_does_not_hide_another_pool_failure() {
|
||||
let peer_sys = S3PeerSys {
|
||||
clients: vec![
|
||||
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
|
||||
test_peer_with_heal_bucket(&[0], Err(Error::TooManyOpenFiles)),
|
||||
test_peer(&[0]),
|
||||
test_peer(&[0]),
|
||||
test_peer_with_heal_bucket(&[1], Err(Error::FaultyDisk)),
|
||||
],
|
||||
pools_count: 2,
|
||||
};
|
||||
|
||||
let error = peer_sys
|
||||
.heal_bucket("bucket", &HealOpts::default())
|
||||
.await
|
||||
.expect_err("the busy split must not hide a non-busy failure from another pool");
|
||||
assert_eq!(error, Error::ErasureWriteQuorum);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_heal_bucket_missing_pool_participants_remains_quorum_failure() {
|
||||
for occupied_pool in 0..2 {
|
||||
let peer_sys = S3PeerSys {
|
||||
clients: vec![
|
||||
test_peer_with_heal_bucket(&[occupied_pool], Err(Error::TooManyOpenFiles)),
|
||||
test_peer_with_heal_bucket(&[occupied_pool], Err(Error::TooManyOpenFiles)),
|
||||
test_peer(&[occupied_pool]),
|
||||
test_peer(&[occupied_pool]),
|
||||
],
|
||||
pools_count: 2,
|
||||
};
|
||||
|
||||
let error = peer_sys
|
||||
.heal_bucket(
|
||||
"bucket",
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a pool without participants cannot be treated as a busy gate");
|
||||
assert_eq!(error, Error::ErasureWriteQuorum, "pool with participants: {occupied_pool}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_bucket_mode_fails_closed_on_incomplete_topology() {
|
||||
let mut opts = HealOpts::default();
|
||||
|
||||
@@ -54,6 +54,7 @@ use rustfs_io_metrics::internode_metrics::{
|
||||
INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, INTERNODE_STAGE_READ_VERSION_REQUEST_ENCODE,
|
||||
INTERNODE_STAGE_READ_VERSION_RESPONSE_DECODE, INTERNODE_STAGE_READ_VERSION_RPC_ROUNDTRIP,
|
||||
};
|
||||
use rustfs_io_metrics::walk_metrics::{RemoteWalkFailureReason, RemoteWalkGuard};
|
||||
use rustfs_protos::ChannelClass;
|
||||
use rustfs_protos::evict_failed_connection;
|
||||
use rustfs_protos::proto_gen::node_service::RenamePartRequest;
|
||||
@@ -306,23 +307,76 @@ fn record_batch_read_version_gate_decision(mode: BatchMetadataRpcMode, decision:
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
async fn copy_stream_with_buffer<R, W>(reader: &mut R, writer: &mut W, buffer_size: usize) -> io::Result<u64>
|
||||
struct ObservedWalkWriter<'a, W> {
|
||||
inner: &'a mut W,
|
||||
observation: &'a mut RemoteWalkGuard,
|
||||
}
|
||||
|
||||
impl<W: AsyncWrite + Unpin> AsyncWrite for ObservedWalkWriter<'_, W> {
|
||||
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
||||
match Pin::new(&mut *self.inner).poll_write(cx, buf) {
|
||||
Poll::Ready(Ok(written)) => {
|
||||
self.observation.written(written);
|
||||
Poll::Ready(Ok(written))
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut *self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut *self.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WalkCopyError {
|
||||
reason: RemoteWalkFailureReason,
|
||||
source: io::Error,
|
||||
}
|
||||
|
||||
async fn copy_stream_with_buffer<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
buffer_size: usize,
|
||||
observation: &mut RemoteWalkGuard,
|
||||
) -> std::result::Result<u64, WalkCopyError>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut copied = 0_u64;
|
||||
let mut buffer = vec![0_u8; buffer_size];
|
||||
let _scratch = observation.scratch_allocated(buffer.capacity());
|
||||
|
||||
loop {
|
||||
let bytes_read = reader.read(&mut buffer).await?;
|
||||
let bytes_read = reader.read(&mut buffer).await.map_err(|source| WalkCopyError {
|
||||
reason: RemoteWalkFailureReason::Read,
|
||||
source,
|
||||
})?;
|
||||
observation.read(bytes_read);
|
||||
if bytes_read == 0 {
|
||||
writer.flush().await?;
|
||||
writer.flush().await.map_err(|source| WalkCopyError {
|
||||
reason: RemoteWalkFailureReason::Flush,
|
||||
source,
|
||||
})?;
|
||||
return Ok(copied);
|
||||
}
|
||||
|
||||
writer.write_all(&buffer[..bytes_read]).await?;
|
||||
copied += bytes_read as u64;
|
||||
ObservedWalkWriter {
|
||||
inner: writer,
|
||||
observation,
|
||||
}
|
||||
.write_all(&buffer[..bytes_read])
|
||||
.await
|
||||
.map_err(|source| WalkCopyError {
|
||||
reason: RemoteWalkFailureReason::Write,
|
||||
source,
|
||||
})?;
|
||||
copied += u64::try_from(bytes_read).unwrap_or(u64::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1089,12 +1143,19 @@ impl RemoteDisk {
|
||||
let probe = self.data_transport.probe_ns_scanner_capability(NsScannerCapabilityRequest {
|
||||
endpoint: self.endpoint.grid_host(),
|
||||
supports_tier_registry_generation: true,
|
||||
supports_checkpoint_coverage: true,
|
||||
});
|
||||
let result = timeout(NS_SCANNER_CAPABILITY_PROBE_TIMEOUT, probe)
|
||||
.await
|
||||
.map_err(|_| DiskError::other("remote namespace scanner capability probe timed out"))?;
|
||||
match result {
|
||||
Ok(response) if response.supports_tier_registry_generation == Some(true) => Ok(Some(response.server_epoch)),
|
||||
// RUSTFS_COMPAT_TODO(ns-scanner-checkpoint-coverage): old strict readers cannot accept the coverage field. Remove after every supported peer authenticates both scanner capabilities.
|
||||
Ok(response)
|
||||
if response.supports_tier_registry_generation == Some(true)
|
||||
&& response.supports_checkpoint_coverage == Some(true) =>
|
||||
{
|
||||
Ok(Some(response.server_epoch))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
// RUSTFS_COMPAT_TODO(ns-scanner-rpc-v3): old peers and legacy transports lack the authenticated startup-epoch handshake. Remove after every supported peer implements namespace scanner protocol v3.
|
||||
Err(DiskError::MethodNotAllowed) => Ok(None),
|
||||
@@ -3599,8 +3660,15 @@ impl DiskAPI for RemoteDisk {
|
||||
"Remote disk RPC started"
|
||||
);
|
||||
|
||||
let mut observation = RemoteWalkGuard::start();
|
||||
let disk = self.disk_ref().await;
|
||||
let body = serde_json::to_vec(&opts)?;
|
||||
let body = match serde_json::to_vec(&opts) {
|
||||
Ok(body) => body,
|
||||
Err(err) => {
|
||||
observation.finish_error(RemoteWalkFailureReason::Encoding);
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
let stall_timeout = opts.stall_timeout_duration().unwrap_or_else(get_drive_walkdir_stall_timeout);
|
||||
let bucket = opts.bucket.clone();
|
||||
let base_dir = opts.base_dir.clone();
|
||||
@@ -3611,56 +3679,71 @@ impl DiskAPI for RemoteDisk {
|
||||
opts.timeout_duration().unwrap_or_else(get_drive_walkdir_timeout)
|
||||
};
|
||||
|
||||
self.execute_with_timeout_for_op_and_health_action(
|
||||
"walk_dir",
|
||||
|| async {
|
||||
let mut last_err = None;
|
||||
let mut failure_reason = None;
|
||||
let result = self
|
||||
.execute_with_timeout_for_op_and_health_action(
|
||||
"walk_dir",
|
||||
|| async {
|
||||
let mut last_err = None;
|
||||
|
||||
for attempt in 1..=2 {
|
||||
let mut reader = match self
|
||||
.data_transport
|
||||
.open_walk_dir(WalkDirStreamRequest {
|
||||
endpoint: self.endpoint.grid_host(),
|
||||
disk: disk.clone(),
|
||||
body: body.clone(),
|
||||
stall_timeout: Some(stall_timeout),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(reader) => reader,
|
||||
Err(err) => {
|
||||
if attempt == 1 && Self::is_retryable_walk_dir_error(&err) {
|
||||
warn!(
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
disk = %disk_for_log,
|
||||
bucket = %bucket,
|
||||
base_dir = %base_dir,
|
||||
attempt,
|
||||
stall_timeout_ms = stall_timeout.as_millis(),
|
||||
error = %err,
|
||||
"remote walk_dir returned retryable transport error; retrying"
|
||||
);
|
||||
last_err = Some(err);
|
||||
continue;
|
||||
for attempt in 1..=2 {
|
||||
let mut reader = match self
|
||||
.data_transport
|
||||
.open_walk_dir(WalkDirStreamRequest {
|
||||
endpoint: self.endpoint.grid_host(),
|
||||
disk: disk.clone(),
|
||||
body: body.clone(),
|
||||
stall_timeout: Some(stall_timeout),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(reader) => reader,
|
||||
Err(err) => {
|
||||
if attempt == 1 && Self::is_retryable_walk_dir_error(&err) {
|
||||
warn!(
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
disk = %disk_for_log,
|
||||
bucket = %bucket,
|
||||
base_dir = %base_dir,
|
||||
attempt,
|
||||
stall_timeout_ms = stall_timeout.as_millis(),
|
||||
error = %err,
|
||||
"remote walk_dir returned retryable transport error; retrying"
|
||||
);
|
||||
last_err = Some(err);
|
||||
continue;
|
||||
}
|
||||
|
||||
failure_reason = Some(RemoteWalkFailureReason::Open);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
return Err(err);
|
||||
match copy_stream_with_buffer(&mut reader, wr, DEFAULT_READ_BUFFER_SIZE, &mut observation).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) => {
|
||||
failure_reason = Some(error.reason);
|
||||
return Err(DiskError::Io(error.source));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match copy_stream_with_buffer(&mut reader, wr, DEFAULT_READ_BUFFER_SIZE).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(io_err) => return Err(DiskError::Io(io_err)),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| DiskError::other("walk_dir retry exhausted without captured error")))
|
||||
},
|
||||
timeout_duration,
|
||||
FailureHealthAction::IgnoreFailure,
|
||||
)
|
||||
.await
|
||||
Err(last_err.unwrap_or_else(|| DiskError::other("walk_dir retry exhausted without captured error")))
|
||||
},
|
||||
timeout_duration,
|
||||
FailureHealthAction::IgnoreFailure,
|
||||
)
|
||||
.await;
|
||||
match &result {
|
||||
Ok(()) => observation.finish(true),
|
||||
Err(error) => observation.finish_error(failure_reason.unwrap_or(match error {
|
||||
DiskError::Timeout => RemoteWalkFailureReason::OperationTimeout,
|
||||
DiskError::FaultyDisk => RemoteWalkFailureReason::FaultyAdmission,
|
||||
_ => RemoteWalkFailureReason::Other,
|
||||
})),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -4932,6 +5015,7 @@ mod tests {
|
||||
calls: Arc<StdMutex<Vec<RecordedTransportCall>>>,
|
||||
ns_scanner_probe_status: Arc<StdMutex<Option<u16>>>,
|
||||
ns_scanner_generation_support: Arc<StdMutex<Option<bool>>>,
|
||||
ns_scanner_checkpoint_support: Arc<StdMutex<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Default for RecordingInternodeDataTransport {
|
||||
@@ -4940,6 +5024,7 @@ mod tests {
|
||||
calls: Arc::default(),
|
||||
ns_scanner_probe_status: Arc::default(),
|
||||
ns_scanner_generation_support: Arc::new(StdMutex::new(Some(true))),
|
||||
ns_scanner_checkpoint_support: Arc::new(StdMutex::new(Some(true))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5190,6 +5275,7 @@ mod tests {
|
||||
calls: Arc::default(),
|
||||
ns_scanner_probe_status: Arc::new(StdMutex::new(Some(status))),
|
||||
ns_scanner_generation_support: Arc::new(StdMutex::new(Some(true))),
|
||||
ns_scanner_checkpoint_support: Arc::new(StdMutex::new(Some(true))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5198,6 +5284,7 @@ mod tests {
|
||||
calls: Arc::default(),
|
||||
ns_scanner_probe_status: Arc::default(),
|
||||
ns_scanner_generation_support: Arc::new(StdMutex::new(support)),
|
||||
ns_scanner_checkpoint_support: Arc::new(StdMutex::new(Some(true))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5898,6 +5985,7 @@ mod tests {
|
||||
server_epoch,
|
||||
proof: Vec::new(),
|
||||
supports_tier_registry_generation,
|
||||
supports_checkpoint_coverage: *self.ns_scanner_checkpoint_support.lock().expect("checkpoint capability lock"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8128,7 +8216,7 @@ mod tests {
|
||||
|
||||
let copy_task = tokio::spawn(async move {
|
||||
let mut cursor = Cursor::new(payload);
|
||||
copy_stream_with_buffer(&mut cursor, &mut write_half, 4 * 1024)
|
||||
copy_stream_with_buffer(&mut cursor, &mut write_half, 4 * 1024, &mut RemoteWalkGuard::default())
|
||||
.await
|
||||
.expect("operation should succeed");
|
||||
});
|
||||
@@ -8140,6 +8228,459 @@ mod tests {
|
||||
assert_eq!(copied, expected);
|
||||
}
|
||||
|
||||
struct WalkTestWriter {
|
||||
bytes: Vec<u8>,
|
||||
accept_limit: usize,
|
||||
zero_write: bool,
|
||||
fail_flush: bool,
|
||||
flushed: bool,
|
||||
write_error: Option<io::Error>,
|
||||
flush_error: Option<io::Error>,
|
||||
}
|
||||
|
||||
impl WalkTestWriter {
|
||||
fn new(accept_limit: usize) -> Self {
|
||||
Self {
|
||||
bytes: Vec::new(),
|
||||
accept_limit,
|
||||
zero_write: false,
|
||||
fail_flush: false,
|
||||
flushed: false,
|
||||
write_error: None,
|
||||
flush_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for WalkTestWriter {
|
||||
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
||||
if self.zero_write {
|
||||
return Poll::Ready(Ok(0));
|
||||
}
|
||||
let remaining = self.accept_limit.saturating_sub(self.bytes.len());
|
||||
if remaining == 0 {
|
||||
return Poll::Ready(Err(self
|
||||
.write_error
|
||||
.take()
|
||||
.unwrap_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "injected downstream failure"))));
|
||||
}
|
||||
let written = remaining.min(buf.len()).min(7);
|
||||
self.bytes.extend_from_slice(&buf[..written]);
|
||||
Poll::Ready(Ok(written))
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
self.flushed = true;
|
||||
if let Some(error) = self.flush_error.take() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
Poll::Ready(if self.fail_flush {
|
||||
Err(io::Error::other("injected flush failure"))
|
||||
} else {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
self.poll_flush(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn walk_stream_preserves_boundaries_and_short_writes() {
|
||||
for size in [
|
||||
0,
|
||||
1,
|
||||
DEFAULT_READ_BUFFER_SIZE - 1,
|
||||
DEFAULT_READ_BUFFER_SIZE,
|
||||
DEFAULT_READ_BUFFER_SIZE + 1,
|
||||
4 * 1024 * 1024,
|
||||
] {
|
||||
let payload: Vec<u8> = (0..=250).cycle().take(size).collect();
|
||||
let mut reader = Cursor::new(&payload);
|
||||
let mut writer = WalkTestWriter::new(usize::MAX);
|
||||
let copied =
|
||||
copy_stream_with_buffer(&mut reader, &mut writer, DEFAULT_READ_BUFFER_SIZE, &mut RemoteWalkGuard::default())
|
||||
.await
|
||||
.expect("walk boundary copy");
|
||||
assert_eq!(copied, u64::try_from(size).expect("test size fits"));
|
||||
assert_eq!(writer.bytes, payload);
|
||||
assert!(writer.flushed, "EOF must flush even an empty walk");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn walk_stream_propagates_write_zero_and_flush_failures() {
|
||||
for (zero_write, fail_flush, expected) in [(true, false, io::ErrorKind::WriteZero), (false, true, io::ErrorKind::Other)] {
|
||||
let mut reader = Cursor::new(b"walk payload");
|
||||
let mut writer = WalkTestWriter::new(usize::MAX);
|
||||
writer.zero_write = zero_write;
|
||||
writer.fail_flush = fail_flush;
|
||||
let error =
|
||||
copy_stream_with_buffer(&mut reader, &mut writer, DEFAULT_READ_BUFFER_SIZE, &mut RemoteWalkGuard::default())
|
||||
.await
|
||||
.expect_err("injected write/flush failure must propagate");
|
||||
assert_eq!(error.source.kind(), expected);
|
||||
assert!(matches!(
|
||||
(error.reason, fail_flush),
|
||||
(RemoteWalkFailureReason::Flush, true) | (RemoteWalkFailureReason::Write, false)
|
||||
));
|
||||
if fail_flush {
|
||||
assert_eq!(writer.bytes, b"walk payload");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn walk_stream_backpressure_limits_read_ahead_and_is_cancellable() {
|
||||
let mut reader = Cursor::new(vec![5; 4 * DEFAULT_READ_BUFFER_SIZE]);
|
||||
let (mut writer, mut consumer) = duplex(64);
|
||||
{
|
||||
let mut observation = RemoteWalkGuard::default();
|
||||
let copy = copy_stream_with_buffer(&mut reader, &mut writer, DEFAULT_READ_BUFFER_SIZE, &mut observation);
|
||||
tokio::pin!(copy);
|
||||
assert!(futures::poll!(&mut copy).is_pending(), "a stalled consumer must backpressure the walk");
|
||||
}
|
||||
assert_eq!(reader.position(), u64::try_from(DEFAULT_READ_BUFFER_SIZE).expect("buffer fits"));
|
||||
drop(writer);
|
||||
let mut published = Vec::new();
|
||||
consumer
|
||||
.read_to_end(&mut published)
|
||||
.await
|
||||
.expect("read published prefix after cancellation");
|
||||
assert_eq!(published, vec![5; 64], "cancellation must not publish additional buffered bytes");
|
||||
}
|
||||
|
||||
struct WalkMetricsTestGate(bool);
|
||||
|
||||
impl WalkMetricsTestGate {
|
||||
fn enable() -> Self {
|
||||
let previous = rustfs_io_metrics::metrics_enabled();
|
||||
rustfs_io_metrics::set_metrics_enabled(true);
|
||||
Self(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WalkMetricsTestGate {
|
||||
fn drop(&mut self) {
|
||||
rustfs_io_metrics::set_metrics_enabled(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_walk_failure_reason(recorder: &crate::test_metrics::CapturingRecorder, expected: Option<&str>) {
|
||||
for reason in [
|
||||
"encoding",
|
||||
"open",
|
||||
"read",
|
||||
"write",
|
||||
"flush",
|
||||
"operation_timeout",
|
||||
"faulty_admission",
|
||||
"other",
|
||||
"abandoned",
|
||||
] {
|
||||
assert_eq!(
|
||||
recorder.counter_value("rustfs_remote_walk_failures_total", &[("reason", reason)]),
|
||||
u64::from(expected == Some(reason)),
|
||||
"one logical terminal reason: {reason}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WalkFailureSource(u8);
|
||||
|
||||
impl std::fmt::Display for WalkFailureSource {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(formatter, "original walk source {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WalkFailureSource {}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn walk_terminal_reasons_distinguish_identical_io_kinds_and_retain_error_sources() {
|
||||
let _gate = WalkMetricsTestGate::enable();
|
||||
for reason in ["read", "write", "flush"] {
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("walk failure runtime");
|
||||
runtime.block_on(async {
|
||||
let source: Box<dyn std::error::Error + Send + Sync> = Box::new(WalkFailureSource(7));
|
||||
let original_source = std::ptr::from_ref(source.as_ref());
|
||||
let error = io::Error::new(io::ErrorKind::BrokenPipe, source);
|
||||
let payload = b"walk payload".to_vec();
|
||||
let mut writer = WalkTestWriter::new(usize::MAX);
|
||||
let step = match reason {
|
||||
"read" => WalkDirTestStep::PartialDataThenError { data: payload, error },
|
||||
"write" => {
|
||||
writer.accept_limit = 9;
|
||||
writer.write_error = Some(error);
|
||||
WalkDirTestStep::Data(payload)
|
||||
}
|
||||
"flush" => {
|
||||
writer.flush_error = Some(error);
|
||||
WalkDirTestStep::Data(payload)
|
||||
}
|
||||
_ => unreachable!("the fixture only covers three copy boundaries"),
|
||||
};
|
||||
let transport = RetryingWalkDirInternodeDataTransport::with_steps(vec![step]);
|
||||
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
|
||||
let error = disk
|
||||
.walk_dir(WalkDirOptions::default(), &mut writer)
|
||||
.await
|
||||
.expect_err("the actual walk must retain the injected failure");
|
||||
let DiskError::Io(error) = error else {
|
||||
panic!("copy failures must keep their existing DiskError::Io shape");
|
||||
};
|
||||
assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
|
||||
assert_eq!(error.to_string(), "original walk source 7");
|
||||
let returned_source = error.get_ref().expect("original typed failure source");
|
||||
assert!(returned_source.downcast_ref::<WalkFailureSource>().is_some());
|
||||
assert!(
|
||||
std::ptr::addr_eq(std::ptr::from_ref(returned_source), original_source),
|
||||
"observation must move the original boxed source rather than wrap or clone it",
|
||||
);
|
||||
let expected = if reason == "write" {
|
||||
&b"walk payl"[..]
|
||||
} else {
|
||||
&b"walk payload"[..]
|
||||
};
|
||||
assert_eq!(writer.bytes, expected);
|
||||
assert_eq!(writer.flushed, reason == "flush");
|
||||
assert_eq!(transport.calls().len(), 1, "a copy failure must never retry an already-used writer");
|
||||
assert_eq!(disk.health.waiting_count(), 0);
|
||||
assert!(!disk.health.is_faulty(), "walk output errors retain IgnoreFailure health policy");
|
||||
});
|
||||
});
|
||||
assert_walk_failure_reason(&recorder, Some(reason));
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "error")]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 12);
|
||||
assert_eq!(
|
||||
recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]),
|
||||
if reason == "write" { 9 } else { 12 },
|
||||
);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocated_bytes_total", &[]), 1_048_576);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn walk_terminal_reasons_keep_final_open_errors_separate_from_wrapper_admission_and_deadline() {
|
||||
let _gate = WalkMetricsTestGate::enable();
|
||||
for (steps, expected, calls) in [
|
||||
(vec![WalkDirTestStep::Error(DiskError::FileNotFound)], DiskError::FileNotFound, 1),
|
||||
(
|
||||
vec![
|
||||
WalkDirTestStep::Error(DiskError::Timeout),
|
||||
WalkDirTestStep::Error(DiskError::Timeout),
|
||||
],
|
||||
DiskError::Timeout,
|
||||
2,
|
||||
),
|
||||
(vec![WalkDirTestStep::Error(DiskError::FaultyDisk)], DiskError::FaultyDisk, 1),
|
||||
] {
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("open failure runtime");
|
||||
runtime.block_on(async {
|
||||
let transport = RetryingWalkDirInternodeDataTransport::with_steps(steps);
|
||||
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
|
||||
let mut writer = Vec::new();
|
||||
let actual = disk
|
||||
.walk_dir(WalkDirOptions::default(), &mut writer)
|
||||
.await
|
||||
.expect_err("the final open failure must propagate");
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(transport.calls().len(), calls);
|
||||
assert!(writer.is_empty());
|
||||
assert_eq!(disk.health.waiting_count(), 0);
|
||||
});
|
||||
});
|
||||
assert_walk_failure_reason(&recorder, Some("open"));
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "error")]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocations_total", &[]), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn walk_terminal_reasons_record_faulty_admission_without_opening_a_stream() {
|
||||
let _gate = WalkMetricsTestGate::enable();
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("faulty admission runtime");
|
||||
runtime.block_on(async {
|
||||
let transport = RetryingWalkDirInternodeDataTransport::with_steps(Vec::new());
|
||||
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
|
||||
disk.health.mark_offline(&disk.endpoint, "walk-admission-fixture");
|
||||
let mut writer = Vec::new();
|
||||
assert!(matches!(
|
||||
disk.walk_dir(WalkDirOptions::default(), &mut writer).await,
|
||||
Err(DiskError::FaultyDisk)
|
||||
));
|
||||
assert!(transport.calls().is_empty());
|
||||
assert!(writer.is_empty());
|
||||
assert_eq!(disk.health.waiting_count(), 0);
|
||||
});
|
||||
});
|
||||
assert_walk_failure_reason(&recorder, Some("faulty_admission"));
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "error")]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocations_total", &[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn walk_stream_counts_partial_output_before_failure() {
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
let _gate = WalkMetricsTestGate::enable();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread().build().expect("test runtime");
|
||||
runtime.block_on(async {
|
||||
let mut observation = RemoteWalkGuard::start();
|
||||
let mut reader = Cursor::new(b"walk payload");
|
||||
let mut writer = WalkTestWriter::new(9);
|
||||
let result = copy_stream_with_buffer(&mut reader, &mut writer, DEFAULT_READ_BUFFER_SIZE, &mut observation).await;
|
||||
observation.finish(result.is_ok());
|
||||
assert_eq!(result.expect_err("partial downstream failure").source.kind(), io::ErrorKind::BrokenPipe);
|
||||
assert_eq!(writer.bytes, b"walk payl");
|
||||
});
|
||||
});
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 12);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]), 9);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "error")]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocated_bytes_total", &[]), 1_048_576);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn walk_stream_open_retry_is_one_logical_observation() {
|
||||
let _gate = WalkMetricsTestGate::enable();
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
runtime.block_on(async {
|
||||
let transport = RetryingWalkDirInternodeDataTransport::with_steps(vec![
|
||||
WalkDirTestStep::Error(DiskError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "retryable open"))),
|
||||
WalkDirTestStep::Data(b"walk payload".to_vec()),
|
||||
]);
|
||||
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
|
||||
let mut output = Vec::new();
|
||||
disk.walk_dir(WalkDirOptions::default(), &mut output)
|
||||
.await
|
||||
.expect("retried walk must complete");
|
||||
assert_eq!(output, b"walk payload");
|
||||
assert_eq!(transport.calls().len(), 2);
|
||||
});
|
||||
});
|
||||
assert_walk_failure_reason(&recorder, None);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_started_total", &[]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "ok")]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 12);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]), 12);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocations_total", &[]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocated_bytes_total", &[]), 1_048_576);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn walk_stream_counts_partial_input_before_reader_failure() {
|
||||
let _gate = WalkMetricsTestGate::enable();
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("test runtime");
|
||||
runtime.block_on(async {
|
||||
let transport = RetryingWalkDirInternodeDataTransport::with_steps(vec![WalkDirTestStep::PartialDataThenError {
|
||||
data: b"walk payload".to_vec(),
|
||||
error: io::Error::new(io::ErrorKind::ConnectionReset, "partial input failure"),
|
||||
}]);
|
||||
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
|
||||
let mut output = Vec::new();
|
||||
let error = disk
|
||||
.walk_dir(WalkDirOptions::default(), &mut output)
|
||||
.await
|
||||
.expect_err("partial input must fail");
|
||||
assert!(matches!(error, DiskError::Io(error) if error.kind() == io::ErrorKind::ConnectionReset));
|
||||
assert_eq!(output, b"walk payload");
|
||||
assert_eq!(transport.calls().len(), 1);
|
||||
});
|
||||
});
|
||||
assert_walk_failure_reason(&recorder, Some("read"));
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 12);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]), 12);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "error")]), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn walk_stream_remote_cancellation_and_timeout_release_tracking() {
|
||||
let _gate = WalkMetricsTestGate::enable();
|
||||
for (cancelled, retried_open) in [(true, false), (false, false), (true, true), (false, true)] {
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.start_paused(true)
|
||||
.build()
|
||||
.expect("paused runtime");
|
||||
runtime.block_on(async {
|
||||
let mut steps = Vec::new();
|
||||
if retried_open {
|
||||
steps.push(WalkDirTestStep::Error(DiskError::Io(io::Error::new(
|
||||
io::ErrorKind::ConnectionReset,
|
||||
"transient open before an unfinished copy",
|
||||
))));
|
||||
}
|
||||
steps.push(WalkDirTestStep::Data(vec![6; DEFAULT_READ_BUFFER_SIZE * 4]));
|
||||
let transport = RetryingWalkDirInternodeDataTransport::with_steps(steps);
|
||||
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
|
||||
let opts = WalkDirOptions {
|
||||
timeout_ms: Some(1000),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut writer, mut consumer) = duplex(64);
|
||||
let mut walk = Box::pin(disk.walk_dir(opts, &mut writer));
|
||||
assert!(futures::poll!(&mut walk).is_pending(), "walk must reach a backpressured await");
|
||||
assert_eq!(transport.calls().len(), if retried_open { 2 } else { 1 });
|
||||
if cancelled {
|
||||
drop(walk);
|
||||
} else {
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
assert!(matches!(walk.await, Err(DiskError::Timeout)));
|
||||
}
|
||||
assert_eq!(disk.health.waiting_count(), 0, "health tracking must be released");
|
||||
drop(writer);
|
||||
let mut published = Vec::new();
|
||||
consumer.read_to_end(&mut published).await.expect("published prefix");
|
||||
assert_eq!(published, vec![6; 64]);
|
||||
});
|
||||
});
|
||||
assert_walk_failure_reason(&recorder, Some(if cancelled { "abandoned" } else { "operation_timeout" }));
|
||||
let outcome = if cancelled { "cancelled" } else { "error" };
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_started_total", &[]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", outcome)]), 1);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 1_048_576);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]), 64);
|
||||
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocations_total", &[]), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remote_disk_disk_id() {
|
||||
let url = url::Url::parse("http://remote-server:9000").expect("operation should succeed");
|
||||
@@ -8540,6 +9081,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remote_disk_namespace_scanner_falls_back_until_checkpoint_support_is_authenticated() {
|
||||
for support in [None, Some(false)] {
|
||||
let transport = RecordingInternodeDataTransport::default();
|
||||
*transport
|
||||
.ns_scanner_checkpoint_support
|
||||
.lock()
|
||||
.expect("checkpoint capability lock") = support;
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
|
||||
assert_eq!(remote_disk.ns_scanner_server_epoch().await.expect("old peer fallback"), None);
|
||||
assert!(matches!(transport.calls().as_slice(), [RecordedTransportCall::NsScannerProbe(_)]));
|
||||
*transport
|
||||
.ns_scanner_checkpoint_support
|
||||
.lock()
|
||||
.expect("checkpoint capability lock") = Some(true);
|
||||
assert_eq!(
|
||||
remote_disk.ns_scanner_server_epoch().await.expect("upgraded peer"),
|
||||
Some(Uuid::from_u128(1))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remote_disk_namespace_scanner_capability_falls_back_without_generation_support() {
|
||||
for support in [None, Some(false)] {
|
||||
|
||||
@@ -322,6 +322,19 @@ where
|
||||
with_read_version_diagnostic_context(context, boundary, future)
|
||||
}
|
||||
|
||||
/// Capture before spawning without inventing an entry for a generic task.
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub fn with_captured_read_version_context<F>(
|
||||
boundary: ReadVersionContextBoundary,
|
||||
future: F,
|
||||
) -> impl std::future::Future<Output = F::Output>
|
||||
where
|
||||
F: std::future::Future,
|
||||
{
|
||||
let context = ReadVersionDiagnosticContext::capture();
|
||||
with_read_version_diagnostic_context(context, boundary, Box::pin(future))
|
||||
}
|
||||
|
||||
/// Observe a named entry even when its scope retains an earlier source.
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub fn observe_read_version_context_boundary(boundary: ReadVersionContextBoundary) {
|
||||
@@ -1337,4 +1350,65 @@ mod tests {
|
||||
assert_eq!(caller, Some(ReadVersionRequestCaller::Peer));
|
||||
assert_eq!(current_read_version_request_caller(), None);
|
||||
}
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[tokio::test]
|
||||
async fn captured_context_keeps_existing_values_without_creating_an_entry() {
|
||||
use rustfs_io_metrics::s3_http_metrics::{
|
||||
S3HttpRequestGuard, current_s3_http_operation_name, observe_s3_http_operation, with_s3_http_operation_context,
|
||||
};
|
||||
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
let operation = request.in_scope(|| {
|
||||
observe_s3_http_operation(rustfs_s3_ops::S3Operation::PutObject);
|
||||
current_s3_http_operation_context().expect("captured PUT operation")
|
||||
});
|
||||
for inherited in [false, true] {
|
||||
let make_future = || {
|
||||
with_captured_read_version_context(ReadVersionContextBoundary::BackgroundTask, async {
|
||||
tokio::task::yield_now().await;
|
||||
(
|
||||
current_read_version_request_caller(),
|
||||
current_read_version_entry_source(),
|
||||
current_s3_http_operation_name(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let captured = if inherited {
|
||||
with_s3_http_operation_context(Some(operation), async {
|
||||
with_read_version_request_caller(ReadVersionRequestCaller::Object, async {
|
||||
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async { make_future() }).await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
make_future()
|
||||
};
|
||||
assert_eq!(current_read_version_request_caller(), None);
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
let observed = tokio::spawn(captured).await.expect("captured child joins");
|
||||
let expected = if inherited {
|
||||
(
|
||||
Some(ReadVersionRequestCaller::Object),
|
||||
Some(ReadVersionEntrySource::TierConfigReload),
|
||||
Some("s3:PutObject"),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
assert_eq!(observed, expected);
|
||||
assert_eq!(current_s3_http_operation_name(), None);
|
||||
}
|
||||
|
||||
let polled = std::sync::atomic::AtomicBool::new(false);
|
||||
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async {
|
||||
let unpolled = with_captured_read_version_context(ReadVersionContextBoundary::BackgroundTask, async {
|
||||
polled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
drop(unpolled);
|
||||
})
|
||||
.await;
|
||||
assert!(!polled.load(std::sync::atomic::Ordering::Relaxed));
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
}
|
||||
}
|
||||
|
||||
+508
-233
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
|
||||
const MINIO_CONFIG: &[u8] = include_bytes!("../../../tests/fixtures/server-config/minio-2025-09-07.json");
|
||||
|
||||
fn roundtrip(cfg: &Config, seed: &[u8]) -> (Config, Value) {
|
||||
let encoded = encode_server_config_blob(cfg, Some(seed)).expect("encode config with seed");
|
||||
let decoded = decode_server_config_blob(&encoded).expect("reload saved config");
|
||||
let repeated = encode_server_config_blob(&decoded, Some(&encoded)).expect("encode reloaded config");
|
||||
let encoded_value: Value = serde_json::from_slice(&encoded).unwrap();
|
||||
let repeated_value: Value = serde_json::from_slice(&repeated).unwrap();
|
||||
assert_eq!(encoded_value, repeated_value, "a second save must preserve JSON values");
|
||||
(decoded, encoded_value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_minio_config_preserves_unsupported_heal_settings_without_applying_them() {
|
||||
assert!(
|
||||
Config::unmarshal(MINIO_CONFIG).is_err(),
|
||||
"the complete fixture exercises the external fallback"
|
||||
);
|
||||
let cfg = decode_server_config_blob(MINIO_CONFIG).expect("decode real MinIO config");
|
||||
let heal = cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).unwrap_or_default();
|
||||
for key in ["bitrotscan", "drive_workers", "max_io", "max_sleep"] {
|
||||
assert!(heal.lookup(key).is_none(), "MinIO heal controls must not be mapped by name");
|
||||
}
|
||||
assert_eq!(
|
||||
cfg.get_value(STORAGE_CLASS_SUB_SYS, DEFAULT_DELIMITER)
|
||||
.unwrap()
|
||||
.get("optimize"),
|
||||
"availability"
|
||||
);
|
||||
let (_, saved) = roundtrip(&cfg, MINIO_CONFIG);
|
||||
let original: Value = serde_json::from_slice(MINIO_CONFIG).unwrap();
|
||||
assert_eq!(saved["heal"], original["heal"], "unsupported KVS entries survive unchanged");
|
||||
assert_eq!(saved["cache"], original["cache"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_default_alias_reset_cannot_expose_shadow_overrides() {
|
||||
for (subsystem, key, old, shadow, defaults) in [
|
||||
(SCANNER_SUB_SYS, rustfs_config::SCANNER_CYCLE, "61", "59", &*scanner::DEFAULT_KVS),
|
||||
(HEAL_SUB_SYS, rustfs_config::HEAL_BITROT_CYCLE, "off", "48h", &*heal::DEFAULT_KVS),
|
||||
] {
|
||||
let seed = serde_json::to_vec(&serde_json::json!({"version":"33", subsystem: {
|
||||
"default": {key: old, "future":"preferred"},
|
||||
"_": [{"key":key,"value":shadow,"future_attribute":"keep"}, {"key":"future","value":"shadow"}]
|
||||
}}))
|
||||
.unwrap();
|
||||
let mut cfg = decode_server_config_blob(&seed).unwrap();
|
||||
cfg.0.remove(subsystem);
|
||||
let (decoded, saved) = roundtrip(&cfg, &seed);
|
||||
assert_eq!(
|
||||
decoded
|
||||
.get_value(subsystem, DEFAULT_DELIMITER)
|
||||
.unwrap_or_default()
|
||||
.lookup(key)
|
||||
.unwrap_or_else(|| defaults.get(key)),
|
||||
defaults.get(key)
|
||||
);
|
||||
assert_eq!(saved[subsystem]["default"]["future"], "preferred");
|
||||
assert_eq!(saved[subsystem]["_"][0]["value"], defaults.get(key));
|
||||
assert_eq!(saved[subsystem]["_"][0]["future_attribute"], "keep");
|
||||
assert_eq!(saved[subsystem]["_"][1]["value"], "shadow");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_aliases_keep_unknown_data_and_sync_known_values() {
|
||||
let seed = br#"{"version":"33",
|
||||
"storageclass":{"standard":"EC:2","future":"canonical"},
|
||||
"storage_class":{"_":[{"key":"standard","value":"EC:4","extra":"keep"},{"key":"future","value":"native"}]},
|
||||
"openid":{"default":{"config_url":"https://id.example/new","client_id":"new","future":"canonical"}},
|
||||
"identity_openid":{"_":{"config_url":"https://id.example/old","client_id":"old","future":"alias"}},
|
||||
"audit":{"webhook":{"default":{"enable":true,"endpoint":"https://audit.example/new","future":"audit"}}},
|
||||
"logger":{"audit_webhook":{"_":{"enable":true,"endpoint":"https://audit.example/old","future":"logger"}}}
|
||||
}"#;
|
||||
let cfg = decode_server_config_blob(seed).unwrap();
|
||||
let (decoded, saved) = roundtrip(&cfg, seed);
|
||||
assert!(configs_semantically_equal(&cfg, &decoded));
|
||||
assert_eq!(saved["storageclass"]["future"], "canonical");
|
||||
assert_eq!(saved["storage_class"]["_"][0]["value"], "EC:2");
|
||||
assert_eq!(saved["storage_class"]["_"][0]["extra"], "keep");
|
||||
assert_eq!(saved["storage_class"]["_"][1]["value"], "native");
|
||||
assert_eq!(saved["openid"]["default"]["future"], "canonical");
|
||||
assert_eq!(saved["identity_openid"]["_"]["future"], "alias");
|
||||
assert_eq!(saved["identity_openid"]["_"]["client_id"], "new");
|
||||
assert_eq!(saved["audit"]["webhook"]["default"]["future"], "audit");
|
||||
assert_eq!(saved["logger"]["audit_webhook"]["_"]["future"], "logger");
|
||||
assert_eq!(saved["logger"]["audit_webhook"]["_"]["endpoint"], "https://audit.example/new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storageclass_nested_alias_reset_clears_inline_override_and_retains_attributes() {
|
||||
let seed = br#"{"version":"33","storageclass":{"standard":"EC:2","_": [
|
||||
{"key":"inline_block","value":"64KiB","extra":{"owner":"future"}},
|
||||
{"key":"future","value":"keep"}]},
|
||||
"storage_class":{"default":{"standard":"EC:4","future":"alias"}}}"#;
|
||||
let (decoded, saved) = roundtrip(&Config::new(), seed);
|
||||
assert_eq!(
|
||||
decoded
|
||||
.get_value(STORAGE_CLASS_SUB_SYS, DEFAULT_DELIMITER)
|
||||
.unwrap()
|
||||
.get("inline_block"),
|
||||
storageclass::DEFAULT_KVS.get("inline_block")
|
||||
);
|
||||
assert_eq!(saved["storageclass"]["_"][0]["extra"]["owner"], "future");
|
||||
assert_eq!(saved["storageclass"]["_"][1]["value"], "keep");
|
||||
assert_eq!(saved["storage_class"]["default"]["future"], "alias");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_known_kvs_entries_are_updated_consistently() {
|
||||
let seed = br#"{"version":"33","scanner":[
|
||||
{"key":"cycle","value":"61","extra":"first"},
|
||||
{"key":"cycle","value":"62","extra":"last"}]}"#;
|
||||
let mut cfg = decode_server_config_blob(seed).unwrap();
|
||||
cfg.0
|
||||
.get_mut(SCANNER_SUB_SYS)
|
||||
.unwrap()
|
||||
.get_mut(DEFAULT_DELIMITER)
|
||||
.unwrap()
|
||||
.insert("cycle".to_string(), "73".to_string());
|
||||
let (decoded, saved) = roundtrip(&cfg, seed);
|
||||
assert_eq!(decoded.get_value(SCANNER_SUB_SYS, DEFAULT_DELIMITER).unwrap().get("cycle"), "73");
|
||||
for entry in saved["scanner"].as_array().unwrap() {
|
||||
assert_eq!(entry["value"], "73");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_target_with_kvs_attributes_stays_disabled_under_enabled_default() {
|
||||
let seed = br#"{"version":"33","notify":{"webhook":{
|
||||
"default":{"enable":true,"endpoint":"https://notify.example/default"},
|
||||
"removed":[{"key":"endpoint","value":"https://notify.example/old","extra":"keep"}]
|
||||
}}}"#;
|
||||
let mut cfg = decode_server_config_blob(seed).unwrap();
|
||||
cfg.0
|
||||
.get_mut(rustfs_config::notify::NOTIFY_WEBHOOK_SUB_SYS)
|
||||
.unwrap()
|
||||
.remove("removed");
|
||||
let (decoded, saved) = roundtrip(&cfg, seed);
|
||||
let removed = decoded
|
||||
.get_value(rustfs_config::notify::NOTIFY_WEBHOOK_SUB_SYS, "removed")
|
||||
.unwrap();
|
||||
assert_eq!(removed.get(ENABLE_KEY), "off");
|
||||
assert_eq!(saved["notify"]["webhook"]["removed"][0]["extra"], "keep");
|
||||
assert_eq!(saved["notify"]["webhook"]["removed"][0]["value"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_preferred_aliases_keep_presence_precedence_but_accept_new_settings() {
|
||||
let seed = br#"{"version":"33","openid":null,"identity_openid":{"default":{"config_url":"https://old.example"}},
|
||||
"audit":null,"logger":{"webhook":{"default":{"enable":true,"endpoint":"https://old.example"}}},
|
||||
"scanner":{"default":null,"_":{"cycle":"59","future":"keep"}}}"#;
|
||||
let cfg = decode_server_config_blob(seed).unwrap();
|
||||
assert!(build_oidc_object(&cfg).is_empty());
|
||||
assert!(build_audit_object(&cfg).is_empty());
|
||||
assert!(build_scalar_config_object(&cfg, scanner_config_descriptor()).is_empty());
|
||||
let (_, reset) = roundtrip(&cfg, seed);
|
||||
assert!(reset["openid"].is_null(), "the preferred null alias must keep shadow config masked");
|
||||
assert!(reset["identity_openid"]["default"].get("config_url").is_none());
|
||||
let reset_cfg = decode_server_config_blob(&serde_json::to_vec(&reset).unwrap()).unwrap();
|
||||
assert!(
|
||||
build_oidc_object(&reset_cfg).is_empty(),
|
||||
"the old shadow provider must stay unapplied after save"
|
||||
);
|
||||
let updated = decode_server_config_blob(br#"{"version":"33","openid":{"default":{"config_url":"https://new.example"}},"audit":{"webhook":{"default":{"enable":true,"endpoint":"https://new.example"}}},"scanner":{"cycle":"73"}}"#).unwrap();
|
||||
let (decoded, _) = roundtrip(&updated, seed);
|
||||
assert!(configs_semantically_equal(&updated, &decoded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storageclass_null_precedence_is_materialized_before_alias_resave() {
|
||||
let seed = br#"{"version":"33","storageclass":null,"storage_class":{"standard":"EC:4","rrs":"EC:2","optimize":"capacity"}}"#;
|
||||
let cfg = decode_server_config_blob(seed).unwrap();
|
||||
let (_, saved) = roundtrip(&cfg, seed);
|
||||
assert_eq!(saved["storageclass"]["standard"], "");
|
||||
assert_eq!(saved["storageclass"]["rrs"], "");
|
||||
assert_eq!(saved["storage_class"]["standard"], "");
|
||||
assert_eq!(saved["storage_class"]["rrs"], "");
|
||||
assert_eq!(saved["storage_class"]["optimize"], "availability");
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct LogCapture(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for LogCapture {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
|
||||
type Writer = Self;
|
||||
fn make_writer(&'a self) -> Self {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_logs(run: impl FnOnce(&tracing::Dispatch)) -> String {
|
||||
let capture = LogCapture::default();
|
||||
let dispatch = tracing::Dispatch::new(
|
||||
tracing_subscriber::fmt()
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_writer(capture.clone())
|
||||
.finish(),
|
||||
);
|
||||
tracing::dispatcher::with_default(&dispatch, || run(&dispatch));
|
||||
String::from_utf8(capture.0.lock().unwrap().clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_reports_are_bounded_summaries_and_never_log_untrusted_payloads() {
|
||||
let make_blob = |prefix: &str, keys: usize| {
|
||||
let mut settings = Map::new();
|
||||
for i in 0..keys {
|
||||
settings.insert(format!("SECRET_KEY_{prefix}_{i}"), Value::String("SECRET_VALUE".to_string()));
|
||||
}
|
||||
serde_json::to_vec(&serde_json::json!({"version":"33", "heal":settings, "scanner":"SECRET_LEGACY_PAYLOAD"})).unwrap()
|
||||
};
|
||||
let a = make_blob("a", 1000);
|
||||
let b = make_blob("b", 65);
|
||||
let logs = captured_logs(|dispatch| {
|
||||
for _ in 0..100 {
|
||||
decode_server_config_blob(&a).unwrap();
|
||||
}
|
||||
decode_server_config_blob(&b).unwrap();
|
||||
decode_server_config_blob(&a).unwrap();
|
||||
let barrier = std::sync::Barrier::new(12);
|
||||
let concurrent = make_blob("concurrent", 80);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..12 {
|
||||
let (barrier, concurrent) = (&barrier, &concurrent);
|
||||
scope.spawn(move || {
|
||||
tracing::dispatcher::with_default(dispatch, || {
|
||||
barrier.wait();
|
||||
decode_server_config_blob(concurrent).unwrap();
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
assert_eq!(
|
||||
logs.matches(EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED).count(),
|
||||
3,
|
||||
"one summary per diagnostic set, including concurrent readers: {logs}"
|
||||
);
|
||||
assert!(logs.contains("unknown_key_count=1000"));
|
||||
assert!(!logs.contains("SECRET"));
|
||||
assert!(logs.len() < 2000, "log volume must not grow with unknown-key count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_known_values_still_fail_without_a_successful_compatibility_warning() {
|
||||
let logs = captured_logs(|_| {
|
||||
for seed in [
|
||||
br#"{"version":"33","heal":{"future":"secret","bitrot_cycle":null}}"#.as_slice(),
|
||||
br#"{"version":"33","scanner":[{"key":"cycle","value":[]}]}"#.as_slice(),
|
||||
br#"{"version":"33","scanner":[false]}"#.as_slice(),
|
||||
br#"{"scanner":null,"heal":"SECRET"}"#.as_slice(),
|
||||
] {
|
||||
assert!(decode_server_config_blob(seed).is_err());
|
||||
}
|
||||
});
|
||||
assert!(!logs.contains(EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED));
|
||||
assert!(!logs.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn final_decode_failures_are_deduplicated_without_changing_error_classification() {
|
||||
let previous = replace_server_config_decrypt_fn_for_test(None);
|
||||
let logs = captured_logs(|_| {
|
||||
for _ in 0..100 {
|
||||
let err = decode_persisted_server_config(br#"{SECRET_CORRUPT_A"#).unwrap_err();
|
||||
assert!(is_server_config_corrupt_error(&err));
|
||||
}
|
||||
assert!(decode_persisted_server_config(br#"{SECRET_CORRUPT_B"#).is_err());
|
||||
assert!(decode_persisted_server_config(br#"{SECRET_CORRUPT_A"#).is_err());
|
||||
replace_server_config_decrypt_fn_for_test(Some(std::sync::Arc::new(|_| None)));
|
||||
for _ in 0..100 {
|
||||
let err = decode_persisted_server_config(b"SECRET_CIPHERTEXT").unwrap_err();
|
||||
assert!(is_server_config_decrypt_error(&err));
|
||||
}
|
||||
});
|
||||
replace_server_config_decrypt_fn_for_test(previous);
|
||||
assert_eq!(logs.matches(EVENT_SERVER_CONFIG_DECODE_FAILED).count(), 2);
|
||||
assert_eq!(logs.matches(EVENT_SERVER_CONFIG_DECRYPT_FAILED).count(), 1);
|
||||
assert!(!logs.contains("SECRET"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn pinned_minio_plaintext_exercises_the_legacy_decrypt_entry_without_false_errors() {
|
||||
// The fixture is plaintext from MinIO; this envelope is produced by RustFS.
|
||||
let encrypted = rustfs_crypto::encrypt_data(b"fixture-test-key", MINIO_CONFIG).unwrap();
|
||||
let previous = replace_server_config_decrypt_fn_for_test(Some(std::sync::Arc::new(|data| {
|
||||
rustfs_crypto::decrypt_data(b"fixture-test-key", data).ok()
|
||||
})));
|
||||
let logs = captured_logs(|_| {
|
||||
let (cfg, seed) = decode_persisted_server_config_with_seed(&encrypted).unwrap();
|
||||
assert_eq!(seed, MINIO_CONFIG);
|
||||
roundtrip(&cfg, &seed);
|
||||
});
|
||||
replace_server_config_decrypt_fn_for_test(previous);
|
||||
assert!(!logs.contains(EVENT_SERVER_CONFIG_DECODE_FAILED));
|
||||
assert!(!logs.contains(EVENT_SERVER_CONFIG_DECRYPT_FAILED));
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 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 std::collections::VecDeque;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::Value;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use super::{
|
||||
EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED, EVENT_SERVER_CONFIG_DECODE_FAILED, EVENT_SERVER_CONFIG_DECRYPT_FAILED,
|
||||
HEAL_SUB_SYS, LOG_COMPONENT_CONFIG, LOG_SUBSYSTEM_CONFIG,
|
||||
};
|
||||
|
||||
const RECENT_REPORTS: usize = 64;
|
||||
const FAILURE_REPORT_INTERVAL: Duration = Duration::from_secs(300);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Reports(VecDeque<Report>);
|
||||
|
||||
struct Report {
|
||||
key: u64,
|
||||
reported: Instant,
|
||||
suppressed: u64,
|
||||
}
|
||||
|
||||
impl Reports {
|
||||
fn observe(&mut self, key: u64, now: Instant, repeat: Option<Duration>) -> Option<u64> {
|
||||
if let Some(index) = self.0.iter().position(|report| report.key == key) {
|
||||
let mut report = self.0.remove(index)?;
|
||||
let emit = repeat.is_some_and(|interval| now.saturating_duration_since(report.reported) >= interval);
|
||||
let suppressed = report.suppressed;
|
||||
if emit {
|
||||
report.reported = now;
|
||||
report.suppressed = 0;
|
||||
} else {
|
||||
report.suppressed = report.suppressed.saturating_add(1);
|
||||
}
|
||||
self.0.push_back(report);
|
||||
return emit.then_some(suppressed);
|
||||
}
|
||||
if self.0.len() == RECENT_REPORTS {
|
||||
self.0.pop_front();
|
||||
}
|
||||
self.0.push_back(Report {
|
||||
key,
|
||||
reported: now,
|
||||
suppressed: 0,
|
||||
});
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
|
||||
static COMPATIBILITY_REPORTS: LazyLock<Mutex<Reports>> = LazyLock::new(Mutex::default);
|
||||
static FAILURE_REPORTS: LazyLock<Mutex<Reports>> = LazyLock::new(Mutex::default);
|
||||
|
||||
fn observe(reports: &Mutex<Reports>, key: u64, repeat: Option<Duration>) -> Option<u64> {
|
||||
// Never hold this process-local diagnostics lock across tracing or I/O.
|
||||
reports
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.observe(key, Instant::now(), repeat)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct CompatibilityDiagnostics {
|
||||
digest: DefaultHasher,
|
||||
unknown_keys: usize,
|
||||
legacy_shapes: usize,
|
||||
legacy_heal_keys: u8,
|
||||
shape_kinds: u8,
|
||||
}
|
||||
|
||||
const LEGACY_HEAL_KEYS: [&str; 4] = ["bitrotscan", "drive_workers", "max_io", "max_sleep"];
|
||||
const SHAPE_KINDS: [&str; 6] = ["null", "boolean", "number", "string", "array", "object"];
|
||||
|
||||
fn shape(value: &Value) -> usize {
|
||||
match value {
|
||||
Value::Null => 0,
|
||||
Value::Bool(_) => 1,
|
||||
Value::Number(_) => 2,
|
||||
Value::String(_) => 3,
|
||||
Value::Array(_) => 4,
|
||||
Value::Object(_) => 5,
|
||||
}
|
||||
}
|
||||
|
||||
impl CompatibilityDiagnostics {
|
||||
pub(super) fn unknown_key(&mut self, subsystem: &str, key: &str) {
|
||||
("unknown_key", subsystem, key).hash(&mut self.digest);
|
||||
self.unknown_keys += 1;
|
||||
if subsystem == HEAL_SUB_SYS
|
||||
&& let Some(index) = LEGACY_HEAL_KEYS.iter().position(|known| *known == key)
|
||||
{
|
||||
self.legacy_heal_keys |= 1 << index;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn legacy_shape(&mut self, subsystem: &str, value: &Value) {
|
||||
let kind = shape(value);
|
||||
("legacy_shape", subsystem, kind).hash(&mut self.digest);
|
||||
self.legacy_shapes += 1;
|
||||
self.shape_kinds |= 1 << kind;
|
||||
}
|
||||
|
||||
pub(super) fn report(&self) {
|
||||
if self.unknown_keys == 0 && self.legacy_shapes == 0 {
|
||||
return;
|
||||
}
|
||||
if observe(&COMPATIBILITY_REPORTS, self.digest.finish(), None).is_none() {
|
||||
return;
|
||||
}
|
||||
let legacy_heal_keys = LEGACY_HEAL_KEYS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, key)| (self.legacy_heal_keys & (1 << index) != 0).then_some(*key))
|
||||
.collect::<Vec<_>>();
|
||||
let legacy_shape_kinds = SHAPE_KINDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, kind)| (self.shape_kinds & (1 << index) != 0).then_some(*kind))
|
||||
.collect::<Vec<_>>();
|
||||
warn!(
|
||||
event = EVENT_SERVER_CONFIG_COMPATIBILITY_IGNORED,
|
||||
component = LOG_COMPONENT_CONFIG,
|
||||
subsystem = LOG_SUBSYSTEM_CONFIG,
|
||||
state = "ignored",
|
||||
unknown_key_count = self.unknown_keys,
|
||||
legacy_shape_count = self.legacy_shapes,
|
||||
?legacy_heal_keys,
|
||||
?legacy_shape_kinds,
|
||||
"Persisted server config contains unsupported settings; unknown fields are retained but not applied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Hash)]
|
||||
pub(super) enum DecodeFailure {
|
||||
Corrupt,
|
||||
Decrypt,
|
||||
}
|
||||
|
||||
pub(super) fn report_decode_failure(data: &[u8], failure: DecodeFailure, encrypted_size: Option<usize>) {
|
||||
let mut digest = DefaultHasher::new();
|
||||
failure.hash(&mut digest);
|
||||
data.hash(&mut digest);
|
||||
let Some(suppressed_attempts) = observe(&FAILURE_REPORTS, digest.finish(), Some(FAILURE_REPORT_INTERVAL)) else {
|
||||
return;
|
||||
};
|
||||
let event = match failure {
|
||||
DecodeFailure::Corrupt => EVENT_SERVER_CONFIG_DECODE_FAILED,
|
||||
DecodeFailure::Decrypt => EVENT_SERVER_CONFIG_DECRYPT_FAILED,
|
||||
};
|
||||
error!(
|
||||
event,
|
||||
component = LOG_COMPONENT_CONFIG,
|
||||
subsystem = LOG_SUBSYSTEM_CONFIG,
|
||||
state = "failed",
|
||||
size = data.len(),
|
||||
encrypted_size,
|
||||
suppressed_attempts,
|
||||
"Persisted server config could not be decoded; the read remains failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reports_keep_history_without_confusing_keys_with_versions() {
|
||||
let mut reports = Reports::default();
|
||||
let now = Instant::now();
|
||||
assert_eq!(reports.observe(1, now, None), Some(0));
|
||||
assert_eq!(reports.observe(2, now, None), Some(0));
|
||||
assert_eq!(reports.observe(1, now, None), None);
|
||||
for key in 3..=65 {
|
||||
assert_eq!(reports.observe(key, now, None), Some(0));
|
||||
}
|
||||
assert_eq!(reports.0.len(), RECENT_REPORTS);
|
||||
assert_eq!(reports.observe(2, now, None), Some(0), "only the least recently used entry is evicted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_report_at_the_interval_boundary_and_count_suppressed_reads() {
|
||||
let mut reports = Reports::default();
|
||||
let now = Instant::now();
|
||||
assert_eq!(reports.observe(1, now, Some(FAILURE_REPORT_INTERVAL)), Some(0));
|
||||
assert_eq!(reports.observe(1, now + Duration::from_secs(299), Some(FAILURE_REPORT_INTERVAL)), None);
|
||||
assert_eq!(reports.observe(1, now + FAILURE_REPORT_INTERVAL, Some(FAILURE_REPORT_INTERVAL)), Some(1));
|
||||
assert_eq!(reports.observe(2, now + FAILURE_REPORT_INTERVAL, Some(FAILURE_REPORT_INTERVAL)), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,13 @@ use rustfs_config::{
|
||||
DEFAULT_SCANNER_BITROT_CYCLE_SECS, DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS, DEFAULT_SCANNER_CYCLE_MAX_DIRECTORIES,
|
||||
DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_DISK_IO_BUDGET,
|
||||
DEFAULT_SCANNER_IDLE_MODE, DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS, DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS,
|
||||
DEFAULT_SCANNER_SPEED, DEFAULT_SCANNER_WALKERS_PER_DISK, DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS,
|
||||
SCANNER_ALERT_EXCESS_VERSION_SIZE, SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT,
|
||||
SCANNER_CYCLE, SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY,
|
||||
DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS, DEFAULT_SCANNER_SPEED, DEFAULT_SCANNER_WALKERS_PER_DISK,
|
||||
DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS, SCANNER_ALERT_EXCESS_VERSION_SIZE,
|
||||
SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE,
|
||||
SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY,
|
||||
SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC, SCANNER_DISK_IO_BUDGET_IOPS, SCANNER_IDLE_MODE, SCANNER_MAX_CONCURRENT_DISK_SCANS,
|
||||
SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_SPEED, SCANNER_START_DELAY, SCANNER_WALKERS_PER_DISK,
|
||||
SCANNER_YIELD_EVERY_N_OBJECTS,
|
||||
SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_RECOVERY_BACKOFF_CAP, SCANNER_SPEED, SCANNER_START_DELAY,
|
||||
SCANNER_WALKERS_PER_DISK, SCANNER_YIELD_EVERY_N_OBJECTS,
|
||||
};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -83,6 +84,11 @@ pub static DEFAULT_KVS: LazyLock<KVS> = LazyLock::new(|| {
|
||||
value: DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS.to_string(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: SCANNER_RECOVERY_BACKOFF_CAP.to_owned(),
|
||||
value: DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS.to_string(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: SCANNER_MAX_CONCURRENT_SET_SCANS.to_owned(),
|
||||
value: DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS.to_string(),
|
||||
|
||||
+7079
-415
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
// Copyright 2026 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.
|
||||
|
||||
//! Test-only barriers inside the data-movement commit windows.
|
||||
//!
|
||||
//! Compiled only with the `e2e-test-hooks` feature and inert unless
|
||||
//! `RUSTFS_E2E_DATA_MOVEMENT_BARRIER_DIR` names an absolute directory holding
|
||||
//! an `arm.json`. A mover that matches the arm records one `reached.*.json`
|
||||
//! observation and then blocks until `release.<nonce>` exists. A release body
|
||||
//! of `io_error` fails the window instead of continuing, so a fixture can
|
||||
//! inject a target-commit or source-cleanup failure at a proven point. The
|
||||
//! observation file is the only evidence that an injection reached its window;
|
||||
//! a fixture must not count an unreached barrier as a fault test.
|
||||
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::fs;
|
||||
use tokio::time::Instant;
|
||||
|
||||
pub(crate) const BARRIER_DIR_ENV: &str = "RUSTFS_E2E_DATA_MOVEMENT_BARRIER_DIR";
|
||||
/// Every target part is staged and the target version is not yet published.
|
||||
pub(crate) const STAGE_BEFORE_TARGET_COMMIT: &str = "before_target_commit";
|
||||
/// The target commit is durable and the source preflight matched; the source
|
||||
/// delete has not been issued.
|
||||
pub(crate) const STAGE_BEFORE_SOURCE_CLEANUP: &str = "before_source_cleanup";
|
||||
/// A decommission start is durable in `pool.bin`; no worker has started and
|
||||
/// the admin request has not been acknowledged.
|
||||
pub(crate) const STAGE_AFTER_RESPONSIBILITY_PERSISTED: &str = "after_responsibility_persisted";
|
||||
const RELEASE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const RELEASE_POLL: Duration = Duration::from_millis(20);
|
||||
const RELEASE_IO_ERROR: &[u8] = b"io_error";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BarrierArm {
|
||||
nonce: String,
|
||||
bucket: String,
|
||||
object: String,
|
||||
stage: String,
|
||||
/// `decommission` or `rebalance`; absent matches either mover. Movers label
|
||||
/// their stages with that word or a `<word>_` prefix (`decommission_object`).
|
||||
#[serde(default)]
|
||||
operation: Option<String>,
|
||||
}
|
||||
|
||||
fn valid_nonce(nonce: &str) -> bool {
|
||||
!nonce.is_empty()
|
||||
&& nonce.len() <= 64
|
||||
&& nonce
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
|
||||
}
|
||||
|
||||
async fn read_arm(directory: &Path) -> std::io::Result<Option<BarrierArm>> {
|
||||
match fs::read(directory.join("arm.json")).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(std::io::Error::other),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold `operation` at `stage` for `bucket/object` while a matching arm exists.
|
||||
pub(crate) async fn wait(operation: &str, bucket: &str, object: &str, stage: &str) -> std::io::Result<()> {
|
||||
let Some(directory) = std::env::var_os(BARRIER_DIR_ENV).map(PathBuf::from) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(arm) = read_arm(&directory).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
if arm.bucket != bucket
|
||||
|| arm.object != object
|
||||
|| arm.stage != stage
|
||||
|| arm
|
||||
.operation
|
||||
.as_deref()
|
||||
.is_some_and(|armed| armed != operation && !operation.starts_with(&format!("{armed}_")))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if !directory.is_absolute() || !valid_nonce(&arm.nonce) {
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"data movement test barrier requires an absolute directory and a safe nonce",
|
||||
));
|
||||
}
|
||||
let pid = std::process::id();
|
||||
let observation = serde_json::json!({
|
||||
"operation": operation,
|
||||
"bucket": bucket,
|
||||
"object": object,
|
||||
"stage": stage,
|
||||
"pid": pid,
|
||||
"nonce": arm.nonce,
|
||||
"reached_at_unix_ms": time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000,
|
||||
});
|
||||
let reached = directory.join(format!("reached.{}.{pid}.{}.json", arm.nonce, uuid::Uuid::new_v4().simple()));
|
||||
fs::write(&reached, serde_json::to_vec(&observation)?).await?;
|
||||
let release = directory.join(format!("release.{}", arm.nonce));
|
||||
let deadline = Instant::now() + RELEASE_TIMEOUT;
|
||||
loop {
|
||||
match fs::read(&release).await {
|
||||
Ok(body) if body == RELEASE_IO_ERROR => {
|
||||
// The reached observation already names the stage and object.
|
||||
return Err(std::io::Error::other("injected data movement barrier failure"));
|
||||
}
|
||||
Ok(_) => return Ok(()),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(std::io::Error::new(ErrorKind::TimedOut, "data movement test barrier release timed out"));
|
||||
}
|
||||
tokio::time::sleep(RELEASE_POLL).await;
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,12 @@
|
||||
// #730: data-movement migration keeps staged cleanup helpers until copy paths converge.
|
||||
|
||||
pub(crate) mod backpressure;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
pub(crate) mod e2e_barrier;
|
||||
pub(crate) mod scanner_backlog;
|
||||
|
||||
use crate::core::pools::{DecommissionCapacityOwner, decommission_capacity_mutation_id};
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::error::{
|
||||
Error, Result, is_err_data_movement_overwrite, is_err_invalid_upload_id, is_err_object_not_found, is_err_version_not_found,
|
||||
};
|
||||
@@ -71,7 +74,9 @@ impl<R> IndexedDataMovementReader<R> {
|
||||
|
||||
impl<R: AsyncRead + Unpin + Send + Sync> AsyncRead for IndexedDataMovementReader<R> {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_read(cx, buf)
|
||||
Pin::new(&mut self.inner)
|
||||
.poll_read(cx, buf)
|
||||
.map(|result| result.map_err(data_movement_source_stream_error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +296,7 @@ fn data_movement_new_multipart_opts(object_info: &ObjectInfo, src_pool_idx: usiz
|
||||
rustfs_utils::http::insert_str(&mut user_defined, SUFFIX_DATA_MOVEMENT_UPLOAD, upload_identity);
|
||||
ObjectOptions {
|
||||
versioned: object_info.version_id.is_some(),
|
||||
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
||||
version_id: Some(data_movement_exact_version_id(object_info.version_id)),
|
||||
user_defined,
|
||||
preserve_etag: object_info.etag.clone(),
|
||||
src_pool_idx,
|
||||
@@ -461,7 +466,7 @@ fn data_movement_complete_multipart_opts(
|
||||
}
|
||||
Ok(ObjectOptions {
|
||||
versioned: object_info.version_id.is_some(),
|
||||
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
||||
version_id: Some(data_movement_exact_version_id(object_info.version_id)),
|
||||
http_preconditions: Some(data_movement_target_precondition()),
|
||||
data_movement: true,
|
||||
mod_time: object_info.mod_time,
|
||||
@@ -478,7 +483,7 @@ fn data_movement_put_object_opts(object_info: &ObjectInfo, src_pool_idx: usize)
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
shard_integrity_write_mode: Some(object_info.shard_integrity_write_mode()),
|
||||
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
||||
version_id: Some(data_movement_exact_version_id(object_info.version_id)),
|
||||
http_preconditions: Some(data_movement_target_precondition()),
|
||||
mod_time: object_info.mod_time,
|
||||
user_defined: data_movement_user_defined(object_info),
|
||||
@@ -487,6 +492,15 @@ fn data_movement_put_object_opts(object_info: &ObjectInfo, src_pool_idx: usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact `ObjectOptions::version_id` selector for one migrated version.
|
||||
///
|
||||
/// `None` selects the latest version, so the null version (stored with an
|
||||
/// absent or nil UUID) is addressed by the nil UUID. Otherwise a source read or
|
||||
/// target lookup resolves to a newer version or a delete marker instead.
|
||||
pub(crate) fn data_movement_exact_version_id(version_id: Option<uuid::Uuid>) -> String {
|
||||
version_id.unwrap_or_else(uuid::Uuid::nil).to_string()
|
||||
}
|
||||
|
||||
fn is_unversioned_data_movement_object(object_info: &ObjectInfo) -> bool {
|
||||
object_info.version_id.is_none_or(|version_id| version_id.is_nil())
|
||||
}
|
||||
@@ -532,11 +546,12 @@ pub(crate) fn can_replace_stale_data_movement_target(target: &ObjectInfo, opts:
|
||||
return false;
|
||||
}
|
||||
|
||||
let version_matches = match (opts.version_id.as_deref(), target.version_id) {
|
||||
(None, None) => true,
|
||||
(Some(expected), Some(actual)) => uuid::Uuid::parse_str(expected).ok() == Some(actual),
|
||||
_ => false,
|
||||
// The null version is addressed by the nil UUID but surfaces without an id.
|
||||
let expected_version = match opts.version_id.as_deref() {
|
||||
Some(expected) => uuid::Uuid::parse_str(expected).ok(),
|
||||
None => Some(uuid::Uuid::nil()),
|
||||
};
|
||||
let version_matches = expected_version == Some(target.version_id.unwrap_or_else(uuid::Uuid::nil));
|
||||
|
||||
version_matches
|
||||
&& target
|
||||
@@ -635,11 +650,16 @@ pub(crate) fn data_movement_stage_source(err: &Error) -> Option<&Error> {
|
||||
let Error::Io(io_err) = err else {
|
||||
return None;
|
||||
};
|
||||
io_err
|
||||
.get_ref()?
|
||||
.downcast_ref::<DataMovementStageError>()?
|
||||
.source
|
||||
.downcast_ref::<Error>()
|
||||
let source = io_err.get_ref()?;
|
||||
if let Some(failure) = source.downcast_ref::<DataMovementSourceReadFailure>() {
|
||||
return Some(&failure.0);
|
||||
}
|
||||
let source = &source.downcast_ref::<DataMovementStageError>()?.source;
|
||||
source.downcast_ref::<Error>().or_else(|| {
|
||||
source
|
||||
.downcast_ref::<DataMovementSourceReadFailure>()
|
||||
.map(|failure| &failure.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// Recover a concrete typed error wrapped by [`data_movement_stage_error`].
|
||||
@@ -657,6 +677,59 @@ where
|
||||
.downcast_ref::<T>()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
struct DataMovementSourceReadFailure(#[source] Error);
|
||||
|
||||
pub(crate) fn is_data_movement_source_read_error(err: &Error) -> bool {
|
||||
if data_movement_stage_source_as::<DataMovementSourceReadFailure>(err).is_some()
|
||||
|| matches!(err, Error::Io(io_err) if io_err.get_ref().is_some_and(|source| source.is::<DataMovementSourceReadFailure>()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
data_movement_stage_source(err).is_some_and(is_data_movement_source_read_error)
|
||||
}
|
||||
|
||||
fn data_movement_source_stream_error(err: std::io::Error) -> std::io::Error {
|
||||
let Some(source) = err.get_ref() else {
|
||||
return err;
|
||||
};
|
||||
let is_quorum = matches!(
|
||||
source.downcast_ref::<Error>(),
|
||||
Some(
|
||||
Error::ErasureReadQuorum
|
||||
| Error::ErasureWriteQuorum
|
||||
| Error::InsufficientReadQuorum(_, _)
|
||||
| Error::InsufficientWriteQuorum(_, _)
|
||||
)
|
||||
) || matches!(
|
||||
source.downcast_ref::<DiskError>(),
|
||||
Some(DiskError::ErasureReadQuorum | DiskError::ErasureWriteQuorum)
|
||||
);
|
||||
if !is_quorum {
|
||||
return err;
|
||||
}
|
||||
std::io::Error::new(err.kind(), DataMovementSourceReadFailure(Error::from(err)))
|
||||
}
|
||||
|
||||
/// Keep source metadata quorum failures distinct from target writes so
|
||||
/// decommission retains its per-object failure policy.
|
||||
pub(crate) fn data_movement_source_read_error(bucket: &str, object: &str, err: Error) -> Error {
|
||||
if matches!(
|
||||
err,
|
||||
Error::ErasureReadQuorum
|
||||
| Error::ErasureWriteQuorum
|
||||
| Error::InsufficientReadQuorum(_, _)
|
||||
| Error::InsufficientWriteQuorum(_, _)
|
||||
) {
|
||||
return data_movement_context_error(
|
||||
format!("data movement source read failed for {bucket}/{object}: {err}"),
|
||||
DataMovementSourceReadFailure(err),
|
||||
);
|
||||
}
|
||||
err
|
||||
}
|
||||
|
||||
fn schedule_data_movement_multipart_abort_cleanup(
|
||||
store: Arc<ECStore>,
|
||||
target_pool_idx: usize,
|
||||
@@ -1118,6 +1191,43 @@ pub(crate) fn source_cleanup_version_identity(version: &FileInfo) -> SourceClean
|
||||
}
|
||||
}
|
||||
|
||||
/// Source versions whose transitioned remote object a committed data-movement
|
||||
/// copy now references from the target pool.
|
||||
///
|
||||
/// Removing a transitioned version from xl.meta normally appends a tier free
|
||||
/// version that owns the remote delete. After a move the target copy is the
|
||||
/// live reference to that remote object, so the source must not get an owner
|
||||
/// of its own: it would fail the decommission post-check and could only be
|
||||
/// consumed once the target copy is gone. A version that did not move keeps
|
||||
/// its free version: one lifecycle expired during the move, or one a newer
|
||||
/// unversioned target object superseded ([`DataMovementCopy::Superseded`]).
|
||||
#[doc(hidden)]
|
||||
#[derive(Clone)]
|
||||
pub struct TransferredTierVersions(Arc<[SourceCleanupVersionIdentity]>);
|
||||
|
||||
impl TransferredTierVersions {
|
||||
fn new(versions: &[SourceCleanupVersionIdentity]) -> Option<Self> {
|
||||
(!versions.is_empty()).then(|| Self(versions.iter().cloned().map(Self::without_name).collect()))
|
||||
}
|
||||
|
||||
// Every version of one cleanup belongs to the same key, but the mover and
|
||||
// the delete name a directory object differently.
|
||||
fn without_name(mut identity: SourceCleanupVersionIdentity) -> SourceCleanupVersionIdentity {
|
||||
identity.name.clear();
|
||||
identity
|
||||
}
|
||||
|
||||
pub(crate) fn contains(&self, version: &FileInfo) -> bool {
|
||||
self.0.contains(&Self::without_name(source_cleanup_version_identity(version)))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TransferredTierVersions {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("TransferredTierVersions").field(&self.0.len()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn source_cleanup_versions_match_with_allowed_missing(
|
||||
expected: &FileInfoVersions,
|
||||
current: &FileInfoVersions,
|
||||
@@ -1173,6 +1283,34 @@ pub(crate) struct SourceCleanupBucketFence<'a> {
|
||||
pub(crate) object_mutation_fence: Option<&'a SourceCleanupMutationFence>,
|
||||
}
|
||||
|
||||
/// Removes versions a mover skipped as not found from the cleanup snapshot.
|
||||
///
|
||||
/// "Not found" only makes a version safe to clean up if it is really gone.
|
||||
/// Without it in the snapshot, the preflight reports `SourceChanged` while
|
||||
/// that exact version still exists instead of deleting it with the entry.
|
||||
pub(crate) fn source_cleanup_expected_without_vanished<'a>(
|
||||
expected: &'a FileInfoVersions,
|
||||
vanished: &[SourceCleanupVersionIdentity],
|
||||
) -> std::borrow::Cow<'a, FileInfoVersions> {
|
||||
if vanished.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(expected);
|
||||
}
|
||||
|
||||
let mut remaining = vanished.to_vec();
|
||||
let mut narrowed = expected.clone();
|
||||
narrowed.versions.retain(|version| {
|
||||
let identity = source_cleanup_version_identity(version);
|
||||
match remaining.iter().position(|vanished| *vanished == identity) {
|
||||
Some(index) => {
|
||||
remaining.swap_remove(index);
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
std::borrow::Cow::Owned(narrowed)
|
||||
}
|
||||
|
||||
fn ensure_source_cleanup_versions_match(
|
||||
expected: &FileInfoVersions,
|
||||
current: &FileInfoVersions,
|
||||
@@ -1319,12 +1457,16 @@ async fn pause_source_cleanup_before_delete(bucket: &str, object: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `transferred_tier_versions` lists the transitioned versions whose copy
|
||||
/// settled as [`DataMovementCopy::Committed`]; see [`TransferredTierVersions`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
set: Arc<SetDisks>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
expected: &FileInfoVersions,
|
||||
allowed_missing: &[SourceCleanupVersionIdentity],
|
||||
transferred_tier_versions: &[SourceCleanupVersionIdentity],
|
||||
bucket_fence: SourceCleanupBucketFence<'_>,
|
||||
op_label: &str,
|
||||
) -> std::result::Result<ObjectInfo, SourceCleanupError> {
|
||||
@@ -1365,11 +1507,16 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_source_cleanup_before_delete(bucket, object).await;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
e2e_barrier::wait(op_label, bucket, object, e2e_barrier::STAGE_BEFORE_SOURCE_CLEANUP)
|
||||
.await
|
||||
.map_err(|err| SourceCleanupError::Storage(Error::from(err)))?;
|
||||
|
||||
let mut opts = ObjectOptions {
|
||||
delete_prefix: true,
|
||||
delete_prefix_object: true,
|
||||
data_movement: true,
|
||||
data_movement_transferred_tier_versions: TransferredTierVersions::new(transferred_tier_versions),
|
||||
no_lock: true,
|
||||
expected_bucket_incarnation_id: bucket_fence.expected_incarnation_id,
|
||||
..Default::default()
|
||||
@@ -1405,7 +1552,7 @@ async fn find_data_movement_target_info(
|
||||
) -> Result<Option<ObjectInfo>> {
|
||||
let opts = ObjectOptions {
|
||||
versioned: object_info.version_id.is_some(),
|
||||
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
||||
version_id: Some(data_movement_exact_version_id(object_info.version_id)),
|
||||
no_lock: true,
|
||||
include_part_checksums: true,
|
||||
..Default::default()
|
||||
@@ -1425,6 +1572,19 @@ async fn find_data_movement_target_info(
|
||||
}
|
||||
}
|
||||
|
||||
/// How a data-movement copy settled on its target pool.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DataMovementCopy {
|
||||
/// The target holds this exact source version, committed by this copy or
|
||||
/// found equivalent from an earlier attempt, so it also holds any remote
|
||||
/// tier tuple of the version.
|
||||
Committed,
|
||||
/// A newer unversioned object on the target replaced the source. The
|
||||
/// target holds no copy of the source and no reference to its remote tier
|
||||
/// object.
|
||||
Superseded,
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "resume adjudication asserted by this file's tests (backlog#1823)")]
|
||||
fn resolve_data_movement_overwrite_resume_result(
|
||||
err: &Error,
|
||||
@@ -1433,16 +1593,18 @@ fn resolve_data_movement_overwrite_resume_result(
|
||||
src_pool_idx: usize,
|
||||
target_pool_idx: usize,
|
||||
) -> Result<bool> {
|
||||
resolve_data_movement_overwrite_resume_result_for(
|
||||
Ok(resolve_data_movement_overwrite_resume_copy(
|
||||
err,
|
||||
target_result,
|
||||
source,
|
||||
src_pool_idx,
|
||||
target_pool_idx,
|
||||
data_movement_part_checksum_writer_enabled(),
|
||||
)
|
||||
)?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resolve_data_movement_overwrite_resume_result_for(
|
||||
err: &Error,
|
||||
target_result: Result<Option<ObjectInfo>>,
|
||||
@@ -1451,30 +1613,47 @@ fn resolve_data_movement_overwrite_resume_result_for(
|
||||
target_pool_idx: usize,
|
||||
compare_part_checksums: bool,
|
||||
) -> Result<bool> {
|
||||
Ok(resolve_data_movement_overwrite_resume_copy(
|
||||
err,
|
||||
target_result,
|
||||
source,
|
||||
src_pool_idx,
|
||||
target_pool_idx,
|
||||
compare_part_checksums,
|
||||
)?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
fn resolve_data_movement_overwrite_resume_copy(
|
||||
err: &Error,
|
||||
target_result: Result<Option<ObjectInfo>>,
|
||||
source: &ObjectInfo,
|
||||
src_pool_idx: usize,
|
||||
target_pool_idx: usize,
|
||||
compare_part_checksums: bool,
|
||||
) -> Result<Option<DataMovementCopy>> {
|
||||
if scanner_backlog::is_scanner_pause_backlog(&source.bucket, &source.name)
|
||||
|| !should_check_data_movement_overwrite_resume(err)
|
||||
|| !should_check_data_movement_resume_target(src_pool_idx, target_pool_idx)
|
||||
{
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(target) = target_result? else {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if is_equivalent_data_movement_object_identity(source, &target, true, compare_part_checksums) {
|
||||
return Ok(true);
|
||||
if is_equivalent_data_movement_object_identity(source, &target, true, compare_part_checksums)
|
||||
|| (compare_part_checksums && is_legacy_data_movement_checksum_target(source, &target))
|
||||
|| is_data_movement_upload_takeover_target(source, &target, compare_part_checksums)
|
||||
{
|
||||
return Ok(Some(DataMovementCopy::Committed));
|
||||
}
|
||||
|
||||
if compare_part_checksums && is_legacy_data_movement_checksum_target(source, &target) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if is_data_movement_upload_takeover_target(source, &target, compare_part_checksums) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
|
||||
Ok(
|
||||
(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
|
||||
.then_some(DataMovementCopy::Superseded),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1491,13 +1670,13 @@ async fn should_treat_data_movement_overwrite_as_complete(
|
||||
err: &Error,
|
||||
compare_part_checksums: bool,
|
||||
capacity: DataMovementOverwriteCapacity,
|
||||
) -> Result<bool> {
|
||||
) -> Result<Option<DataMovementCopy>> {
|
||||
if !should_check_data_movement_overwrite_resume(err) {
|
||||
return Ok(false);
|
||||
return Ok(None);
|
||||
}
|
||||
let (src_pool_idx, target_pool_idx) = pool_indices;
|
||||
|
||||
let equivalent = resolve_data_movement_overwrite_resume_result_for(
|
||||
let copy = resolve_data_movement_overwrite_resume_copy(
|
||||
err,
|
||||
find_data_movement_target_info(store, target_pool_idx, bucket, object_info).await,
|
||||
object_info,
|
||||
@@ -1505,7 +1684,9 @@ async fn should_treat_data_movement_overwrite_as_complete(
|
||||
target_pool_idx,
|
||||
compare_part_checksums,
|
||||
)?;
|
||||
if equivalent && let Some(owner) = capacity.owner {
|
||||
if copy.is_some()
|
||||
&& let Some(owner) = capacity.owner
|
||||
{
|
||||
let expected_data_bytes = capacity
|
||||
.expected_data_bytes
|
||||
.ok_or_else(|| Error::other("equivalent data-movement target cannot reconcile unknown committed data size"))?;
|
||||
@@ -1513,7 +1694,7 @@ async fn should_treat_data_movement_overwrite_as_complete(
|
||||
.reconcile_decommission_capacity_after_equivalent_target(owner, target_pool_idx, expected_data_bytes)
|
||||
.await?;
|
||||
}
|
||||
Ok(equivalent)
|
||||
Ok(copy)
|
||||
}
|
||||
|
||||
fn data_movement_part_stage_error(
|
||||
@@ -1525,7 +1706,16 @@ fn data_movement_part_stage_error(
|
||||
err: Error,
|
||||
) -> Error {
|
||||
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}");
|
||||
if matches!(&err, Error::DecommissionCapacityBlocked { .. }) {
|
||||
if matches!(&err, Error::DecommissionCapacityBlocked { .. })
|
||||
|| (stage == "put_object_part"
|
||||
&& matches!(
|
||||
&err,
|
||||
Error::ErasureReadQuorum
|
||||
| Error::ErasureWriteQuorum
|
||||
| Error::InsufficientReadQuorum(_, _)
|
||||
| Error::InsufficientWriteQuorum(_, _)
|
||||
))
|
||||
{
|
||||
return data_movement_context_error(rendered, err);
|
||||
}
|
||||
// A missing target part is not evidence that the source can be deleted.
|
||||
@@ -1554,7 +1744,7 @@ fn is_data_movement_part_read_error(err: &Error) -> bool {
|
||||
.is_some_and(is_unexpected_eof)
|
||||
}
|
||||
|
||||
matches!(err, Error::Io(io_err) if is_unexpected_eof(io_err))
|
||||
is_data_movement_source_read_error(err) || matches!(err, Error::Io(io_err) if is_unexpected_eof(io_err))
|
||||
}
|
||||
|
||||
fn data_movement_part_upload_failure_stage(err: &Error) -> &'static str {
|
||||
@@ -1573,13 +1763,16 @@ pub(crate) async fn migrate_decommission_object(
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
capacity_owner: Option<DecommissionCapacityOwner>,
|
||||
) -> Result<()> {
|
||||
) -> Result<DataMovementCopy> {
|
||||
let source = rd.object_info.clone();
|
||||
let mutation_fence = store
|
||||
.acquire_decommission_object_mutation_fence(&bucket, &source.name)
|
||||
.await?;
|
||||
let current = find_data_movement_target_info(store.as_ref(), pool_idx, &bucket, &source)
|
||||
.await?
|
||||
let current = find_data_movement_target_info(store.as_ref(), pool_idx, &bucket, &source).await;
|
||||
#[cfg(test)]
|
||||
let current = crate::core::pools::decommission_test_wrap_result("source_recheck", &bucket, &source.name, 0, current);
|
||||
let current = current
|
||||
.map_err(|err| data_movement_source_read_error(&bucket, &source.name, err))?
|
||||
.ok_or(Error::FileNotFound)?;
|
||||
if !is_equivalent_data_movement_object_identity(&source, ¤t, true, false) {
|
||||
return Err(Error::FileNotFound);
|
||||
@@ -1607,7 +1800,7 @@ pub(crate) async fn migrate_object(
|
||||
rd: GetObjectReader,
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
) -> Result<()> {
|
||||
) -> Result<DataMovementCopy> {
|
||||
migrate_object_with_lock_lost_signal(store, pool_idx, bucket, rd, source_bucket_incarnation_id, op_label, None).await
|
||||
}
|
||||
|
||||
@@ -1620,7 +1813,7 @@ pub(crate) async fn migrate_object_with_lock_lost_signal(
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> Result<()> {
|
||||
) -> Result<DataMovementCopy> {
|
||||
migrate_object_inner(
|
||||
store,
|
||||
pool_idx,
|
||||
@@ -1646,7 +1839,7 @@ async fn migrate_object_inner(
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
capacity_owner: Option<DecommissionCapacityOwner>,
|
||||
mutation_fence: Option<DecommissionFixedReadAnchor>,
|
||||
) -> Result<()> {
|
||||
) -> Result<DataMovementCopy> {
|
||||
if scanner_backlog::is_scanner_pause_backlog(&bucket, &rd.object_info.name) {
|
||||
return Err(Error::other("scanner pause backlog requires native retirement handoff"));
|
||||
}
|
||||
@@ -1720,7 +1913,7 @@ async fn migrate_object_inner(
|
||||
bucket.as_str(),
|
||||
object_info.name.as_str()
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(DataMovementCopy::Committed);
|
||||
}
|
||||
let mut cleanup_opts = data_movement_abort_opts(
|
||||
&object_info,
|
||||
@@ -1778,7 +1971,7 @@ async fn migrate_object_inner(
|
||||
}
|
||||
};
|
||||
let abort_multipart_flag = new_multipart_abort_flag();
|
||||
let multipart_result: Result<()> = async {
|
||||
let multipart_result: Result<DataMovementCopy> = async {
|
||||
let mut parts = vec![CompletePart::default(); object_info.parts.len()];
|
||||
let reader = Arc::new(Mutex::new(rd.stream));
|
||||
for (i, part) in object_info.parts.iter().enumerate() {
|
||||
@@ -1855,6 +2048,23 @@ async fn migrate_object_inner(
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
e2e_barrier::wait(
|
||||
op_label,
|
||||
bucket.as_str(),
|
||||
object_info.name.as_str(),
|
||||
e2e_barrier::STAGE_BEFORE_TARGET_COMMIT,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
data_movement_stage_error(
|
||||
op_label,
|
||||
"complete_multipart_upload",
|
||||
bucket.as_str(),
|
||||
object_info.name.as_str(),
|
||||
Error::from(err),
|
||||
)
|
||||
})?;
|
||||
let mut complete_multipart_opts =
|
||||
data_movement_complete_multipart_opts(&object_info, pool_idx, preserve_part_checksums).map_err(|err| {
|
||||
data_movement_stage_error(
|
||||
@@ -1892,7 +2102,7 @@ async fn migrate_object_inner(
|
||||
)
|
||||
.await
|
||||
{
|
||||
if should_treat_data_movement_overwrite_as_complete(
|
||||
if let Some(copy) = should_treat_data_movement_overwrite_as_complete(
|
||||
store.as_ref(),
|
||||
(pool_idx, target_pool_idx),
|
||||
bucket.as_str(),
|
||||
@@ -1911,7 +2121,7 @@ async fn migrate_object_inner(
|
||||
bucket.as_str(),
|
||||
object_info.name.as_str()
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(copy);
|
||||
}
|
||||
|
||||
error!("{op_label}: complete_multipart_upload err {:?}", &err);
|
||||
@@ -1925,11 +2135,13 @@ async fn migrate_object_inner(
|
||||
}
|
||||
|
||||
mark_multipart_upload_completed(&abort_multipart_flag);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
.await;
|
||||
|
||||
if multipart_result.is_ok() && should_abort_multipart_upload(&abort_multipart_flag) {
|
||||
if let Ok(copy) = multipart_result
|
||||
&& should_abort_multipart_upload(&abort_multipart_flag)
|
||||
{
|
||||
let mut abort_opts = data_movement_abort_opts(
|
||||
&object_info,
|
||||
pool_idx,
|
||||
@@ -1953,9 +2165,9 @@ async fn migrate_object_inner(
|
||||
)
|
||||
.await;
|
||||
match abort_result {
|
||||
Ok(()) => return Ok(()),
|
||||
Ok(()) => return Ok(copy),
|
||||
Err(abort_err) if is_err_invalid_upload_id(&abort_err) => {
|
||||
if should_treat_data_movement_overwrite_as_complete(
|
||||
if let Some(copy) = should_treat_data_movement_overwrite_as_complete(
|
||||
store.as_ref(),
|
||||
(pool_idx, target_pool_idx),
|
||||
bucket.as_str(),
|
||||
@@ -1969,7 +2181,7 @@ async fn migrate_object_inner(
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
return Ok(copy);
|
||||
}
|
||||
return Err(data_movement_stage_error(
|
||||
op_label,
|
||||
@@ -2062,7 +2274,7 @@ async fn migrate_object_inner(
|
||||
return Err(primary_err);
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
return multipart_result;
|
||||
}
|
||||
|
||||
let mut data = data_movement_put_object_reader(bucket.as_str(), &object_info, rd, op_label)?;
|
||||
@@ -2090,7 +2302,7 @@ async fn migrate_object_inner(
|
||||
.await
|
||||
.map_err(|err| data_movement_stage_error(op_label, "prepare_put_object", &bucket, &object_info.name, err))?;
|
||||
if let Err(err) = put_result {
|
||||
if should_treat_data_movement_overwrite_as_complete(
|
||||
if let Some(copy) = should_treat_data_movement_overwrite_as_complete(
|
||||
store.as_ref(),
|
||||
(pool_idx, target_pool_idx),
|
||||
bucket.as_str(),
|
||||
@@ -2109,7 +2321,7 @@ async fn migrate_object_inner(
|
||||
bucket.as_str(),
|
||||
object_info.name.as_str()
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(copy);
|
||||
}
|
||||
|
||||
error!("{op_label}: put_object err {:?}", &err);
|
||||
@@ -2122,7 +2334,7 @@ async fn migrate_object_inner(
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2393,6 +2605,34 @@ mod tests {
|
||||
assert!(matches!(err, SourceCleanupError::SourceChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_cleanup_preflight_requires_vanished_versions_to_be_gone() {
|
||||
let mut marker = cleanup_test_file_info("object.txt", Uuid::from_u128(1), "marker");
|
||||
marker.deleted = true;
|
||||
let mut null_version = cleanup_test_file_info("object.txt", Uuid::nil(), "null");
|
||||
null_version.version_id = None;
|
||||
let expected = cleanup_test_versions(vec![marker.clone(), null_version.clone()]);
|
||||
let vanished = vec![source_cleanup_version_identity(&null_version)];
|
||||
let narrowed = source_cleanup_expected_without_vanished(&expected, &vanished);
|
||||
assert_eq!(narrowed.versions.len(), 1);
|
||||
assert!(matches!(
|
||||
source_cleanup_expected_without_vanished(&expected, &[]),
|
||||
std::borrow::Cow::Borrowed(_)
|
||||
));
|
||||
|
||||
// The unchanged source used to pass the preflight even though the mover
|
||||
// never copied the version it reported as not found.
|
||||
let unchanged = cleanup_test_versions(vec![marker.clone(), null_version]);
|
||||
assert!(source_cleanup_versions_match_with_allowed_missing(&expected, &unchanged, &[]));
|
||||
let err = ensure_source_cleanup_versions_match(&narrowed, &unchanged, &[])
|
||||
.expect_err("a version skipped as not found must not be deleted while it still exists");
|
||||
assert!(matches!(err, SourceCleanupError::SourceChanged));
|
||||
|
||||
let gone = cleanup_test_versions(vec![marker]);
|
||||
ensure_source_cleanup_versions_match(&narrowed, &gone, &[])
|
||||
.expect("a version that really vanished must not block cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_multipart_abort_flag_defaults_to_abort_enabled() {
|
||||
let flag = new_multipart_abort_flag();
|
||||
@@ -2466,6 +2706,54 @@ mod tests {
|
||||
assert!(data_movement_stage_source(&Error::other("plain io error")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_read_quorum_error_keeps_its_source_and_origin() {
|
||||
let original = Error::InsufficientReadQuorum("bucket-a".to_owned(), "object-a".to_owned());
|
||||
let wrapped = data_movement_source_read_error("bucket-a", "object-a", original.clone());
|
||||
let failure = data_movement_stage_source_as::<DataMovementSourceReadFailure>(&wrapped)
|
||||
.expect("the error must retain its source-read origin");
|
||||
let source = std::error::Error::source(failure)
|
||||
.and_then(|source| source.downcast_ref::<Error>())
|
||||
.expect("the original storage error must remain in the error chain");
|
||||
assert_eq!(source.to_string(), original.to_string());
|
||||
assert!(is_data_movement_source_read_error(&wrapped));
|
||||
assert!(matches!(data_movement_stage_source(&wrapped), Some(Error::InsufficientReadQuorum(_, _))));
|
||||
|
||||
assert!(matches!(
|
||||
data_movement_source_read_error("bucket-a", "object-a", Error::FileNotFound),
|
||||
Error::FileNotFound
|
||||
));
|
||||
assert!(matches!(
|
||||
data_movement_source_read_error("bucket-a", "object-a", Error::DiskFull),
|
||||
Error::DiskFull
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_stream_quorum_keeps_its_kind_origin_and_original_error_through_put_stage() {
|
||||
for source_error in [
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, Error::ErasureReadQuorum),
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, DiskError::ErasureReadQuorum),
|
||||
] {
|
||||
let marked = data_movement_source_stream_error(source_error);
|
||||
assert_eq!(marked.kind(), std::io::ErrorKind::InvalidData);
|
||||
let source = Error::from(marked);
|
||||
assert!(is_data_movement_source_read_error(&source));
|
||||
assert_eq!(data_movement_part_upload_failure_stage(&source), "read_part");
|
||||
let wrapped = data_movement_stage_error("decommission_object", "put_object", "bucket-a", "object-a", source);
|
||||
assert!(is_data_movement_source_read_error(&wrapped));
|
||||
// Rebalance unwraps each stage before checking whether quorum is transient.
|
||||
let mut original = &wrapped;
|
||||
while let Some(source) = data_movement_stage_source(original) {
|
||||
original = source;
|
||||
}
|
||||
assert!(matches!(original, Error::ErasureReadQuorum));
|
||||
}
|
||||
let unrelated = data_movement_source_stream_error(std::io::Error::new(std::io::ErrorKind::TimedOut, "source timeout"));
|
||||
assert_eq!(unrelated.kind(), std::io::ErrorKind::TimedOut);
|
||||
assert_eq!(unrelated.to_string(), "source timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_movement_part_stage_error_includes_stage_object_and_part() {
|
||||
let err =
|
||||
@@ -2491,7 +2779,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_data_movement_part_upload_failure_stage_keeps_write_errors() {
|
||||
assert_eq!(data_movement_part_upload_failure_stage(&Error::SlowDown), "put_object_part");
|
||||
for target_error in [
|
||||
Error::SlowDown,
|
||||
Error::ErasureReadQuorum,
|
||||
Error::ErasureWriteQuorum,
|
||||
Error::InsufficientReadQuorum("bucket-a".to_owned(), "object-a".to_owned()),
|
||||
Error::InsufficientWriteQuorum("bucket-a".to_owned(), "object-a".to_owned()),
|
||||
] {
|
||||
assert_eq!(data_movement_part_upload_failure_stage(&target_error), "put_object_part");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3101,6 +3397,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_target_opts_address_the_null_version_exactly() {
|
||||
let nil = Uuid::nil().to_string();
|
||||
assert_eq!(data_movement_exact_version_id(None), nil);
|
||||
assert_eq!(data_movement_exact_version_id(Some(Uuid::nil())), nil);
|
||||
assert_eq!(data_movement_exact_version_id(Some(Uuid::from_u128(9))), Uuid::from_u128(9).to_string());
|
||||
|
||||
// The null version keeps its unversioned write semantics, but the
|
||||
// target precondition must not resolve to a newer version or marker.
|
||||
let null_version = ObjectInfo {
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let put = data_movement_put_object_opts(&null_version, 0);
|
||||
let new_multipart = data_movement_new_multipart_opts(&null_version, 0);
|
||||
let complete =
|
||||
data_movement_complete_multipart_opts(&null_version, 0, false).expect("complete opts should encode metadata");
|
||||
for opts in [&put, &new_multipart, &complete] {
|
||||
assert!(!opts.versioned);
|
||||
assert_eq!(opts.version_id.as_deref(), Some(nil.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_movement_put_opts_do_not_persist_multipart_part_checksums() {
|
||||
let object_info = ObjectInfo {
|
||||
@@ -3215,6 +3534,16 @@ mod tests {
|
||||
same_generation.mod_time = Some(source_time);
|
||||
assert!(!can_replace_stale_data_movement_target(&same_generation, &opts));
|
||||
|
||||
let null_opts = ObjectOptions {
|
||||
versioned: false,
|
||||
version_id: Some(data_movement_exact_version_id(None)),
|
||||
..opts.clone()
|
||||
};
|
||||
let mut null_target = target.clone();
|
||||
null_target.version_id = None;
|
||||
assert!(can_replace_stale_data_movement_target(&null_target, &null_opts));
|
||||
assert!(!can_replace_stale_data_movement_target(&target, &null_opts));
|
||||
|
||||
let mut delete_marker = target;
|
||||
delete_marker.delete_marker = true;
|
||||
assert!(!can_replace_stale_data_movement_target(&delete_marker, &opts));
|
||||
@@ -4258,6 +4587,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The resume adjudication tells a target that holds the source version
|
||||
/// (equivalent, an old node's upload takeover, a legacy checksum target)
|
||||
/// from a newer unversioned object that replaced it, and accepts neither
|
||||
/// when the target's remote tier tuple differs (rustfs/backlog#2873).
|
||||
#[test]
|
||||
fn overwrite_resume_copy_separates_committed_from_superseded_targets() {
|
||||
let classify = |err: &Error, target: &ObjectInfo, source: &ObjectInfo| {
|
||||
resolve_data_movement_overwrite_resume_copy(err, Ok(Some(target.clone())), source, 0, 1, true)
|
||||
.expect("the target should be evaluated")
|
||||
};
|
||||
let overwrite = Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string());
|
||||
let mut source = overwrite_equivalence_source();
|
||||
source.transitioned_object.status = rustfs_filemeta::TRANSITION_COMPLETE.to_string();
|
||||
source.transitioned_object.tier = "WARM".to_string();
|
||||
source.transitioned_object.name = "remote/object".to_string();
|
||||
source.transitioned_object.version_id = "remote-version".to_string();
|
||||
|
||||
assert_eq!(classify(&overwrite, &source, &source), Some(DataMovementCopy::Committed));
|
||||
|
||||
let mut legacy = source.clone();
|
||||
let mut legacy_parts = legacy.parts.as_ref().clone();
|
||||
legacy_parts[0].checksums = None;
|
||||
legacy.parts = Arc::new(legacy_parts);
|
||||
let legacy_metadata = Arc::make_mut(&mut legacy.user_defined);
|
||||
rustfs_utils::http::insert_str(legacy_metadata, SUFFIX_DATA_MOVED, "true".to_string());
|
||||
rustfs_utils::http::insert_str(legacy_metadata, SUFFIX_DATA_MOVED_TAGS, "v1:tag=value".to_string());
|
||||
assert_eq!(classify(&overwrite, &legacy, &source), Some(DataMovementCopy::Committed));
|
||||
|
||||
let mut takeover_source = source.clone();
|
||||
let mut takeover_parts = takeover_source.parts.as_ref().clone();
|
||||
takeover_parts[0].checksums = None;
|
||||
takeover_source.parts = Arc::new(takeover_parts);
|
||||
let mut takeover = ObjectInfo {
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
..takeover_source.clone()
|
||||
};
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut takeover.user_defined),
|
||||
SUFFIX_DATA_MOVEMENT_UPLOAD,
|
||||
data_movement_upload_identity(&takeover_source),
|
||||
);
|
||||
let invalid_upload = Error::InvalidUploadID("bucket".to_string(), "object".to_string(), "upload-id".to_string());
|
||||
assert_eq!(classify(&invalid_upload, &takeover, &takeover_source), Some(DataMovementCopy::Committed));
|
||||
|
||||
for (err, target, target_source) in [
|
||||
(&overwrite, &source, &source),
|
||||
(&overwrite, &legacy, &source),
|
||||
(&invalid_upload, &takeover, &takeover_source),
|
||||
] {
|
||||
let mut other_remote = target.clone();
|
||||
other_remote.transitioned_object.name = "remote/other".to_string();
|
||||
assert_eq!(
|
||||
classify(err, &other_remote, target_source),
|
||||
None,
|
||||
"a different remote tuple is not a copy"
|
||||
);
|
||||
}
|
||||
|
||||
let mut null_source = source;
|
||||
null_source.version_id = None;
|
||||
let newer_null = ObjectInfo {
|
||||
etag: Some("etag-client-write".to_string()),
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
transitioned_object: Default::default(),
|
||||
..null_source.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
classify(&Error::PreconditionFailed, &newer_null, &null_source),
|
||||
Some(DataMovementCopy::Superseded)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&overwrite, &newer_null, &null_source),
|
||||
None,
|
||||
"only a precondition conflict can be superseded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overwrite_error_rejects_newer_unversioned_target() {
|
||||
let source = ObjectInfo {
|
||||
|
||||
@@ -50,11 +50,43 @@ impl ScannerPauseBacklogRetirementError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same native planner runs before repair and after each durable phase.
|
||||
/// Keep this context bounded without exposing replica payloads or revisions.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum ScannerPauseBacklogRetirementPhase {
|
||||
InitialRead,
|
||||
SeedReadback,
|
||||
CommitReadback,
|
||||
StabilizeReadback,
|
||||
FinalCheck,
|
||||
}
|
||||
|
||||
impl ScannerPauseBacklogRetirementPhase {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::InitialRead => "initial_read",
|
||||
Self::SeedReadback => "seed_readback",
|
||||
Self::CommitReadback => "commit_readback",
|
||||
Self::StabilizeReadback => "stabilize_readback",
|
||||
Self::FinalCheck => "final_check",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn planner_error(self, err: ScannerPauseBacklogRetirementError) -> Error {
|
||||
data_movement_context_error(
|
||||
format!("scanner pause backlog retirement planner failed during {}: {err}", self.as_str()),
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded, storage-fenced native replica. Only a confirmed missing object
|
||||
/// has no payload; read failures never enter the Scanner verifier.
|
||||
pub struct ScannerPauseBacklogRetirementReplica {
|
||||
pub pool_index: usize,
|
||||
pub set_index: usize,
|
||||
/// False for retiring members retained only to verify the old commit proof.
|
||||
pub writable: bool,
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -104,10 +136,11 @@ impl ScannerPauseBacklogRetirementRead {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetirementRead> {
|
||||
async fn read_replica((set, writable): (Arc<SetDisks>, bool)) -> Result<ScannerPauseBacklogRetirementRead> {
|
||||
let mut replica = ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: set.pool_index,
|
||||
set_index: set.set_index,
|
||||
writable,
|
||||
data: None,
|
||||
};
|
||||
let opts = ObjectOptions {
|
||||
@@ -156,7 +189,7 @@ async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetiremen
|
||||
pub(crate) async fn read_scanner_pause_backlog_retirement_replicas(
|
||||
source_pool_index: usize,
|
||||
source_set_index: usize,
|
||||
sets: Vec<Arc<SetDisks>>,
|
||||
sets: Vec<(Arc<SetDisks>, bool)>,
|
||||
) -> Result<Vec<ScannerPauseBacklogRetirementRead>> {
|
||||
let replicas = join_all(sets.into_iter().map(read_replica))
|
||||
.await
|
||||
@@ -170,23 +203,30 @@ pub(crate) async fn read_scanner_pause_backlog_retirement_replicas(
|
||||
Ok(replicas)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("scanner pause backlog native retirement planner is unavailable during {phase}")]
|
||||
struct ScannerPauseBacklogPlannerUnavailable {
|
||||
phase: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) fn plan_scanner_pause_backlog_retirement(
|
||||
source_pool_index: usize,
|
||||
replicas: &[ScannerPauseBacklogRetirementRead],
|
||||
phase: ScannerPauseBacklogRetirementPhase,
|
||||
) -> Result<Option<ScannerPauseBacklogRetirementPlan>> {
|
||||
let planner = RETIREMENT_PLANNER
|
||||
.get()
|
||||
.ok_or_else(|| Error::other("scanner pause backlog native retirement planner is unavailable"))?;
|
||||
.ok_or_else(|| Error::other(ScannerPauseBacklogPlannerUnavailable { phase: phase.as_str() }))?;
|
||||
let snapshots = replicas
|
||||
.iter()
|
||||
.map(|read| ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: read.replica.pool_index,
|
||||
set_index: read.replica.set_index,
|
||||
writable: read.replica.writable,
|
||||
data: read.replica.data.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
planner(source_pool_index, &snapshots)
|
||||
.map_err(|err| data_movement_context_error(format!("scanner pause backlog retirement planner failed: {err}"), err))
|
||||
planner(source_pool_index, &snapshots).map_err(|err| phase.planner_error(err))
|
||||
}
|
||||
|
||||
/// The native writer and retirement handoff use the same conditional, full-tail
|
||||
@@ -315,3 +355,138 @@ pub mod test_util {
|
||||
Ok(Some(fault))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data_movement::data_movement_stage_source_as;
|
||||
|
||||
fn failing_planner(
|
||||
source_pool_index: usize,
|
||||
replicas: &[ScannerPauseBacklogRetirementReplica],
|
||||
) -> std::result::Result<Option<ScannerPauseBacklogRetirementPlan>, ScannerPauseBacklogRetirementError> {
|
||||
assert!(replicas.is_empty(), "the callback fixture needs no storage replicas");
|
||||
Err(match source_pool_index {
|
||||
0 => ScannerPauseBacklogRetirementError::HandoffConflict {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
1 => ScannerPauseBacklogRetirementError::AuthorityConflict {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
2 => ScannerPauseBacklogRetirementError::InvalidRecord {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
_ => panic!("unexpected planner fixture source {source_pool_index}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retirement_planner_phase_context_preserves_every_typed_error() {
|
||||
const TEST_NAME: &str =
|
||||
"data_movement::scanner_backlog::tests::retirement_planner_phase_context_preserves_every_typed_error";
|
||||
const CHILD: &str = "RUSTFS_TEST_RETIREMENT_PLANNER_PHASE_CHILD";
|
||||
if std::env::var(CHILD).ok().as_deref() != Some(TEST_NAME) {
|
||||
// Registration is process-global and must not replace another test's planner.
|
||||
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args(["--exact", TEST_NAME, "--nocapture", "--color", "never"])
|
||||
.env(CHILD, TEST_NAME)
|
||||
.output()
|
||||
.expect("start isolated retirement planner test");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let one_passed = stdout
|
||||
.lines()
|
||||
.any(|line| line.starts_with("test result: ok. 1 passed; 0 failed;"));
|
||||
assert!(
|
||||
output.status.success() && one_passed,
|
||||
"isolated planner regression must execute exactly one passing test: {stdout}\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let phases = [
|
||||
(ScannerPauseBacklogRetirementPhase::InitialRead, "initial_read"),
|
||||
(ScannerPauseBacklogRetirementPhase::SeedReadback, "seed_readback"),
|
||||
(ScannerPauseBacklogRetirementPhase::CommitReadback, "commit_readback"),
|
||||
(ScannerPauseBacklogRetirementPhase::StabilizeReadback, "stabilize_readback"),
|
||||
(ScannerPauseBacklogRetirementPhase::FinalCheck, "final_check"),
|
||||
];
|
||||
for (phase, label) in phases {
|
||||
let unavailable = match plan_scanner_pause_backlog_retirement(0, &[], phase) {
|
||||
Ok(_) => panic!("unregistered planner must fail during {label}"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
unavailable.to_string(),
|
||||
format!("Io error: scanner pause backlog native retirement planner is unavailable during {label}"),
|
||||
);
|
||||
let Error::Io(io_error) = &unavailable else {
|
||||
panic!("unregistered planner must retain its IO error boundary during {label}");
|
||||
};
|
||||
let recovered = io_error
|
||||
.get_ref()
|
||||
.and_then(|cause| cause.downcast_ref::<ScannerPauseBacklogPlannerUnavailable>())
|
||||
.expect("unregistered planner must retain its typed cause");
|
||||
assert_eq!(recovered.phase, label);
|
||||
assert!(data_movement_stage_source_as::<ScannerPauseBacklogRetirementError>(&unavailable).is_none());
|
||||
let same_failure = plan_scanner_pause_backlog_retirement(0, &[], phase)
|
||||
.err()
|
||||
.expect("a second unregistered call must fail in the same phase");
|
||||
let errors = [
|
||||
Some(
|
||||
unavailable
|
||||
.narrow_to_disk()
|
||||
.expect("planner IO error must cross the disk boundary"),
|
||||
),
|
||||
Some(
|
||||
same_failure
|
||||
.narrow_to_disk()
|
||||
.expect("same-phase planner IO error must cross the disk boundary"),
|
||||
),
|
||||
];
|
||||
let (count, _) = crate::disk::error_reduce::reduce_errs(&errors, &[]);
|
||||
assert_eq!(count, 2, "same-phase planner failures must share one quorum bucket");
|
||||
}
|
||||
register_scanner_pause_backlog_retirement_planner(failing_planner);
|
||||
for (phase, label) in phases {
|
||||
for (source_pool_index, source) in [
|
||||
ScannerPauseBacklogRetirementError::HandoffConflict {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
ScannerPauseBacklogRetirementError::AuthorityConflict {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
ScannerPauseBacklogRetirementError::InvalidRecord {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let discriminant = std::mem::discriminant(&source);
|
||||
let retryable = source.is_retryable_handoff_conflict();
|
||||
let original = source.to_string();
|
||||
let error = match plan_scanner_pause_backlog_retirement(source_pool_index, &[], phase) {
|
||||
Ok(_) => panic!("the registered planner fixture must fail during {label}"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!("Io error: scanner pause backlog retirement planner failed during {label}: {original}")
|
||||
);
|
||||
let recovered = data_movement_stage_source_as::<ScannerPauseBacklogRetirementError>(&error)
|
||||
.expect("the worker classifier must still reach the original native error");
|
||||
assert_eq!(std::mem::discriminant(recovered), discriminant);
|
||||
assert_eq!(recovered.to_string(), original);
|
||||
assert_eq!(recovered.is_retryable_handoff_conflict(), retryable);
|
||||
let mut source = std::error::Error::source(&error);
|
||||
let mut found_native = false;
|
||||
while let Some(cause) = source {
|
||||
found_native |= cause.downcast_ref::<ScannerPauseBacklogRetirementError>().is_some();
|
||||
source = cause.source();
|
||||
}
|
||||
assert!(found_native, "the standard source chain must retain the native error during {label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ pub const DATA_USAGE_ROOT: &str = SLASH_SEPARATOR;
|
||||
const DATA_COMPRESSION_TOTAL_NAME: &str = ".compression.json";
|
||||
pub const DATA_USAGE_CACHE_NAME: &str = ".usage-cache.bin";
|
||||
const DATA_USAGE_CACHE_TTL_SECS: u64 = 30;
|
||||
// A scanner save invalidates only the saving process's snapshot cache; every
|
||||
// other node sees it only after reloading. A fail-closed usage lookup for a
|
||||
// bucket the cached snapshot does not mention at all may reload a copy this
|
||||
// old, so a bucket created after the last load is not reported unknown for the
|
||||
// rest of the TTL once the scanner leader has published it. Such early
|
||||
// reloads are spaced by the same interval whatever their outcome.
|
||||
const DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS: u64 = 5;
|
||||
const LIVE_BUCKET_USAGE_MAX_ENTRIES: u64 = 1024;
|
||||
const DATA_USAGE_REMOVE_CAS_RETRIES: usize = 3;
|
||||
|
||||
@@ -169,6 +176,9 @@ static USAGE_CACHE_UPDATING: OnceLock<CacheUpdating> = OnceLock::new();
|
||||
static LIVE_BUCKET_USAGE_CACHE: OnceLock<LiveBucketUsageCache> = OnceLock::new();
|
||||
static USAGE_MEMORY_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Compatibility input for snapshot-protocol quota admission and capacity
|
||||
/// reporting. Durable quota checks and statistics do not consult this floor.
|
||||
///
|
||||
/// Best-available persisted usage for `bucket` when no authoritative source
|
||||
/// exists yet (issue #5716): after an upgrade from a pre-v2 release the only
|
||||
/// persisted usage data is the legacy `.usage.json`, which is demoted to
|
||||
@@ -188,7 +198,7 @@ static USAGE_MEMORY_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
/// authoritative snapshot first, backfilled per bucket from the observed
|
||||
/// (nonconverged) snapshot for buckets no authoritative cycle has covered
|
||||
/// yet (issue #6852). Loads go through the TTL-bounded
|
||||
/// snapshot cache, so the quota path adds at most one backend read per
|
||||
/// snapshot cache, so this lookup adds at most one backend read per
|
||||
/// [`DATA_USAGE_CACHE_TTL_SECS`] window. Returns `None` for buckets absent
|
||||
/// from every persisted snapshot — those still fail closed.
|
||||
pub async fn lookup_degraded_bucket_usage_baseline(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
@@ -221,7 +231,8 @@ struct CachedDataUsageSnapshot {
|
||||
loaded_at: tokio::time::Instant,
|
||||
/// Pre-discard per-bucket sizes from the same load, retained even when the
|
||||
/// snapshot is incomplete and its bucket data is discarded. Consumed only
|
||||
/// by [`lookup_degraded_bucket_usage_baseline`] for quota admission.
|
||||
/// by [`lookup_degraded_bucket_usage_baseline`] for snapshot-protocol
|
||||
/// admission and capacity reporting until their compatibility windows end.
|
||||
degraded_baseline: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
@@ -1244,8 +1255,8 @@ async fn load_data_usage_from_backend_with_baseline(store: Arc<ECStore>) -> Resu
|
||||
// gate ties the observation to this exact authoritative snapshot, so a
|
||||
// stale observed object left behind by an earlier incarnation (e.g. a
|
||||
// deleted and recreated bucket) cannot inject ghost usage. Loads sit
|
||||
// behind the same TTL cache as the snapshot itself, so this adds at most
|
||||
// one backend read per TTL window.
|
||||
// behind the same cache as the snapshot itself, so this adds one backend
|
||||
// read per snapshot refresh.
|
||||
let mut observed_unavailable = false;
|
||||
match load_observed_data_usage_snapshot(store).await {
|
||||
Ok(Some(observed)) if observed_data_usage_is_newer(&observed, &authoritative_as_persisted) => {
|
||||
@@ -1470,8 +1481,10 @@ async fn normalize_loaded_data_usage(
|
||||
///
|
||||
/// Admin read endpoints call this on every request; the cache bounds backend
|
||||
/// reads (and the associated JSON parse and INFO log) to once per
|
||||
/// `DATA_USAGE_CACHE_TTL_SECS` per process. `save_data_usage_in_backend`
|
||||
/// invalidates the cache so a fresh scanner save is visible immediately.
|
||||
/// `DATA_USAGE_CACHE_TTL_SECS` per process, apart from the spaced early
|
||||
/// reloads of [`reload_unknown_bucket_usage`].
|
||||
/// `save_data_usage_in_backend` invalidates the cache so a fresh scanner save
|
||||
/// is visible immediately in the saving process.
|
||||
pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<DataUsageInfo, Error> {
|
||||
let ttl = Duration::from_secs(DATA_USAGE_CACHE_TTL_SECS);
|
||||
|
||||
@@ -2260,10 +2273,149 @@ pub async fn record_bucket_delete_marker_memory(bucket: &str) {
|
||||
/// Get bucket usage from the authoritative cache for this topology.
|
||||
async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
|
||||
let store = runtime_sources::object_store_handle()?;
|
||||
persisted_bucket_usage(store, bucket).await
|
||||
}
|
||||
|
||||
async fn persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
let info = load_data_usage_from_backend_cached(store).await.ok()?;
|
||||
info.buckets_usage.get(bucket).map(|usage| usage.size)
|
||||
}
|
||||
|
||||
async fn reload_persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
let loader_store = Arc::clone(&store);
|
||||
if !reload_snapshot_for_unknown_bucket(bucket, move || load_data_usage_from_backend_with_baseline(loader_store)).await {
|
||||
return None;
|
||||
}
|
||||
persisted_bucket_usage(store, bucket).await
|
||||
}
|
||||
|
||||
/// Last early snapshot reload attempted for a bucket the cached snapshot did
|
||||
/// not cover. Attempts are spaced whatever their outcome, so a failing backend
|
||||
/// or a bucket the scanner never reaches costs at most one load per interval.
|
||||
static UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT: std::sync::Mutex<Option<tokio::time::Instant>> = std::sync::Mutex::new(None);
|
||||
|
||||
fn unknown_bucket_reload_due(cache: &Option<CachedDataUsageSnapshot>, bucket: &str, now: tokio::time::Instant) -> bool {
|
||||
let refresh_age = Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS);
|
||||
// A cached failure keeps its TTL backoff. A bucket the degraded baseline
|
||||
// covers is served from it; reloading would only re-read a snapshot whose
|
||||
// bucket data is still incomplete.
|
||||
let Some(cached) = cache.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(info) = cached.info.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if info.buckets_usage.contains_key(bucket)
|
||||
|| cached.degraded_baseline.contains_key(bucket)
|
||||
|| now.duration_since(cached.loaded_at) < refresh_age
|
||||
{
|
||||
return false;
|
||||
}
|
||||
UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.is_none_or(|attempted_at| now.duration_since(attempted_at) >= refresh_age)
|
||||
}
|
||||
|
||||
/// Whether a freshly loaded snapshot is older than the cached one, as when a
|
||||
/// primary read fails and the loader falls back to the previous `.bkp` copy.
|
||||
fn snapshot_predates(loaded: Option<SystemTime>, cached: Option<SystemTime>) -> bool {
|
||||
match (loaded, cached) {
|
||||
(Some(loaded), Some(cached)) => loaded < cached,
|
||||
(None, Some(_)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the cached persisted snapshot ahead of its TTL when it does not
|
||||
/// cover `bucket` at all. Only the scanner leader's own save invalidates its
|
||||
/// cache, so on every other node a bucket created after the last load would
|
||||
/// otherwise stay unknown for the rest of the TTL. A failed load, or one older
|
||||
/// than the cached snapshot, never replaces it. Returns whether the cache may
|
||||
/// now cover `bucket`.
|
||||
///
|
||||
/// Lock order: `DATA_USAGE_SNAPSHOT_REFRESH`, then the snapshot cache, then the
|
||||
/// attempt stamp, which is never held across an await.
|
||||
async fn reload_snapshot_for_unknown_bucket<F, Fut>(bucket: &str, load: F) -> bool
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = Result<LoadedUsageBaseline, Error>>,
|
||||
{
|
||||
if !unknown_bucket_reload_due(&*data_usage_snapshot_cache().read().await, bucket, tokio::time::Instant::now()) {
|
||||
return false;
|
||||
}
|
||||
let _refresh_guard = DATA_USAGE_SNAPSHOT_REFRESH
|
||||
.get_or_init(|| Arc::new(TokioMutex::new(())))
|
||||
.lock()
|
||||
.await;
|
||||
let now = tokio::time::Instant::now();
|
||||
if !unknown_bucket_reload_due(&*data_usage_snapshot_cache().read().await, bucket, now) {
|
||||
// Another caller reloaded, or attempted to, while this one waited.
|
||||
return true;
|
||||
}
|
||||
*UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(now);
|
||||
|
||||
let refresh_generation = data_usage_snapshot_generation();
|
||||
let Ok(loaded) = load().await else {
|
||||
return false;
|
||||
};
|
||||
let mut cache = data_usage_snapshot_cache().write().await;
|
||||
let cached_last_update = cache
|
||||
.as_ref()
|
||||
.and_then(|cached| cached.info.as_ref())
|
||||
.and_then(|info| info.last_update);
|
||||
if snapshot_predates(loaded.info.last_update, cached_last_update) {
|
||||
return false;
|
||||
}
|
||||
cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
Ok(loaded),
|
||||
tokio::time::Instant::now(),
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
// Calls of `reload_unknown_bucket_usage` per bucket. Unit tests do not run in
|
||||
// a distributed deployment, where the reload itself happens, so this is how
|
||||
// they tell which lookup a quota caller took.
|
||||
#[cfg(test)]
|
||||
static RELOAD_UNKNOWN_BUCKET_USAGE_CALLS: LazyLock<std::sync::Mutex<HashMap<String, usize>>> = LazyLock::new(Default::default);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reload_unknown_bucket_usage_calls_for_test(bucket: &str) -> usize {
|
||||
RELOAD_UNKNOWN_BUCKET_USAGE_CALLS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(bucket)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Second lookup for a caller that fails closed after [`get_bucket_usage_memory`]
|
||||
/// found no usage: in a distributed deployment, reload the cached persisted
|
||||
/// snapshot early when it does not cover `bucket` at all, then look the bucket
|
||||
/// up again. The reload is awaited and is a full snapshot load, so reporting
|
||||
/// paths do not use this.
|
||||
pub async fn reload_unknown_bucket_usage(bucket: &str) -> Option<u64> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
*RELOAD_UNKNOWN_BUCKET_USAGE_CALLS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.entry(bucket.to_string())
|
||||
.or_default() += 1;
|
||||
}
|
||||
if !runtime_sources::setup_is_dist_erasure().await {
|
||||
return None;
|
||||
}
|
||||
let store = runtime_sources::object_store_handle()?;
|
||||
reload_persisted_bucket_usage(store, bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_bucket_usage_memory(bucket: &str) -> Option<u64> {
|
||||
// A process-local mutation cache is authoritative only when every request
|
||||
// is handled by this process. In a distributed deployment it contains an
|
||||
@@ -3992,6 +4144,384 @@ mod tests {
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
async fn set_data_usage_snapshot_cache_age_for_test(age: Duration) {
|
||||
let mut cache = data_usage_snapshot_cache().write().await;
|
||||
let cached = cache.as_mut().expect("the snapshot cache must hold a load");
|
||||
cached.loaded_at = tokio::time::Instant::now()
|
||||
.checked_sub(age)
|
||||
.expect("the monotonic clock must reach back by the requested age");
|
||||
}
|
||||
|
||||
async fn install_data_usage_snapshot_cache_for_test(info: DataUsageInfo, age: Duration) {
|
||||
let degraded_baseline = info.bucket_sizes.clone();
|
||||
*data_usage_snapshot_cache().write().await = Some(CachedDataUsageSnapshot {
|
||||
info: Some(info),
|
||||
loaded_at: tokio::time::Instant::now()
|
||||
.checked_sub(age)
|
||||
.expect("the monotonic clock must reach back by the requested age"),
|
||||
degraded_baseline,
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_unknown_bucket_reload_attempt_for_test() {
|
||||
*UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
||||
}
|
||||
|
||||
fn usage_snapshot_with_new_bucket(last_update: SystemTime, existing_size: u64) -> DataUsageInfo {
|
||||
let mut info = data_usage_info_for_test("existing", 2, existing_size, last_update);
|
||||
info.buckets_usage.insert(
|
||||
"created-later".to_string(),
|
||||
BucketUsageInfo {
|
||||
objects_count: 1,
|
||||
versions_count: 1,
|
||||
size: 1024,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
info.bucket_sizes.insert("created-later".to_string(), 1024);
|
||||
info.buckets_count = info.buckets_usage.len() as u64;
|
||||
info.calculate_totals();
|
||||
info
|
||||
}
|
||||
|
||||
fn loaded_usage_for_test(info: DataUsageInfo) -> LoadedUsageBaseline {
|
||||
LoadedUsageBaseline {
|
||||
degraded_baseline: info.bucket_sizes.clone(),
|
||||
info,
|
||||
observed_unavailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
// The fail-closed composition the quota checker uses in a distributed deployment.
|
||||
async fn fail_closed_lookup_for_test(store: &Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
match persisted_bucket_usage(Arc::clone(store), bucket).await {
|
||||
Some(usage) => Some(usage),
|
||||
None => reload_persisted_bucket_usage(Arc::clone(store), bucket).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cached_bucket_size_for_test(bucket: &str) -> Option<u64> {
|
||||
data_usage_snapshot_cache()
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.and_then(|cached| cached.info.as_ref())
|
||||
.and_then(|info| info.buckets_usage.get(bucket))
|
||||
.map(|usage| usage.size)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn unknown_bucket_lookup_reloads_a_snapshot_another_node_published() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::save_config;
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let before = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(1));
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&before).expect("encode usage"),
|
||||
)
|
||||
.await
|
||||
.expect("persist usage that predates the new bucket");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
assert_eq!(fail_closed_lookup_for_test(&store, "existing").await, Some(10));
|
||||
|
||||
// The scanner leader on another node publishes a snapshot covering the
|
||||
// new bucket. Its save does not invalidate this process's cache.
|
||||
let after = usage_snapshot_with_new_bucket(SystemTime::UNIX_EPOCH + Duration::from_secs(2), 20);
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&after).expect("encode usage"),
|
||||
)
|
||||
.await
|
||||
.expect("persist the later snapshot");
|
||||
|
||||
// A fresh copy is reused even for an unknown bucket, which bounds reloads.
|
||||
set_data_usage_snapshot_cache_age_for_test(Duration::ZERO).await;
|
||||
assert_eq!(fail_closed_lookup_for_test(&store, "created-later").await, None);
|
||||
|
||||
set_data_usage_snapshot_cache_age_for_test(Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
|
||||
assert_eq!(
|
||||
fail_closed_lookup_for_test(&store, "existing").await,
|
||||
Some(10),
|
||||
"a bucket the cached copy covers keeps the cached value for the full TTL"
|
||||
);
|
||||
assert_eq!(
|
||||
persisted_bucket_usage(store.clone(), "created-later").await,
|
||||
None,
|
||||
"a reporting lookup never reloads early"
|
||||
);
|
||||
assert_eq!(
|
||||
fail_closed_lookup_for_test(&store, "created-later").await,
|
||||
Some(1024),
|
||||
"an unknown bucket must reload an aged copy instead of waiting out the TTL"
|
||||
);
|
||||
assert_eq!(persisted_bucket_usage(store, "existing").await, Some(20));
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn degraded_baseline_bucket_lookup_does_not_reload_the_snapshot() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::save_config;
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let mut incomplete = data_usage_info_for_test("legacy", 1, 7, SystemTime::UNIX_EPOCH + Duration::from_secs(1));
|
||||
incomplete.usage_snapshot_complete = false;
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&incomplete).expect("encode usage"),
|
||||
)
|
||||
.await
|
||||
.expect("persist an incomplete snapshot");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
assert_eq!(fail_closed_lookup_for_test(&store, "legacy").await, None);
|
||||
assert_eq!(lookup_degraded_bucket_usage_baseline(store.clone(), "legacy").await, Some(7));
|
||||
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&data_usage_info_for_test("legacy", 2, 9, SystemTime::UNIX_EPOCH + Duration::from_secs(2)))
|
||||
.expect("encode usage"),
|
||||
)
|
||||
.await
|
||||
.expect("persist the later complete snapshot");
|
||||
set_data_usage_snapshot_cache_age_for_test(Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
|
||||
assert_eq!(fail_closed_lookup_for_test(&store, "legacy").await, None);
|
||||
assert_eq!(
|
||||
lookup_degraded_bucket_usage_baseline(store, "legacy").await,
|
||||
Some(7),
|
||||
"a bucket the degraded baseline covers must not reload the snapshot early"
|
||||
);
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn early_reload_keeps_the_cached_snapshot_when_the_backend_read_fails() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::save_config;
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let current = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(¤t).expect("encode usage"),
|
||||
)
|
||||
.await
|
||||
.expect("persist the current snapshot");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
assert_eq!(fail_closed_lookup_for_test(&store, "existing").await, Some(10));
|
||||
|
||||
// The primary becomes unreadable and there is no backup.
|
||||
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), b"not a usage snapshot".to_vec())
|
||||
.await
|
||||
.expect("corrupt the primary snapshot");
|
||||
set_data_usage_snapshot_cache_age_for_test(Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
|
||||
assert_eq!(fail_closed_lookup_for_test(&store, "unknown").await, None);
|
||||
let cached = load_data_usage_from_backend_cached(store)
|
||||
.await
|
||||
.expect("a failed early reload must keep the cached snapshot");
|
||||
assert_eq!(cached.buckets_usage.get("existing").map(|usage| usage.size), Some(10));
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn early_reload_keeps_the_cached_snapshot_when_the_backend_regresses_to_an_older_backup() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::save_config;
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let current = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(¤t).expect("encode usage"),
|
||||
)
|
||||
.await
|
||||
.expect("persist the current snapshot");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
assert_eq!(fail_closed_lookup_for_test(&store, "existing").await, Some(10));
|
||||
|
||||
// The primary becomes unreadable and the loader falls back to an older
|
||||
// backup that would otherwise expose the unknown bucket.
|
||||
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), b"not a usage snapshot".to_vec())
|
||||
.await
|
||||
.expect("corrupt the primary snapshot");
|
||||
let older = usage_snapshot_with_new_bucket(SystemTime::UNIX_EPOCH + Duration::from_secs(1), 5);
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_BACKUP_PATH.as_str(),
|
||||
serde_json::to_vec(&older).expect("encode usage"),
|
||||
)
|
||||
.await
|
||||
.expect("persist an older backup snapshot");
|
||||
set_data_usage_snapshot_cache_age_for_test(Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
|
||||
assert_eq!(
|
||||
fail_closed_lookup_for_test(&store, "created-later").await,
|
||||
None,
|
||||
"an older snapshot must not replace the cached one"
|
||||
);
|
||||
let cached = load_data_usage_from_backend_cached(store)
|
||||
.await
|
||||
.expect("an older early reload must keep the cached snapshot");
|
||||
assert_eq!(cached.buckets_usage.get("existing").map(|usage| usage.size), Some(10));
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn early_reload_rejects_a_snapshot_without_last_update() {
|
||||
// With both the primary and the `.bkp` copy missing, the loader returns
|
||||
// an empty snapshot without `last_update`. Installing it over a good
|
||||
// copy would make every bucket unknown and fail snapshot-protocol
|
||||
// admission closed.
|
||||
let cached = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
|
||||
install_data_usage_snapshot_cache_for_test(cached, Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
|
||||
assert!(
|
||||
!reload_snapshot_for_unknown_bucket("created-later", || async {
|
||||
Ok(loaded_usage_for_test(DataUsageInfo::default()))
|
||||
})
|
||||
.await
|
||||
);
|
||||
assert_eq!(
|
||||
cached_bucket_size_for_test("existing").await,
|
||||
Some(10),
|
||||
"a snapshot without last_update must not replace a dated one"
|
||||
);
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn early_reload_accepts_a_snapshot_with_the_same_last_update() {
|
||||
let last_update = SystemTime::UNIX_EPOCH + Duration::from_secs(2);
|
||||
let cached = data_usage_info_for_test("existing", 1, 10, last_update);
|
||||
install_data_usage_snapshot_cache_for_test(cached, Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
|
||||
let same_age = usage_snapshot_with_new_bucket(last_update, 10);
|
||||
assert!(reload_snapshot_for_unknown_bucket("created-later", || async { Ok(loaded_usage_for_test(same_age)) }).await);
|
||||
assert_eq!(
|
||||
cached_bucket_size_for_test("created-later").await,
|
||||
Some(1024),
|
||||
"a snapshot as recent as the cached one must be accepted"
|
||||
);
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn early_reload_attempts_are_spaced_whatever_their_outcome() {
|
||||
let refresh_age = Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS);
|
||||
let cached = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
|
||||
install_data_usage_snapshot_cache_for_test(cached, refresh_age).await;
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
let loads = AtomicUsize::new(0);
|
||||
|
||||
assert!(
|
||||
!reload_snapshot_for_unknown_bucket("created-later", || async {
|
||||
loads.fetch_add(1, Ordering::SeqCst);
|
||||
Err(Error::ErasureReadQuorum)
|
||||
})
|
||||
.await
|
||||
);
|
||||
assert_eq!(cached_bucket_size_for_test("existing").await, Some(10));
|
||||
|
||||
let newer = usage_snapshot_with_new_bucket(SystemTime::UNIX_EPOCH + Duration::from_secs(3), 20);
|
||||
assert!(
|
||||
!reload_snapshot_for_unknown_bucket("created-later", || async {
|
||||
loads.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(loaded_usage_for_test(newer.clone()))
|
||||
})
|
||||
.await
|
||||
);
|
||||
assert_eq!(loads.load(Ordering::SeqCst), 1, "a failed attempt must still space the next one");
|
||||
|
||||
*UNKNOWN_BUCKET_RELOAD_ATTEMPTED_AT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = tokio::time::Instant::now().checked_sub(refresh_age);
|
||||
assert!(
|
||||
reload_snapshot_for_unknown_bucket("created-later", || async {
|
||||
loads.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(loaded_usage_for_test(newer.clone()))
|
||||
})
|
||||
.await
|
||||
);
|
||||
assert_eq!(loads.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(cached_bucket_size_for_test("created-later").await, Some(1024));
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn concurrent_unknown_and_known_lookups_load_the_snapshot_once() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
|
||||
// Nothing is persisted, so any real backend load would replace the
|
||||
// installed snapshot with an empty one and fail the final assertions.
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let cached = data_usage_info_for_test("existing", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
|
||||
install_data_usage_snapshot_cache_for_test(cached, Duration::from_secs(DATA_USAGE_UNKNOWN_BUCKET_REFRESH_SECS)).await;
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
let loads = Arc::new(AtomicUsize::new(0));
|
||||
let newer = usage_snapshot_with_new_bucket(SystemTime::UNIX_EPOCH + Duration::from_secs(3), 10);
|
||||
|
||||
let start = Arc::new(tokio::sync::Barrier::new(16));
|
||||
let mut unknown_lookups = Vec::new();
|
||||
let mut known_lookups = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let (unknown_start, loads, newer) = (Arc::clone(&start), Arc::clone(&loads), newer.clone());
|
||||
unknown_lookups.push(tokio::spawn(async move {
|
||||
unknown_start.wait().await;
|
||||
reload_snapshot_for_unknown_bucket("created-later", move || async move {
|
||||
loads.fetch_add(1, Ordering::SeqCst);
|
||||
// Hold the refresh lock long enough for the other lookups to queue.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
Ok(loaded_usage_for_test(newer))
|
||||
})
|
||||
.await
|
||||
}));
|
||||
let (known_start, store) = (Arc::clone(&start), Arc::clone(&store));
|
||||
known_lookups.push(tokio::spawn(async move {
|
||||
known_start.wait().await;
|
||||
fail_closed_lookup_for_test(&store, "existing").await
|
||||
}));
|
||||
}
|
||||
for lookup in unknown_lookups {
|
||||
lookup.await.expect("unknown-bucket lookup task");
|
||||
}
|
||||
for lookup in known_lookups {
|
||||
assert_eq!(lookup.await.expect("known-bucket lookup task"), Some(10));
|
||||
}
|
||||
assert_eq!(loads.load(Ordering::SeqCst), 1, "concurrent lookups must share one backend load");
|
||||
assert_eq!(persisted_bucket_usage(store, "created-later").await, Some(1024));
|
||||
clear_unknown_bucket_reload_attempt_for_test();
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_save_cleanup_removes_observed_snapshot_best_effort() {
|
||||
let store = UsageCasStore::default();
|
||||
@@ -5831,6 +6361,43 @@ mod tests {
|
||||
assert_eq!(get_bucket_usage_memory("bucket-a").await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn complete_scanner_cycle_with_carried_bucket_preserves_legacy_quota_floor() {
|
||||
clear_usage_memory_cache_for_test().await;
|
||||
let bucket = "partial-carried-quota";
|
||||
let mut baseline = data_usage_info_for_test(bucket, 2, 100, SystemTime::UNIX_EPOCH + Duration::from_secs(100));
|
||||
baseline.scanner_epoch = Some(7);
|
||||
baseline.scanner_cycle = Some(10);
|
||||
baseline.usage_snapshot_converged = Some(true);
|
||||
replace_bucket_usage_memory_from_info(&baseline).await;
|
||||
record_bucket_object_write_memory(bucket, None, 25).await;
|
||||
record_bucket_object_delete_memory(bucket, 20, true).await;
|
||||
assert_eq!(get_bucket_usage_memory(bucket).await, Some(125));
|
||||
let mutation_time = memory_cache().read().await[bucket].usage_updated_at;
|
||||
|
||||
let mut carried = baseline;
|
||||
carried.last_update = Some(mutation_time + Duration::from_secs(1));
|
||||
carried.scanner_cycle = Some(11);
|
||||
carried.usage_snapshot_converged = Some(false);
|
||||
carried.usage_snapshot_pending_buckets = vec![bucket.to_string()];
|
||||
assert!(carried.usage_snapshot_complete);
|
||||
replace_bucket_usage_memory_from_info(&carried).await;
|
||||
assert_eq!(
|
||||
get_bucket_usage_memory(bucket).await,
|
||||
Some(125),
|
||||
"Complete with a carried certified value must retain positive deltas and the delete hold"
|
||||
);
|
||||
|
||||
let mut reconciled = data_usage_info_for_test(bucket, 2, 105, mutation_time + Duration::from_secs(2));
|
||||
reconciled.scanner_epoch = Some(7);
|
||||
reconciled.scanner_cycle = Some(12);
|
||||
reconciled.usage_snapshot_converged = Some(true);
|
||||
replace_bucket_usage_memory_from_info(&reconciled).await;
|
||||
assert_eq!(get_bucket_usage_memory(bucket).await, Some(105));
|
||||
clear_usage_memory_cache_for_test().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn stale_quota_uses_complete_baseline_plus_positive_deltas() {
|
||||
|
||||
@@ -393,6 +393,10 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_online_drive_state(state: &str) -> bool {
|
||||
state == DriveState::Ok.to_string() || state == DriveState::Unformatted.to_string()
|
||||
}
|
||||
|
||||
/// Classify every drive into online / offline / unknown buckets.
|
||||
///
|
||||
/// `unknown` holds drives synthesized for a member whose properties RPC could
|
||||
@@ -412,6 +416,7 @@ fn get_online_offline_disks_stats(disks_info: &[Disk]) -> (BackendDisks, Backend
|
||||
unknown_disks.entry(ep.clone()).or_insert(0);
|
||||
}
|
||||
|
||||
let mut online_root_disk_count = 0;
|
||||
for disk in disks_info {
|
||||
let ep = &disk.endpoint;
|
||||
let state = &disk.state;
|
||||
@@ -419,30 +424,28 @@ fn get_online_offline_disks_stats(disks_info: &[Disk]) -> (BackendDisks, Backend
|
||||
*unknown_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
|
||||
continue;
|
||||
}
|
||||
if *state != DriveState::Ok.to_string() && *state != DriveState::Unformatted.to_string() {
|
||||
if !is_online_drive_state(state) {
|
||||
*offline_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
|
||||
continue;
|
||||
}
|
||||
*online_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
|
||||
}
|
||||
|
||||
let mut root_disk_count = 0;
|
||||
for di in disks_info {
|
||||
if di.root_disk {
|
||||
root_disk_count += 1;
|
||||
if disk.root_disk {
|
||||
online_root_disk_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// When every non-offline, non-unknown drive is a root mount, leave the
|
||||
// online tally as-is instead of demoting all of them (matches the prior
|
||||
// behavior; the unknown bucket is simply carried through untouched).
|
||||
if disks_info.len() == (root_disk_count + offline_disks.values().sum::<usize>() + unknown_disks.values().sum::<usize>()) {
|
||||
if disks_info.len()
|
||||
== (online_root_disk_count + offline_disks.values().sum::<usize>() + unknown_disks.values().sum::<usize>())
|
||||
{
|
||||
return (BackendDisks(online_disks), BackendDisks(offline_disks), BackendDisks(unknown_disks));
|
||||
}
|
||||
|
||||
for disk in disks_info {
|
||||
let ep = &disk.endpoint;
|
||||
if disk.root_disk {
|
||||
if disk.root_disk && is_online_drive_state(&disk.state) {
|
||||
*offline_disks.get_mut(ep).expect("endpoint should be in disk map") += 1;
|
||||
*online_disks.get_mut(ep).expect("endpoint should be in disk map") -= 1;
|
||||
}
|
||||
@@ -835,6 +838,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_stats_only_demote_online_root_disks() {
|
||||
let mut offline_root = disk_with_state("http://n3:9000/data", ITEM_OFFLINE);
|
||||
offline_root.root_disk = true;
|
||||
let mut unknown_root = disk_with_state("http://n4:9000/data", ITEM_UNKNOWN);
|
||||
unknown_root.root_disk = true;
|
||||
let mut online_root = disk_with_state("http://n5:9000/data", "ok");
|
||||
online_root.root_disk = true;
|
||||
|
||||
let disks = vec![
|
||||
disk_with_state("http://n1:9000/data", "ok"),
|
||||
disk_with_state("http://n2:9000/data", "ok"),
|
||||
offline_root,
|
||||
unknown_root,
|
||||
online_root,
|
||||
];
|
||||
|
||||
let (online, offline, unknown) = get_online_offline_disks_stats(&disks);
|
||||
|
||||
assert_eq!(online.sum(), 2, "only the two non-root online drives remain online");
|
||||
assert_eq!(offline.sum(), 2, "the offline root drive and demoted online root drive are offline");
|
||||
assert_eq!(unknown.sum(), 1, "the unknown root drive stays in unknown without underflowing online");
|
||||
assert_eq!(
|
||||
online.sum() + offline.sum() + unknown.sum(),
|
||||
disks.len(),
|
||||
"online + offline + unknown must equal the total drive count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_reconcile_synthesizes_missing_member_as_unknown() {
|
||||
let endpoints = topology_with_hosts(&["rustfs-1", "rustfs-2", "rustfs-3", "rustfs-4"]);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! PUT admission precedes staging; receiver and GC disk work share a separate
|
||||
//! budget so coordinators waiting for peers cannot exhaust receiver capacity.
|
||||
|
||||
use crate::disk::io_admission::{current_resource_unit, with_optional_resource_unit};
|
||||
use crate::disk::{LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_DISK};
|
||||
use std::collections::BTreeSet;
|
||||
use std::future::Future;
|
||||
@@ -422,7 +423,7 @@ impl CleanupRuntime {
|
||||
// the result receiver's lifetime to discard work cancelled during
|
||||
// runtime scheduling, before its first poll can start filesystem I/O.
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
let task = runtime.spawn(async move {
|
||||
let task = runtime.spawn(with_optional_resource_unit(current_resource_unit(), async move {
|
||||
if result_tx.is_closed() {
|
||||
return;
|
||||
}
|
||||
@@ -430,7 +431,7 @@ impl CleanupRuntime {
|
||||
// through I/O even if the waiter cancels and closes result_rx.
|
||||
let result = execute_disk(pending, Some(active), execution, started, future).await;
|
||||
let _ = result_tx.send(result);
|
||||
});
|
||||
}));
|
||||
match result_rx.await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
@@ -469,11 +470,11 @@ impl CleanupRuntime {
|
||||
gates: Some((self.disk_admission.clone(), self.disk_execution.clone())),
|
||||
};
|
||||
handle
|
||||
.spawn(async move {
|
||||
.spawn(with_optional_resource_unit(current_resource_unit(), async move {
|
||||
let active = execution.acquire_owned().await.map_err(super::error::DiskError::other)?;
|
||||
let _active = Admission::new(active, "rustfs_cleanup_gc_active");
|
||||
scan(budget).await
|
||||
})
|
||||
}))
|
||||
.await
|
||||
.map_err(super::error::DiskError::other)?
|
||||
}
|
||||
@@ -582,6 +583,7 @@ where
|
||||
F::Output: Send + 'static,
|
||||
{
|
||||
let started = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
|
||||
let future = with_optional_resource_unit(current_resource_unit(), future);
|
||||
match reservation {
|
||||
Some(reservation) => reservation.handle.clone().spawn(async move {
|
||||
// This semaphore is never closed. Both queued work and overflow
|
||||
@@ -620,6 +622,62 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_dispatch_retains_resource_units_without_inheriting_disk_policy() {
|
||||
use crate::disk::io_admission::{current_disk_io_context, with_background_disk_io, with_background_resource_unit};
|
||||
use rustfs_concurrency::adaptive::AdaptiveAdmission;
|
||||
use std::num::NonZeroU16;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
for mode in 0..4 {
|
||||
let runtime = Arc::new(CleanupRuntime::new(config(1, 1, 1)).expect("cleanup runtime"));
|
||||
let worker_runtime = runtime.clone();
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let work = async move {
|
||||
started_tx
|
||||
.send((current_resource_unit().is_some(), current_disk_io_context().is_none()))
|
||||
.expect("report worker context");
|
||||
release_rx.await.expect("release owned worker");
|
||||
Ok::<_, super::super::error::DiskError>(())
|
||||
};
|
||||
let task = tokio::spawn(with_background_disk_io(
|
||||
CancellationToken::new(),
|
||||
with_background_resource_unit(gate.acquire().await, async move {
|
||||
match mode {
|
||||
0 => worker_runtime
|
||||
.disk_operation(work, DiskAdmission::Request)
|
||||
.await
|
||||
.expect("disk operation"),
|
||||
1 => worker_runtime.gc_operation(move |_| work).await.expect("GC operation"),
|
||||
2 => {
|
||||
let reservation = worker_runtime.reserve().expect("cleanup reservation");
|
||||
run(Some(reservation), async { work.await.expect("cleanup operation") }).await;
|
||||
}
|
||||
_ => spawn(None, work)
|
||||
.await
|
||||
.expect("unisolated worker")
|
||||
.expect("unisolated operation"),
|
||||
}
|
||||
}),
|
||||
));
|
||||
let (inherited, original_policy) = deadline(started_rx).await.expect("owned work starts");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("waiter cancelled").is_cancelled());
|
||||
let held = gate.snapshot().active;
|
||||
release_tx.send(()).expect("owned work survives cancellation");
|
||||
deadline(async {
|
||||
while gate.snapshot().active != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(inherited, "mode {mode} lost resource ownership");
|
||||
assert!(original_policy, "mode {mode} changed the detached worker's disk policy");
|
||||
assert_eq!(held, 1, "mode {mode} released a live worker's unit");
|
||||
}
|
||||
}
|
||||
fn config(pending: usize, workers: usize, blocking_threads: usize) -> Config {
|
||||
Config {
|
||||
pending,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use crate::disk::cleanup_runtime;
|
||||
use crate::disk::io_admission::{
|
||||
AdmissionReader, BoxedChunkReader, DiskIoAdmission, DiskIoClass, DiskIoContext, current_disk_io_context,
|
||||
with_disk_io_context, with_disk_io_permit,
|
||||
current_resource_unit, with_disk_io_context, with_disk_io_permit, with_optional_resource_unit,
|
||||
};
|
||||
use crate::disk::{
|
||||
CheckPartsResp, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskError, DiskInfo, DiskInfoOptions, DiskLocation, Endpoint,
|
||||
@@ -32,6 +32,7 @@ use crate::runtime::sources as runtime_sources;
|
||||
use bytes::Bytes;
|
||||
use metrics::counter;
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
||||
use rustfs_io_metrics::walk_metrics::LocalWalkGuard;
|
||||
use rustfs_madmin::{info_commands::DiskMetrics, metrics::TimedAction};
|
||||
#[cfg(not(test))]
|
||||
use std::sync::OnceLock;
|
||||
@@ -177,6 +178,45 @@ impl AsyncWrite for SlowDiskWriter {
|
||||
}
|
||||
}
|
||||
|
||||
struct ObservedLocalWalkWriter<'a, W> {
|
||||
inner: &'a mut W,
|
||||
observation: &'a mut LocalWalkGuard,
|
||||
}
|
||||
|
||||
impl<W: AsyncWrite + Unpin> AsyncWrite for ObservedLocalWalkWriter<'_, W> {
|
||||
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
||||
let result = Pin::new(&mut *self.inner).poll_write(cx, buf);
|
||||
if let Poll::Ready(Ok(written)) = &result {
|
||||
self.observation.written(*written);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn poll_write_vectored(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
bufs: &[std::io::IoSlice<'_>],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
let result = Pin::new(&mut *self.inner).poll_write_vectored(cx, bufs);
|
||||
if let Poll::Ready(Ok(written)) = &result {
|
||||
self.observation.written(*written);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn is_write_vectored(&self) -> bool {
|
||||
self.inner.is_write_vectored()
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut *self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut *self.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum TimeoutHealthAction {
|
||||
MarkFailure,
|
||||
@@ -409,10 +449,10 @@ where
|
||||
if external_guard.is_none() {
|
||||
return operation().await;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
tokio::spawn(with_optional_resource_unit(current_resource_unit(), async move {
|
||||
let _external_guard = external_guard;
|
||||
operation().await
|
||||
})
|
||||
}))
|
||||
.await
|
||||
.map_err(|_| Error::other("owned mutation task failed"))?
|
||||
}
|
||||
@@ -1582,6 +1622,16 @@ impl LocalDiskWrapper {
|
||||
self.disk.clone()
|
||||
}
|
||||
|
||||
/// Only suppresses idle monitoring; request paths still validate the format on disk.
|
||||
pub(crate) async fn can_skip_background_probe(&self) -> bool {
|
||||
if self.runtime_state() != RuntimeDriveHealthState::Online {
|
||||
return false;
|
||||
}
|
||||
let observed = self.disk.format_info.read().await.id;
|
||||
let expected = *self.disk_id.read().await;
|
||||
observed.is_some_and(|id| !id.is_nil()) && observed == expected
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn background_io_admissions_for_tests(&self) -> usize {
|
||||
self.io_admission.background_admissions_for_tests()
|
||||
@@ -1598,6 +1648,7 @@ impl LocalDiskWrapper {
|
||||
cancellation: CancellationToken::new(),
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: current_resource_unit(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2639,23 +2690,30 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn walk_dir<W: tokio::io::AsyncWrite + Unpin + Send>(&self, opts: WalkDirOptions, wr: &mut W) -> Result<()> {
|
||||
let mut observation = LocalWalkGuard::start();
|
||||
let mut writer = ObservedLocalWalkWriter {
|
||||
inner: wr,
|
||||
observation: &mut observation,
|
||||
};
|
||||
let timeout_duration = if opts.skip_total_timeout {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
opts.timeout_duration().unwrap_or_else(get_drive_walkdir_timeout)
|
||||
};
|
||||
|
||||
with_disk_io_context(
|
||||
let result = with_disk_io_context(
|
||||
self.disk_walk_io_context(),
|
||||
self.track_disk_health_with_op_and_timeout_action(
|
||||
"walk_dir",
|
||||
|| async { self.disk.walk_dir(opts, wr).await },
|
||||
|| async { self.disk.walk_dir(opts, &mut writer).await },
|
||||
timeout_duration,
|
||||
// Listing/scanner backpressure should fail only the current walk, not poison drive health.
|
||||
TimeoutHealthAction::IgnoreFailure,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
observation.finish(result.is_ok());
|
||||
result
|
||||
}
|
||||
|
||||
async fn delete_version(
|
||||
@@ -3728,32 +3786,49 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_mutation_keeps_publication_guard_after_waiter_cancellation() {
|
||||
use rustfs_concurrency::adaptive::AdaptiveAdmission;
|
||||
use std::num::NonZeroU16;
|
||||
|
||||
let resources = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one resource unit"), 1);
|
||||
let resource_unit = resources.acquire().await;
|
||||
let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let guard: Arc<dyn Send + Sync> = Arc::new(DropProbe(Arc::clone(&drops)));
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
let waiter = tokio::spawn(run_owned_mutation(Some(guard), move || async move {
|
||||
started_tx.send(()).expect("mutation should signal start");
|
||||
release_rx.await.expect("mutation should be released");
|
||||
finished_tx.send(()).expect("mutation should signal completion");
|
||||
Ok::<_, Error>(())
|
||||
}));
|
||||
let waiter = tokio::spawn(crate::disk::with_background_resource_unit(
|
||||
resource_unit,
|
||||
run_owned_mutation(Some(guard), move || async move {
|
||||
let inherited = current_resource_unit().is_some();
|
||||
assert!(current_disk_io_context().is_none(), "preserve the owned task's original disk policy");
|
||||
started_tx.send(inherited).expect("mutation should signal start");
|
||||
release_rx.await.expect("mutation should be released");
|
||||
finished_tx.send(()).expect("mutation should signal completion");
|
||||
Ok::<_, Error>(())
|
||||
}),
|
||||
));
|
||||
|
||||
started_rx.await.expect("mutation owner should start");
|
||||
let inherited = started_rx.await.expect("mutation owner should start");
|
||||
waiter.abort();
|
||||
assert!(waiter.await.expect_err("caller was cancelled").is_cancelled());
|
||||
let held_after_cancel = resources.snapshot().active;
|
||||
assert_eq!(drops.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
|
||||
release_tx.send(()).expect("mutation owner should still be alive");
|
||||
finished_rx.await.expect("mutation owner should finish");
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
while drops.load(std::sync::atomic::Ordering::SeqCst) == 0 {
|
||||
while drops.load(std::sync::atomic::Ordering::SeqCst) == 0 || resources.snapshot().active != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("publication guard should be released after mutation completion");
|
||||
assert!(inherited, "owned mutation must retain its resource unit");
|
||||
assert_eq!(
|
||||
held_after_cancel, 1,
|
||||
"cancelling the waiter must not release a live mutation's resource unit"
|
||||
);
|
||||
}
|
||||
|
||||
struct PendingWriter;
|
||||
@@ -4771,6 +4846,239 @@ mod tests {
|
||||
assert!(!wrapper.health.is_faulty());
|
||||
}
|
||||
|
||||
struct WalkMetricsGate(bool);
|
||||
|
||||
impl WalkMetricsGate {
|
||||
fn enable() -> Self {
|
||||
let previous = rustfs_io_metrics::metrics_enabled();
|
||||
rustfs_io_metrics::set_metrics_enabled(true);
|
||||
Self(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WalkMetricsGate {
|
||||
fn drop(&mut self) {
|
||||
rustfs_io_metrics::set_metrics_enabled(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn local_walk_writer_forwards_vectored_flush_shutdown_and_pending() {
|
||||
struct Writer {
|
||||
bytes: Vec<u8>,
|
||||
pending: bool,
|
||||
vectored: bool,
|
||||
}
|
||||
impl AsyncWrite for Writer {
|
||||
fn poll_write(mut self: Pin<&mut Self>, _: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
||||
if self.pending {
|
||||
return Poll::Pending;
|
||||
}
|
||||
let written = buf.len().min(3);
|
||||
self.bytes.extend_from_slice(&buf[..written]);
|
||||
Poll::Ready(Ok(written))
|
||||
}
|
||||
fn poll_write_vectored(
|
||||
mut self: Pin<&mut Self>,
|
||||
_: &mut Context<'_>,
|
||||
bufs: &[io::IoSlice<'_>],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
if self.pending {
|
||||
return Poll::Pending;
|
||||
}
|
||||
let mut written = 0;
|
||||
for buf in bufs {
|
||||
let take = buf.len().min(3 - written);
|
||||
self.bytes.extend_from_slice(&buf[..take]);
|
||||
written += take;
|
||||
}
|
||||
Poll::Ready(Ok(written))
|
||||
}
|
||||
fn is_write_vectored(&self) -> bool {
|
||||
self.vectored
|
||||
}
|
||||
fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Err(io::Error::from(io::ErrorKind::PermissionDenied)))
|
||||
}
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Err(io::Error::from(io::ErrorKind::BrokenPipe)))
|
||||
}
|
||||
}
|
||||
let _gate = WalkMetricsGate::enable();
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let mut observation = LocalWalkGuard::start();
|
||||
let mut downstream = Writer {
|
||||
bytes: Vec::new(),
|
||||
pending: true,
|
||||
vectored: true,
|
||||
};
|
||||
{
|
||||
let mut writer = ObservedLocalWalkWriter {
|
||||
inner: &mut downstream,
|
||||
observation: &mut observation,
|
||||
};
|
||||
let mut cx = Context::from_waker(futures::task::noop_waker_ref());
|
||||
assert!(writer.is_write_vectored());
|
||||
assert!(Pin::new(&mut writer).poll_write(&mut cx, b"abc").is_pending());
|
||||
let bufs = [io::IoSlice::new(b"de"), io::IoSlice::new(b"fg")];
|
||||
assert!(Pin::new(&mut writer).poll_write_vectored(&mut cx, &bufs).is_pending());
|
||||
writer.inner.pending = false;
|
||||
assert!(matches!(Pin::new(&mut writer).poll_write(&mut cx, b"abcdef"), Poll::Ready(Ok(3))));
|
||||
assert!(matches!(Pin::new(&mut writer).poll_write_vectored(&mut cx, &bufs), Poll::Ready(Ok(3))));
|
||||
assert!(matches!(Pin::new(&mut writer).poll_write(&mut cx, b""), Poll::Ready(Ok(0))));
|
||||
assert!(
|
||||
matches!(Pin::new(&mut writer).poll_flush(&mut cx), Poll::Ready(Err(error)) if error.kind() == io::ErrorKind::PermissionDenied)
|
||||
);
|
||||
assert!(
|
||||
matches!(Pin::new(&mut writer).poll_shutdown(&mut cx), Poll::Ready(Err(error)) if error.kind() == io::ErrorKind::BrokenPipe)
|
||||
);
|
||||
writer.inner.vectored = false;
|
||||
assert!(!writer.is_write_vectored());
|
||||
}
|
||||
assert_eq!(downstream.bytes, b"abcdef");
|
||||
});
|
||||
assert_eq!(recorder.counter_value("rustfs_local_walk_written_bytes_total", &[]), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn local_walk_metrics_preserve_real_output_errors_cancellation_and_timeout() {
|
||||
struct PrefixWriter {
|
||||
bytes: Vec<u8>,
|
||||
limit: usize,
|
||||
pending: bool,
|
||||
reached_limit: Arc<AtomicBool>,
|
||||
}
|
||||
impl AsyncWrite for PrefixWriter {
|
||||
fn poll_write(mut self: Pin<&mut Self>, _: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
||||
if self.bytes.len() == self.limit {
|
||||
self.reached_limit.store(true, Ordering::Relaxed);
|
||||
return if self.pending {
|
||||
Poll::Pending
|
||||
} else {
|
||||
Poll::Ready(Err(io::Error::from(io::ErrorKind::BrokenPipe)))
|
||||
};
|
||||
}
|
||||
let written = buf.len().min(7).min(self.limit - self.bytes.len());
|
||||
self.bytes.extend_from_slice(&buf[..written]);
|
||||
Poll::Ready(Ok(written))
|
||||
}
|
||||
fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
self.poll_flush(cx)
|
||||
}
|
||||
}
|
||||
let _gate = WalkMetricsGate::enable();
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("local walk runtime");
|
||||
let dir = tempfile::tempdir().expect("local walk directory");
|
||||
let (wrapper, options, expected) = runtime.block_on(async {
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 directory")).expect("local endpoint");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk"));
|
||||
let wrapper = LocalDiskWrapper::new(disk, false);
|
||||
let bucket = "walk-metrics-test";
|
||||
wrapper.make_volume(bucket).await.expect("local walk bucket");
|
||||
let mut file_info = FileInfo::new(&format!("{bucket}/object"), 1, 0);
|
||||
file_info.volume = bucket.to_owned();
|
||||
file_info.name = "object".to_owned();
|
||||
file_info.mod_time = Some(::time::OffsetDateTime::now_utc());
|
||||
file_info.erasure.index = 1;
|
||||
wrapper
|
||||
.write_metadata("", bucket, "object", file_info)
|
||||
.await
|
||||
.expect("local walk metadata");
|
||||
let options = WalkDirOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut expected = Vec::new();
|
||||
wrapper
|
||||
.walk_dir(options.clone(), &mut expected)
|
||||
.await
|
||||
.expect("reference local walk");
|
||||
assert!(expected.len() > 7, "fixture must reach a partial downstream write");
|
||||
(wrapper, options, expected)
|
||||
});
|
||||
for case in ["ok", "write_error", "cancelled", "timeout"] {
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
runtime.block_on(async {
|
||||
let reached_limit = Arc::new(AtomicBool::new(false));
|
||||
let mut writer = PrefixWriter {
|
||||
bytes: Vec::new(),
|
||||
limit: if case == "ok" { usize::MAX } else { 7 },
|
||||
pending: matches!(case, "cancelled" | "timeout"),
|
||||
reached_limit: Arc::clone(&reached_limit),
|
||||
};
|
||||
let mut options = options.clone();
|
||||
if case == "cancelled" {
|
||||
options.skip_total_timeout = true;
|
||||
let mut walk = Box::pin(wrapper.walk_dir(options, &mut writer));
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
futures::future::poll_fn(|cx| {
|
||||
use std::future::Future;
|
||||
assert!(walk.as_mut().poll(cx).is_pending(), "walk must reach downstream backpressure");
|
||||
if reached_limit.load(Ordering::Relaxed) {
|
||||
Poll::Ready(())
|
||||
} else {
|
||||
Poll::Pending
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("walk must reach the writer before cancellation");
|
||||
drop(walk);
|
||||
} else {
|
||||
if case == "timeout" {
|
||||
options.timeout_ms = Some(100);
|
||||
}
|
||||
let result = wrapper.walk_dir(options, &mut writer).await;
|
||||
match case {
|
||||
"ok" => result.expect("short downstream writes preserve success"),
|
||||
"write_error" => {
|
||||
assert!(matches!(result, Err(DiskError::Io(error)) if error.kind() == io::ErrorKind::BrokenPipe))
|
||||
}
|
||||
"timeout" => {
|
||||
assert_eq!(result.expect_err("backpressured walk must time out"), DiskError::Timeout);
|
||||
assert!(reached_limit.load(Ordering::Relaxed), "timeout must occur after downstream progress");
|
||||
}
|
||||
_ => unreachable!("bounded test cases"),
|
||||
}
|
||||
}
|
||||
let accepted = if case == "ok" { expected.len() } else { 7 };
|
||||
assert_eq!(writer.bytes, expected[..accepted], "exact accepted prefix for {case}");
|
||||
assert_eq!(
|
||||
recorder.counter_value("rustfs_local_walk_written_bytes_total", &[]),
|
||||
u64::try_from(accepted).expect("fixture length fits")
|
||||
);
|
||||
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Online);
|
||||
assert!(!wrapper.health.is_faulty());
|
||||
assert_eq!(wrapper.health.waiting_count(), 0, "walk must release health tracking");
|
||||
})
|
||||
});
|
||||
let outcome = match case {
|
||||
"ok" => "ok",
|
||||
"cancelled" => "cancelled",
|
||||
_ => "error",
|
||||
};
|
||||
assert_eq!(recorder.counter_value("rustfs_local_walk_started_total", &[]), 1, "{case}");
|
||||
assert_eq!(
|
||||
recorder.counter_value("rustfs_local_walk_completed_total", &[("outcome", outcome)]),
|
||||
1,
|
||||
"{case}"
|
||||
);
|
||||
assert_eq!(recorder.histogram_sample_count("rustfs_local_walk_duration_seconds"), 1, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn walk_dir_writer_backpressure_timeout_does_not_mark_drive_failure() {
|
||||
temp_env::async_with_vars(
|
||||
|
||||
@@ -24,6 +24,7 @@ use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use metrics::{counter, gauge, histogram};
|
||||
use rustfs_concurrency::adaptive::AdaptivePermit;
|
||||
use rustfs_rio::ChunkReader;
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
use tokio::sync::Notify;
|
||||
@@ -35,6 +36,7 @@ const BACKGROUND_MAX_WAIT: Duration = Duration::from_millis(100);
|
||||
|
||||
tokio::task_local! {
|
||||
static DISK_IO_CONTEXT: DiskIoContext;
|
||||
static RESOURCE_IO_UNIT: AdaptivePermit;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -49,6 +51,7 @@ pub(crate) struct DiskIoContext {
|
||||
pub(crate) cancellation: CancellationToken,
|
||||
pub(crate) admission: Option<Arc<DiskIoAdmission>>,
|
||||
pub(crate) permit: Option<DiskIoPermit>,
|
||||
pub(crate) resource_unit: Option<AdaptivePermit>,
|
||||
}
|
||||
|
||||
/// Mark disk I/O issued by `future` as background work for local-disk admission.
|
||||
@@ -60,12 +63,43 @@ pub async fn with_background_disk_io<F: Future>(cancellation: CancellationToken,
|
||||
cancellation,
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: current_resource_unit(),
|
||||
},
|
||||
future,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Retain an already admitted resource unit without changing disk classification,
|
||||
/// cancellation or admission. Acquire it before namespace, cache or commit locks.
|
||||
/// Existing background scopes and disk leases propagate it into native I/O.
|
||||
/// Use this for physical I/O ownership, not node-wide scheduling capacity that
|
||||
/// must return with a cancelled waiter so healthy disks can progress independently
|
||||
/// of a stalled native operation. Escaping buffers need their own memory leases.
|
||||
#[doc(hidden)]
|
||||
pub async fn with_background_resource_unit<F: Future>(unit: AdaptivePermit, future: F) -> F::Output {
|
||||
RESOURCE_IO_UNIT.scope(unit, future).await
|
||||
}
|
||||
|
||||
pub(crate) fn current_resource_unit() -> Option<AdaptivePermit> {
|
||||
RESOURCE_IO_UNIT.try_with(Clone::clone).ok().or_else(|| {
|
||||
DISK_IO_CONTEXT
|
||||
.try_with(|context| context.resource_unit.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Transfer only resource ownership into an existing spawned task. Disk
|
||||
/// classification/permits are not inherited: changing those can introduce a
|
||||
/// wait behind a disk permit still held by the parent.
|
||||
pub(crate) async fn with_optional_resource_unit<F: Future>(unit: Option<AdaptivePermit>, future: F) -> F::Output {
|
||||
match unit {
|
||||
Some(unit) => with_background_resource_unit(unit, future).await,
|
||||
None => future.await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn with_disk_io_context<F: Future>(context: DiskIoContext, future: F) -> F::Output {
|
||||
DISK_IO_CONTEXT.scope(context, future).await
|
||||
}
|
||||
@@ -79,7 +113,11 @@ pub(crate) async fn with_disk_io_permit<F: Future>(permit: Option<DiskIoPermit>,
|
||||
cancellation: CancellationToken::new(),
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: None,
|
||||
});
|
||||
if context.resource_unit.is_none() {
|
||||
context.resource_unit = permit.lease.resource_unit.clone();
|
||||
}
|
||||
context.admission = Some(permit.admission());
|
||||
with_disk_io_context(
|
||||
DiskIoContext {
|
||||
@@ -91,15 +129,32 @@ pub(crate) async fn with_disk_io_permit<F: Future>(permit: Option<DiskIoPermit>,
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn with_optional_disk_io_context<F: Future>(context: Option<DiskIoContext>, future: F) -> F::Output {
|
||||
match context {
|
||||
Some(context) => with_disk_io_context(context, future).await,
|
||||
None => future.await,
|
||||
pub(crate) fn with_optional_disk_io_context<F: Future>(
|
||||
context: Option<DiskIoContext>,
|
||||
future: F,
|
||||
) -> impl Future<Output = F::Output> {
|
||||
// Capture at future construction, before JoinSet/spawn moves polling to a
|
||||
// different task. A resource-only scope has no DiskIoContext to carry it.
|
||||
let unit = current_resource_unit();
|
||||
async move {
|
||||
let future = with_optional_resource_unit(unit, future);
|
||||
match context {
|
||||
Some(context) => with_disk_io_context(context, future).await,
|
||||
None => future.await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_disk_io_context() -> Option<DiskIoContext> {
|
||||
DISK_IO_CONTEXT.try_with(Clone::clone).ok()
|
||||
DISK_IO_CONTEXT
|
||||
.try_with(|context| {
|
||||
let mut context = context.clone();
|
||||
if let Ok(unit) = RESOURCE_IO_UNIT.try_with(Clone::clone) {
|
||||
context.resource_unit = Some(unit);
|
||||
}
|
||||
context
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Acquire one low-level permit only when the current walker explicitly owns a gate.
|
||||
@@ -175,6 +230,10 @@ impl DiskIoAdmission {
|
||||
}
|
||||
|
||||
pub(crate) fn foreground(self: &Arc<Self>) -> DiskIoPermit {
|
||||
self.foreground_with_resource(current_resource_unit())
|
||||
}
|
||||
|
||||
fn foreground_with_resource(self: &Arc<Self>, resource_unit: Option<AdaptivePermit>) -> DiskIoPermit {
|
||||
self.foreground_active.fetch_add(1, Ordering::AcqRel);
|
||||
if self.background_waiters.load(Ordering::Acquire) > 0 {
|
||||
self.record_state();
|
||||
@@ -183,11 +242,20 @@ impl DiskIoAdmission {
|
||||
lease: Arc::new(DiskIoPermitLease {
|
||||
admission: self.clone(),
|
||||
class: DiskIoClass::Foreground,
|
||||
resource_unit,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn background(self: &Arc<Self>, cancellation: &CancellationToken) -> std::io::Result<DiskIoPermit> {
|
||||
self.background_with_resource(cancellation, current_resource_unit()).await
|
||||
}
|
||||
|
||||
async fn background_with_resource(
|
||||
self: &Arc<Self>,
|
||||
cancellation: &CancellationToken,
|
||||
resource_unit: Option<AdaptivePermit>,
|
||||
) -> std::io::Result<DiskIoPermit> {
|
||||
let started_at = Instant::now();
|
||||
let ticket = {
|
||||
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
|
||||
@@ -248,6 +316,7 @@ impl DiskIoAdmission {
|
||||
lease: Arc::new(DiskIoPermitLease {
|
||||
admission: self.clone(),
|
||||
class: DiskIoClass::Background,
|
||||
resource_unit,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -360,6 +429,7 @@ impl DiskIoPermit {
|
||||
struct DiskIoPermitLease {
|
||||
admission: Arc<DiskIoAdmission>,
|
||||
class: DiskIoClass,
|
||||
resource_unit: Option<AdaptivePermit>,
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_blocking_with_disk_io_permit<T, F>(
|
||||
@@ -370,8 +440,12 @@ where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
{
|
||||
// A supplied disk permit can predate this resource scope or carry another
|
||||
// owner's unit. Preserve the current unit independently until native I/O ends.
|
||||
let resource_unit = current_resource_unit();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _io_permit = io_permit;
|
||||
let _resource_unit = resource_unit;
|
||||
operation()
|
||||
})
|
||||
}
|
||||
@@ -436,7 +510,7 @@ impl<R> AdmissionReader<R> {
|
||||
}
|
||||
|
||||
if self.context.class == DiskIoClass::Foreground {
|
||||
self.permit = Some(self.admission.foreground());
|
||||
self.permit = Some(self.admission.foreground_with_resource(self.context.resource_unit.clone()));
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
@@ -444,7 +518,10 @@ impl<R> AdmissionReader<R> {
|
||||
if wait.is_none() {
|
||||
let admission = self.admission.clone();
|
||||
let cancellation = self.context.cancellation.clone();
|
||||
*wait = Some(Box::pin(async move { admission.background(&cancellation).await }));
|
||||
let resource_unit = self.context.resource_unit.clone();
|
||||
*wait = Some(Box::pin(
|
||||
async move { admission.background_with_resource(&cancellation, resource_unit).await },
|
||||
));
|
||||
}
|
||||
let Some(wait_future) = wait.as_mut() else {
|
||||
return Poll::Ready(Err(io::Error::other("background disk I/O admission waiter is missing")));
|
||||
@@ -514,8 +591,166 @@ impl ChunkReader for BoxedChunkReader {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_concurrency::adaptive::AdaptiveAdmission;
|
||||
use std::num::NonZeroU16;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_unit_outlives_cancelled_scope_until_native_io_finishes() {
|
||||
for permit_mode in 0..4 {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let previous_gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("previous owner"), 1);
|
||||
let unit = gate.acquire().await;
|
||||
let disk = DiskIoAdmission::new("resource-unit-native");
|
||||
let existing_permit = match permit_mode {
|
||||
2 => Some(disk.foreground()),
|
||||
3 => Some(with_background_resource_unit(previous_gate.acquire().await, async { disk.foreground() }).await),
|
||||
_ => None,
|
||||
};
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(with_background_resource_unit(unit, async move {
|
||||
with_background_disk_io(CancellationToken::new(), async {
|
||||
let permit = if existing_permit.is_some() {
|
||||
existing_permit
|
||||
} else if permit_mode == 1 {
|
||||
Some(disk.background(&CancellationToken::new()).await.expect("disk permit"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
spawn_blocking_with_disk_io_permit(permit, move || {
|
||||
started_tx.send(()).expect("native I/O started");
|
||||
release_rx.blocking_recv().expect("native I/O release");
|
||||
})
|
||||
.await
|
||||
.expect("native I/O completed");
|
||||
})
|
||||
.await;
|
||||
}));
|
||||
started_rx.await.expect("native worker owns unit");
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("scope was cancelled").is_cancelled());
|
||||
let held = gate.snapshot().active;
|
||||
let previous_held = previous_gate.snapshot().active;
|
||||
let rejected = gate.try_acquire().is_none();
|
||||
gate.set_limit(0);
|
||||
release_tx.send(()).expect("release native I/O");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while gate.snapshot().active != 0 || previous_gate.snapshot().active != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("native completion releases the unit");
|
||||
gate.set_limit(1);
|
||||
assert!(gate.try_acquire().is_some());
|
||||
assert_eq!(held, 1, "mode {permit_mode}: native I/O still owns the current unit");
|
||||
assert!(rejected, "mode {permit_mode}: a live native worker must retain admission");
|
||||
assert_eq!(previous_held, u32::from(permit_mode == 3), "preserve any older disk lease's unit");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn escaped_disk_permit_restores_resource_context_without_a_new_unit() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let disk = DiskIoAdmission::new("resource-unit-escape");
|
||||
let permit = with_background_resource_unit(gate.acquire().await, async {
|
||||
disk.background(&CancellationToken::new()).await.expect("disk permit")
|
||||
})
|
||||
.await;
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
with_background_disk_io(CancellationToken::new(), async {
|
||||
with_disk_io_permit(Some(permit), async {
|
||||
let unit = current_resource_unit().expect("escaped disk lease restores unit into an existing context");
|
||||
assert_eq!(gate.snapshot().active, 1, "cloning retains one unit rather than charging twice");
|
||||
drop(unit);
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn escaped_stream_retains_resource_unit_outside_task_local_scope() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let disk = DiskIoAdmission::new("resource-unit-stream");
|
||||
let (mut writer, inner) = tokio::io::duplex(8);
|
||||
let mut reader = with_background_resource_unit(gate.acquire().await, async {
|
||||
with_background_disk_io(CancellationToken::new(), async {
|
||||
AdmissionReader::new(inner, disk, current_disk_io_context().expect("resource context"))
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
writer.write_all(b"rust").await.expect("write complete stream");
|
||||
drop(writer);
|
||||
let mut bytes = Vec::new();
|
||||
reader.read_to_end(&mut bytes).await.expect("read outside original scope");
|
||||
assert_eq!(bytes, b"rust");
|
||||
assert_eq!(gate.snapshot().active, 1, "reader still owns its context");
|
||||
drop(reader);
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_scope_preserves_existing_disk_policy_and_cancellation() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
with_background_resource_unit(gate.acquire().await, async {
|
||||
assert!(current_disk_io_context().is_none(), "resource ownership does not classify disk I/O");
|
||||
assert!(current_resource_unit().is_some());
|
||||
})
|
||||
.await;
|
||||
let cancellation = CancellationToken::new();
|
||||
let existing = DiskIoContext {
|
||||
class: DiskIoClass::Foreground,
|
||||
cancellation: cancellation.clone(),
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: None,
|
||||
};
|
||||
with_disk_io_context(existing, async {
|
||||
with_background_resource_unit(gate.acquire().await, async {
|
||||
cancellation.cancel();
|
||||
let context = current_disk_io_context().expect("existing disk context");
|
||||
assert_eq!(context.class, DiskIoClass::Foreground);
|
||||
assert!(
|
||||
context.resource_unit.is_some(),
|
||||
"captured disk context must retain the current resource unit"
|
||||
);
|
||||
assert!(context.cancellation.is_cancelled(), "retain the original cancellation token");
|
||||
assert!(current_resource_unit().is_some());
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_only_scope_survives_existing_optional_context_spawn_boundary() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let mut worker = None;
|
||||
with_background_resource_unit(gate.acquire().await, async {
|
||||
worker = Some(tokio::spawn(with_optional_disk_io_context(current_disk_io_context(), async {
|
||||
release_rx.await.expect("release child after caller scope ends");
|
||||
assert!(current_disk_io_context().is_none());
|
||||
assert!(current_resource_unit().is_some());
|
||||
})));
|
||||
})
|
||||
.await;
|
||||
let held = gate.snapshot().active;
|
||||
release_tx.send(()).expect("child remains alive");
|
||||
worker
|
||||
.expect("scoped child was spawned")
|
||||
.await
|
||||
.expect("child preserves resource-only scope");
|
||||
assert_eq!(held, 1);
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
|
||||
struct BlockingWorker(tokio::task::JoinHandle<()>);
|
||||
|
||||
async fn wait_for_background_waiters(admission: &DiskIoAdmission, expected: usize) {
|
||||
@@ -636,6 +871,7 @@ mod tests {
|
||||
cancellation: CancellationToken::new(),
|
||||
admission: None,
|
||||
permit: None,
|
||||
resource_unit: None,
|
||||
},
|
||||
);
|
||||
let read = tokio::spawn(async move {
|
||||
|
||||
+1798
-263
File diff suppressed because it is too large
Load Diff
@@ -21,10 +21,11 @@ use super::run_destination_commit_directory_preparation;
|
||||
use super::should_fail_local_inline_rollback_hardlink;
|
||||
use super::{
|
||||
EVENT_DISK_LOCAL_ACCESS_FAILED, EVENT_DISK_LOCAL_HEAL_PURGE_FAILED, EVENT_DISK_LOCAL_RENAME_REJECTED, LOG_COMPONENT_ECSTORE,
|
||||
LOG_SUBSYSTEM_DISK_LOCAL, LocalDisk, SyncMode, effective_durability, inline_metadata_rollback_dir, observe_old_current_size,
|
||||
remove_dir_all_if_exists, remove_dst_base_before_commit, remove_file_if_exists, rename_data_versions_signature,
|
||||
run_inline_preparation_before_backup, should_fail_after_metadata_commit, should_fail_before_old_metadata_backup,
|
||||
should_fail_commit_rename, should_remove_staged_meta_before_commit, skip_access_checks, tmp_staging_mutation_path,
|
||||
LOG_SUBSYSTEM_DISK_LOCAL, LocalDisk, SyncMode, effective_durability, has_inline_commit_test_failpoints,
|
||||
inline_metadata_rollback_dir, observe_old_current, remove_dir_all_if_exists, remove_dst_base_before_commit,
|
||||
remove_file_if_exists, rename_data_versions_signature, run_inline_preparation_before_backup,
|
||||
should_fail_after_metadata_commit, should_fail_before_old_metadata_backup, should_fail_commit_rename,
|
||||
should_remove_staged_meta_before_commit, skip_access_checks, tmp_staging_mutation_path,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use super::{run_inline_before_file_sync_admission, run_owned_file_write_before_open, run_rename_data_after_first_publication};
|
||||
@@ -47,33 +48,6 @@ use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn transitioned_old_current_source(
|
||||
dst_meta_existed: bool,
|
||||
dst_meta_unparsable: bool,
|
||||
xlmeta: &FileMeta,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
version_id: Uuid,
|
||||
) -> (bool, Option<FileInfo>) {
|
||||
if dst_meta_unparsable {
|
||||
return (false, None);
|
||||
}
|
||||
if !dst_meta_existed {
|
||||
return (true, None);
|
||||
}
|
||||
let Ok(old_current) = xlmeta.into_fileinfo(volume, path, "", false, false, true) else {
|
||||
return (false, None);
|
||||
};
|
||||
if old_current.version_id.unwrap_or_default() != version_id {
|
||||
return (true, None);
|
||||
}
|
||||
if old_current.transition_status == rustfs_filemeta::TRANSITION_COMPLETE {
|
||||
(true, Some(old_current))
|
||||
} else {
|
||||
(true, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold later repair publications after admitting one baseline object. The
|
||||
/// fixture arms this on one replacement disk before rejoining the cluster.
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
@@ -132,16 +106,7 @@ impl StagedRollbackBackup {
|
||||
|
||||
impl RenameCommitContext<'_> {
|
||||
fn rollback_metadata_std(&self, rollback_data_dir: Option<Uuid>) -> std::io::Result<()> {
|
||||
if let Some(old_data_dir) = rollback_data_dir {
|
||||
let Some(dst_parent) = self.destination_metadata.parent() else {
|
||||
return Err(std::io::Error::new(ErrorKind::InvalidInput, "missing object metadata parent"));
|
||||
};
|
||||
let backup_path = dst_parent.join(old_data_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
std::fs::rename(backup_path, self.destination_metadata)?;
|
||||
} else {
|
||||
remove_file_if_exists(self.destination_metadata)?;
|
||||
}
|
||||
Ok(())
|
||||
rollback_metadata_std(self.destination_metadata, rollback_data_dir)
|
||||
}
|
||||
|
||||
fn rollback_non_inline_std(&self, rollback_data_dir: Option<Uuid>) -> std::io::Result<()> {
|
||||
@@ -157,16 +122,7 @@ impl RenameCommitContext<'_> {
|
||||
rollback_data_dir: Option<Uuid>,
|
||||
local_backup: Option<&StagedRollbackBackup>,
|
||||
) -> std::io::Result<()> {
|
||||
#[cfg(all(test, not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Rollback, self.destination_metadata);
|
||||
if let Some(backup) = local_backup {
|
||||
// The preceding commit used this staged directory on the same
|
||||
// filesystem. Never unlink xl.meta before its atomic replacement.
|
||||
std::fs::rename(backup.path(), self.destination_metadata)?;
|
||||
} else {
|
||||
self.rollback_metadata_std(rollback_data_dir)?;
|
||||
}
|
||||
Ok(())
|
||||
rollback_inline_metadata_std(self.destination_metadata, rollback_data_dir, local_backup)
|
||||
}
|
||||
|
||||
async fn restore_published_data_source(&self) -> Result<()> {
|
||||
@@ -234,6 +190,106 @@ pub(super) fn create_local_inline_rollback_backup(
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
fn rollback_metadata_std(destination_metadata: &Path, rollback_data_dir: Option<Uuid>) -> std::io::Result<()> {
|
||||
if let Some(old_data_dir) = rollback_data_dir {
|
||||
let Some(dst_parent) = destination_metadata.parent() else {
|
||||
return Err(std::io::Error::new(ErrorKind::InvalidInput, "missing object metadata parent"));
|
||||
};
|
||||
let backup_path = dst_parent.join(old_data_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
std::fs::rename(backup_path, destination_metadata)?;
|
||||
} else {
|
||||
remove_file_if_exists(destination_metadata)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback_inline_metadata_std(
|
||||
destination_metadata: &Path,
|
||||
rollback_data_dir: Option<Uuid>,
|
||||
local_backup: Option<&StagedRollbackBackup>,
|
||||
) -> std::io::Result<()> {
|
||||
#[cfg(all(test, not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Rollback, destination_metadata);
|
||||
if let Some(backup) = local_backup {
|
||||
// The preceding commit used this staged directory on the same
|
||||
// filesystem. Never unlink xl.meta before its atomic replacement.
|
||||
std::fs::rename(backup.path(), destination_metadata)?;
|
||||
Ok(())
|
||||
} else {
|
||||
rollback_metadata_std(destination_metadata, rollback_data_dir)
|
||||
}
|
||||
}
|
||||
|
||||
fn fsync_inline_commit_std(
|
||||
dst: &Path,
|
||||
dst_volume_dir: &Path,
|
||||
destination_was_absent: bool,
|
||||
local_backup: Option<&StagedRollbackBackup>,
|
||||
commit_guard: &os::RenameCommitGuard,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(dst_parent) = dst.parent() {
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) = os::fsync_commit_directory_limited_std(dst_parent, commit_guard) {
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_DST_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
rollback_inline_metadata_std(dst, None, local_backup)?;
|
||||
return Err(err);
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_DST_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
}
|
||||
|
||||
if destination_was_absent {
|
||||
let mut ancestor = dst.parent().and_then(Path::parent);
|
||||
while let Some(ancestor_dir) = ancestor {
|
||||
if !ancestor_dir.starts_with(dst_volume_dir) {
|
||||
break;
|
||||
}
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) = os::fsync_dir_limited_std(ancestor_dir) {
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_ANCESTOR_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
rollback_inline_metadata_std(dst, None, local_backup)?;
|
||||
return Err(err);
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_ANCESTOR_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
if ancestor_dir == dst_volume_dir {
|
||||
break;
|
||||
}
|
||||
ancestor = ancestor_dir.parent();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type PreparedInlineMetadata = (
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
Option<Vec<u8>>,
|
||||
Option<crate::disk::OldCurrentSize>,
|
||||
bool,
|
||||
Option<FileInfo>,
|
||||
Option<StagedRollbackBackup>,
|
||||
bool,
|
||||
os::PreparedRenameSource,
|
||||
);
|
||||
|
||||
enum InlinePreparationOutcome {
|
||||
Committed(RenameDataResp),
|
||||
CommitErr(DiskError),
|
||||
Prepared(PreparedInlineMetadata),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn lock_rename_commit_directories(
|
||||
source_parent: &Path,
|
||||
destination_parent: &Path,
|
||||
@@ -271,33 +327,66 @@ pub(super) async fn lock_rename_commit_directories(
|
||||
result.map_err(to_file_error).map_err(DiskError::from)
|
||||
}
|
||||
|
||||
async fn prepare_rename_destination_metadata(
|
||||
file_path: &Path,
|
||||
rename_commit_guard: &os::RenameCommitGuard,
|
||||
async fn prepare_rename_commit_and_destination_metadata(
|
||||
create_source_parent: bool,
|
||||
source_parent: &Path,
|
||||
destination_parent: &Path,
|
||||
destination_metadata: &Path,
|
||||
base_dir: &Path,
|
||||
publication_root: &os::PublicationRoot,
|
||||
mutation_lease: Arc<os::NamespaceMutationLease>,
|
||||
) -> Result<Option<Bytes>> {
|
||||
) -> Result<(os::RenameCommitGuard, Option<Bytes>)> {
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
let result = {
|
||||
let file_path = file_path.to_path_buf();
|
||||
let rename_commit_guard = rename_commit_guard.clone();
|
||||
let source_parent = source_parent.to_path_buf();
|
||||
let destination_parent = destination_parent.to_path_buf();
|
||||
let destination_metadata = destination_metadata.to_path_buf();
|
||||
let base_dir = base_dir.to_path_buf();
|
||||
let publication_root = publication_root.clone();
|
||||
os::run_blocking_namespace_operation(mutation_lease, move || {
|
||||
os::read_destination_file_with_commit_guard(&file_path, &rename_commit_guard)
|
||||
if create_source_parent {
|
||||
std::fs::create_dir_all(&source_parent)?;
|
||||
}
|
||||
let guard = match os::prepare_rename_commit_guard(&source_parent, &destination_parent, &base_dir, &publication_root) {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
return Err(match std::fs::symlink_metadata(&base_dir) {
|
||||
Err(base_err) if base_err.kind() == ErrorKind::NotFound => base_err,
|
||||
_ => err,
|
||||
});
|
||||
}
|
||||
};
|
||||
#[cfg(all(test, windows))]
|
||||
run_destination_commit_directory_preparation(&destination_parent);
|
||||
let data = os::read_destination_file_with_commit_guard(&destination_metadata, &guard)?;
|
||||
Ok((guard, data.map(Bytes::from)))
|
||||
})
|
||||
.await
|
||||
};
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
let _ = (rename_commit_guard, mutation_lease);
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
let result = match fs::read(file_path).await {
|
||||
Ok(data) => Ok(Some(data)),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
let result = {
|
||||
let _ = mutation_lease;
|
||||
if create_source_parent {
|
||||
fs::create_dir_all(source_parent).await.map_err(to_file_error)?;
|
||||
}
|
||||
let guard =
|
||||
os::prepare_rename_commit_guard(source_parent, destination_parent, base_dir, publication_root).map_err(|err| {
|
||||
match std::fs::symlink_metadata(base_dir) {
|
||||
Err(base_err) if base_err.kind() == ErrorKind::NotFound => base_err,
|
||||
_ => err,
|
||||
}
|
||||
});
|
||||
match guard {
|
||||
Ok(guard) => match fs::read(destination_metadata).await {
|
||||
Ok(data) => Ok((guard, Some(Bytes::from(data)))),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok((guard, None)),
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
};
|
||||
|
||||
result
|
||||
.map(|data| data.map(Bytes::from))
|
||||
.map_err(to_file_error)
|
||||
.map_err(DiskError::from)
|
||||
result.map_err(to_file_error).map_err(DiskError::from)
|
||||
}
|
||||
|
||||
/// Proof produced only when the local rename returns at an existing access
|
||||
@@ -332,7 +421,7 @@ impl LocalDisk {
|
||||
// object commit is publishing into it. The peer's empty scan remains
|
||||
// optimistic; this lease establishes the local commit/delete order and
|
||||
// remains owned by any blocking syscall that outlives async cancellation.
|
||||
let destination_object_path = self.io_get_object_path(dst_volume, dst_path)?;
|
||||
let (dst_volume_dir, destination_object_path) = self.io_get_bucket_and_object_path(dst_volume, dst_path)?;
|
||||
let quota_fence_token =
|
||||
match rustfs_utils::http::metadata_compat::get_consistent_str(&fi.metadata, QUOTA_MUTATION_FENCE_METADATA_SUFFIX) {
|
||||
Some(value) => {
|
||||
@@ -361,8 +450,13 @@ impl LocalDisk {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let root_destination_object_path = self.root.join(
|
||||
destination_object_path
|
||||
.strip_prefix(self.io_root())
|
||||
.map_err(|_| DiskError::InvalidPath)?,
|
||||
);
|
||||
let metadata_lease =
|
||||
os::acquire_metadata_mutation_lease(&self.get_object_path(dst_volume, dst_path)?, state.namespace_owner.take()).await;
|
||||
os::acquire_metadata_mutation_lease(&root_destination_object_path, state.namespace_owner.take()).await;
|
||||
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
|
||||
&self.root,
|
||||
dst_volume,
|
||||
@@ -413,7 +507,6 @@ impl LocalDisk {
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
|
||||
let dst_volume_dir = self.io_get_bucket_path(dst_volume)?;
|
||||
if !skip_access_checks(dst_volume)
|
||||
&& let Err(e) = super::super::fs::access_std(&dst_volume_dir)
|
||||
{
|
||||
@@ -431,22 +524,23 @@ impl LocalDisk {
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
|
||||
// xl.meta path
|
||||
let src_file_path = self.io_get_object_path(src_volume, format!("{}/{}", src_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
// Keep recovery authority separate from active upload metadata so a
|
||||
// committed upload cannot reopen when set-level cleanup fails.
|
||||
let preserve_upload_metadata = src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET;
|
||||
let source_metadata_name = if preserve_upload_metadata {
|
||||
super::super::MULTIPART_COMMIT_METADATA_FILE
|
||||
} else {
|
||||
STORAGE_FORMAT_FILE
|
||||
};
|
||||
let src_file_path = self.io_get_object_path(src_volume, format!("{src_path}/{source_metadata_name}").as_str())?;
|
||||
let dst_file_path = self.io_get_object_open_path(dst_volume, format!("{}/{}", dst_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
|
||||
// data_dir path
|
||||
let has_data_dir_path = {
|
||||
let has_data_dir = {
|
||||
if !fi.is_remote() {
|
||||
fi.data_dir
|
||||
.map(|dir| rustfs_utils::path::retain_slash(dir.to_string().as_str()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(data_dir) = has_data_dir {
|
||||
// data_dir path (only used by the non-inline commit branch)
|
||||
let has_data_dir_path = if no_inline && !fi.is_remote() {
|
||||
if let Some(data_dir) = fi
|
||||
.data_dir
|
||||
.map(|dir| rustfs_utils::path::retain_slash(dir.to_string().as_str()))
|
||||
{
|
||||
let src_data_path = self.io_get_object_path(
|
||||
src_volume,
|
||||
rustfs_utils::path::retain_slash(format!("{}/{}", src_path, data_dir).as_str()).as_str(),
|
||||
@@ -460,6 +554,8 @@ impl LocalDisk {
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
check_path_length(src_file_path.to_string_lossy().to_string().as_str())?;
|
||||
@@ -479,14 +575,13 @@ impl LocalDisk {
|
||||
let dst_file_parent = dst_file_path
|
||||
.parent()
|
||||
.ok_or_else(|| DiskError::other("missing object metadata parent"))?;
|
||||
if !no_inline {
|
||||
fs::create_dir_all(src_file_parent).await.map_err(to_file_error)?;
|
||||
}
|
||||
// Acquire the common trees before reading destination metadata. On
|
||||
// Windows this pins the object directory identity across publication.
|
||||
let rename_commit_guard = lock_rename_commit_directories(
|
||||
let (rename_commit_guard, has_dst_buf) = prepare_rename_commit_and_destination_metadata(
|
||||
!no_inline,
|
||||
src_file_parent,
|
||||
dst_file_parent,
|
||||
&dst_file_path,
|
||||
&dst_volume_dir,
|
||||
&self.publication_root,
|
||||
mutation_lease.clone(),
|
||||
@@ -501,12 +596,39 @@ impl LocalDisk {
|
||||
source_volume_dir: &src_volume_dir,
|
||||
data_paths: has_data_dir_path.as_ref(),
|
||||
};
|
||||
let has_dst_buf = prepare_rename_destination_metadata(
|
||||
commit.destination_metadata,
|
||||
&commit.directory_guard,
|
||||
commit.mutation_lease.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if preserve_upload_metadata {
|
||||
let source_object_path = self.get_object_path(src_volume, src_path)?;
|
||||
let authority_owner: Arc<dyn Send + Sync> = if source_object_path == root_destination_object_path {
|
||||
commit.mutation_lease.clone()
|
||||
} else {
|
||||
os::acquire_metadata_mutation_lease(&source_object_path, Some(commit.mutation_lease.clone())).await
|
||||
};
|
||||
let authority = self.io_get_object_path(src_volume, &format!("{src_path}/{STORAGE_FORMAT_FILE}"))?;
|
||||
let recovery = self.io_get_object_path(
|
||||
src_volume,
|
||||
&format!("{src_path}/{}/{STORAGE_FORMAT_FILE}", super::super::MULTIPART_RECOVERY_DIR),
|
||||
)?;
|
||||
check_path_length(recovery.to_string_lossy().as_ref())?;
|
||||
os::rename_all_with_owner(
|
||||
&authority,
|
||||
&recovery,
|
||||
&src_volume_dir,
|
||||
commit.publication_root,
|
||||
Some(authority_owner.clone()),
|
||||
)
|
||||
.await?;
|
||||
// This is system metadata authority, so use its existing strict
|
||||
// durability policy even when the object payload is relaxed.
|
||||
if effective_durability(src_volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(recovery.parent().ok_or(DiskError::InvalidPath)?, Some(authority_owner.clone()))
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
os::fsync_dir_with_owner(src_file_parent, Some(authority_owner.clone()))
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
if no_inline {
|
||||
// Non-inline: read xl.meta, parse, write, rename data dir, rename xl.meta
|
||||
@@ -526,12 +648,6 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
let old_current_size = if dst_meta_unparsable {
|
||||
None
|
||||
} else {
|
||||
observe_old_current_size(has_dst_buf.is_some(), &xlmeta)
|
||||
};
|
||||
|
||||
let mut skip_parent = dst_volume_dir.clone();
|
||||
if has_dst_buf.as_ref().is_some()
|
||||
&& let Some(parent) = dst_file_path.parent()
|
||||
@@ -540,18 +656,11 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
let version_id = fi.version_id.unwrap_or_default();
|
||||
let (old_current_source_checked, old_current_source) = transitioned_old_current_source(
|
||||
has_dst_buf.is_some(),
|
||||
dst_meta_unparsable,
|
||||
&xlmeta,
|
||||
dst_volume,
|
||||
dst_path,
|
||||
version_id,
|
||||
);
|
||||
let (old_current_size, old_current_source_checked, old_current_source) =
|
||||
observe_old_current(has_dst_buf.is_some(), dst_meta_unparsable, &xlmeta, dst_volume, dst_path, version_id);
|
||||
let has_old_data_dir = xlmeta.find_unshared_data_dir_for_version(Some(version_id));
|
||||
let old_version_exists = xlmeta.find_version(Some(version_id)).is_ok();
|
||||
let rollback_data_dir = has_old_data_dir.or_else(|| {
|
||||
if old_version_exists && has_dst_buf.is_some() {
|
||||
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
|
||||
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
|
||||
} else {
|
||||
None
|
||||
@@ -908,14 +1017,8 @@ impl LocalDisk {
|
||||
#[cfg(windows)]
|
||||
drop(commit.directory_guard);
|
||||
|
||||
if let Some(src_file_path_parent) = src_file_path.parent() {
|
||||
if src_volume != super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
let _ = std::fs::remove_dir(src_file_path_parent);
|
||||
} else {
|
||||
let _ = self
|
||||
.delete_file(&dst_volume_dir, &src_file_path_parent.to_path_buf(), true, false)
|
||||
.await;
|
||||
}
|
||||
if !preserve_upload_metadata && let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
// Heal reuses a version's `data_dir` and lands the rebuilt shard on
|
||||
@@ -948,14 +1051,12 @@ impl LocalDisk {
|
||||
// allowing a retry to reuse the deterministic staging path too early.
|
||||
let src = src_file_path.clone();
|
||||
let dst = dst_file_path.clone();
|
||||
let cleanup_path = if src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
src_file_path.parent().map(|p| p.to_path_buf())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let dst_path_for_failpoint = dst_path.to_string();
|
||||
let dst_volume_for_source = dst_volume.to_string();
|
||||
let dst_path_for_source = dst_path.to_string();
|
||||
let dst_volume_dir_for_commit = dst_volume_dir.clone();
|
||||
let publication_root_for_commit = self.publication_root.clone();
|
||||
let cleanup_src_parent = !preserve_upload_metadata;
|
||||
#[cfg(windows)]
|
||||
let source_parent = src_file_parent.to_path_buf();
|
||||
let rename_commit_guard_for_preparation = commit.directory_guard.clone();
|
||||
@@ -972,7 +1073,7 @@ impl LocalDisk {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let prepare_inline_metadata = move || {
|
||||
let prepare_inline_metadata = move |is_cancelled: &dyn Fn() -> bool| {
|
||||
let mut prepared_metadata_source =
|
||||
os::create_prepared_rename_source_with_commit_guard(&src, &dst, &rename_commit_guard_for_preparation)?;
|
||||
#[cfg(windows)]
|
||||
@@ -993,14 +1094,8 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
let old_current_size = if dst_meta_unparsable {
|
||||
None
|
||||
} else {
|
||||
observe_old_current_size(has_dst_buf.is_some(), &xlmeta)
|
||||
};
|
||||
|
||||
let version_id = fi.version_id.unwrap_or_default();
|
||||
let (old_current_source_checked, old_current_source) = transitioned_old_current_source(
|
||||
let (old_current_size, old_current_source_checked, old_current_source) = observe_old_current(
|
||||
has_dst_buf.is_some(),
|
||||
dst_meta_unparsable,
|
||||
&xlmeta,
|
||||
@@ -1015,9 +1110,8 @@ impl LocalDisk {
|
||||
let rollback_data_dir = if fi_healing {
|
||||
None
|
||||
} else {
|
||||
let old_version_exists = xlmeta.find_version(Some(version_id)).is_ok();
|
||||
old_data_dir.or_else(|| {
|
||||
if old_version_exists && has_dst_buf.is_some() {
|
||||
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
|
||||
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
|
||||
} else {
|
||||
None
|
||||
@@ -1059,7 +1153,71 @@ impl LocalDisk {
|
||||
staged_rollback_path = Some(StagedRollbackBackup(backup_path));
|
||||
}
|
||||
|
||||
Ok::<_, std::io::Error>((
|
||||
let destination_was_absent = has_dst_buf.is_none();
|
||||
if !is_cancelled()
|
||||
&& rollback_data_dir.is_none()
|
||||
&& !os::dst_dir_fsync_group_commit_enabled()
|
||||
&& !has_inline_commit_test_failpoints(&dst_path_for_failpoint)
|
||||
{
|
||||
let rename_res = if should_fail_commit_rename(&dst_path_for_failpoint) {
|
||||
drop(prepared_metadata_source);
|
||||
Err(DiskError::other("test fail during metadata commit rename"))
|
||||
} else {
|
||||
os::rename_prepared_source_on_thread(
|
||||
prepared_metadata_source,
|
||||
&src,
|
||||
&dst,
|
||||
&dst_volume_dir_for_commit,
|
||||
&publication_root_for_commit,
|
||||
&rename_commit_guard_for_preparation,
|
||||
)
|
||||
};
|
||||
if let Err(err) = rename_res {
|
||||
if let Some(backup_path) = staged_rollback_path.as_ref().map(StagedRollbackBackup::path) {
|
||||
let _ = remove_file_if_exists(backup_path);
|
||||
}
|
||||
return Ok(InlinePreparationOutcome::CommitErr(err));
|
||||
}
|
||||
|
||||
if should_fail_after_metadata_commit(&dst_path_for_failpoint) {
|
||||
let err = rollback_inline_metadata_std(&dst, None, staged_rollback_path.as_ref())
|
||||
.err()
|
||||
.unwrap_or_else(|| std::io::Error::other("test fail after metadata commit"));
|
||||
return Ok(InlinePreparationOutcome::CommitErr(DiskError::from(err)));
|
||||
}
|
||||
|
||||
if sync
|
||||
&& let Err(err) = fsync_inline_commit_std(
|
||||
&dst,
|
||||
&dst_volume_dir_for_commit,
|
||||
destination_was_absent,
|
||||
staged_rollback_path.as_ref(),
|
||||
&rename_commit_guard_for_preparation,
|
||||
)
|
||||
{
|
||||
return Ok(InlinePreparationOutcome::CommitErr(DiskError::from(err)));
|
||||
}
|
||||
|
||||
if let Some(backup_path) = staged_rollback_path.as_ref().map(StagedRollbackBackup::path) {
|
||||
let _ = remove_file_if_exists(backup_path);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
if cleanup_src_parent && let Some(parent) = src.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(RenameDataResp {
|
||||
old_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir: old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok::<_, std::io::Error>(InlinePreparationOutcome::Prepared((
|
||||
rollback_data_dir,
|
||||
old_data_dir,
|
||||
version_signature,
|
||||
@@ -1067,19 +1225,22 @@ impl LocalDisk {
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
staged_rollback_path,
|
||||
has_dst_buf.is_none(),
|
||||
destination_was_absent,
|
||||
prepared_metadata_source,
|
||||
))
|
||||
)))
|
||||
};
|
||||
let inline_preparation = if let Some(admission) = file_sync_admission.as_ref() {
|
||||
os::run_blocking_namespace_file_sync_operation(commit.mutation_lease.clone(), admission, prepare_inline_metadata)
|
||||
.await
|
||||
os::run_cancellable_blocking_namespace_file_sync_operation(
|
||||
commit.mutation_lease.clone(),
|
||||
admission,
|
||||
prepare_inline_metadata,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
os::run_blocking_namespace_operation(commit.mutation_lease.clone(), prepare_inline_metadata).await
|
||||
os::run_cancellable_blocking_namespace_operation(commit.mutation_lease.clone(), prepare_inline_metadata).await
|
||||
}
|
||||
.map_err(to_file_error)
|
||||
.map_err(DiskError::from);
|
||||
|
||||
let (
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir,
|
||||
@@ -1091,13 +1252,27 @@ impl LocalDisk {
|
||||
destination_was_absent,
|
||||
prepared_metadata_source,
|
||||
) = match inline_preparation {
|
||||
Ok(prepared) => prepared,
|
||||
Err(err) => {
|
||||
Ok(InlinePreparationOutcome::Committed(resp)) => {
|
||||
drop(file_sync_admission.take());
|
||||
#[cfg(windows)]
|
||||
drop(commit.directory_guard);
|
||||
#[cfg(windows)]
|
||||
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
for part_path in &invalidate_part_paths {
|
||||
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Ok(InlinePreparationOutcome::CommitErr(err)) | Err(err) => {
|
||||
drop(file_sync_admission.take());
|
||||
for part_path in &invalidate_part_paths {
|
||||
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
Ok(InlinePreparationOutcome::Prepared(prepared)) => prepared,
|
||||
};
|
||||
|
||||
commit.directory_guard = remove_dst_base_before_commit(
|
||||
@@ -1301,9 +1476,7 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if let Some(ref cleanup) = cleanup_path {
|
||||
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
|
||||
} else if let Some(parent) = src_file_path.parent() {
|
||||
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ impl DiscoverySession {
|
||||
if let Some((object, id)) = bound {
|
||||
let transaction =
|
||||
if [PREPARED, STORAGE_FORMAT_FILE_BACKUP, DELETE_QUORUM_COMMITTED_FILE].contains(&name.as_str()) {
|
||||
Uuid::parse_str(id).ok().filter(|id| !id.is_nil())
|
||||
canonical_uuid(id).filter(|id| !id.is_nil())
|
||||
} else {
|
||||
[
|
||||
DELETE_DATA_DIR_MARKER_PREFIX,
|
||||
@@ -130,7 +130,7 @@ impl DiscoverySession {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if self.object.is_some() && (!parent.is_empty() || !Uuid::parse_str(&name).is_ok_and(|id| !id.is_nil())) {
|
||||
if self.object.is_some() && (!parent.is_empty() || !canonical_uuid(&name).is_some_and(|id| !id.is_nil())) {
|
||||
continue;
|
||||
}
|
||||
// A valid 1024-byte key can exceed 256 one-byte path
|
||||
@@ -578,7 +578,7 @@ fn explicit_delete_plan(
|
||||
let entry = entry.map_err(to_file_error)?;
|
||||
let kind = entry.file_type().map_err(to_file_error)?;
|
||||
let name = entry.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
|
||||
if kind.is_dir() && Uuid::parse_str(&name).is_ok_and(|id| referenced.contains(&id)) {
|
||||
if kind.is_dir() && canonical_uuid(&name).is_some_and(|id| referenced.contains(&id)) {
|
||||
candidates.push(entry.path());
|
||||
} else if kind.is_file()
|
||||
&& [
|
||||
@@ -609,7 +609,7 @@ fn explicit_delete_plan(
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(to_file_error)?;
|
||||
let name = entry.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
|
||||
let Some(id) = Uuid::parse_str(&name).ok().filter(|id| !id.is_nil()) else {
|
||||
let Some(id) = canonical_uuid(&name).filter(|id| !id.is_nil()) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.file_type().map_err(to_file_error)?.is_dir() {
|
||||
@@ -662,6 +662,13 @@ fn explicit_delete_plan(
|
||||
Ok(Some(ExplicitDeletePlan { data_dirs, proof_files }))
|
||||
}
|
||||
|
||||
/// Protocol directories always use the canonical hyphenated spelling;
|
||||
/// `Uuid::parse_str` also accepts simple, braced and URN forms, which can only
|
||||
/// name independent child keys here.
|
||||
fn canonical_uuid(name: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(name).ok().filter(|id| name == id.hyphenated().to_string())
|
||||
}
|
||||
|
||||
fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Result<DeleteRecoveryInspection> {
|
||||
let rollback = root.join(transaction.to_string());
|
||||
let before = regular_metadata_bytes(&rollback.join(STORAGE_FORMAT_FILE_BACKUP))?;
|
||||
@@ -746,8 +753,15 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
let name = entry.file_name().into_string().map_err(|_| DiskError::FileCorrupt)?;
|
||||
let id = canonical_uuid(&name);
|
||||
if kind.is_dir() && id.is_none() {
|
||||
// A non-UUID directory is only a prefix of independent child
|
||||
// keys, which settle under their own exact-key locks and may
|
||||
// vanish or appear concurrently. Settlement never touches it.
|
||||
continue;
|
||||
}
|
||||
descriptions.push((name.clone(), kind.is_dir(), entry.metadata().map_err(to_file_error)?.len()));
|
||||
if kind.is_dir() && Uuid::parse_str(&name).is_ok_and(|id| !id.is_nil()) {
|
||||
if kind.is_dir() && id.is_some_and(|id| !id.is_nil()) {
|
||||
for child in std::fs::read_dir(entry.path()).map_err(to_file_error)? {
|
||||
let child = child.map_err(to_file_error)?;
|
||||
let kind = child.file_type().map_err(to_file_error)?;
|
||||
@@ -768,7 +782,7 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
{
|
||||
any_marker = true;
|
||||
}
|
||||
let staged_id = Uuid::parse_str(&child_name).ok();
|
||||
let staged_id = canonical_uuid(&child_name);
|
||||
let known_staged = kind.is_dir()
|
||||
&& staged_id.is_some_and(|dir| {
|
||||
journal.as_ref().is_some_and(|journal| journal.data_dirs.contains(&dir))
|
||||
@@ -1205,7 +1219,7 @@ impl LocalDisk {
|
||||
let journal = journal_bytes(journal)?;
|
||||
self.wait_for_startup_cleanup().await;
|
||||
let temporary = self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?;
|
||||
let root = self.io_get_object_path(volume, object)?;
|
||||
let (bucket, root) = self.io_get_bucket_and_object_path(volume, object)?;
|
||||
let destination = root.join(transaction.to_string());
|
||||
let original = original.map(<[u8]>::to_vec);
|
||||
let sync = effective_durability(volume).syncs_commit_metadata();
|
||||
@@ -1213,7 +1227,7 @@ impl LocalDisk {
|
||||
let record_destination = destination.clone();
|
||||
let staging_root = temporary.clone();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&destination, owner.clone()).await;
|
||||
let (staged, target) = os::run_blocking_namespace_operation(lease, move || {
|
||||
let (staged, target) = os::run_blocking_namespace_operation_with_sync(lease, sync, move || {
|
||||
std::fs::create_dir_all(&staging_root)?;
|
||||
let staged = Arc::new(
|
||||
tempfile::Builder::new()
|
||||
@@ -1248,6 +1262,17 @@ impl LocalDisk {
|
||||
self.delete_recovery_barrier(volume, object, transaction, "PREACTIVATION")
|
||||
.await?;
|
||||
match fs::symlink_metadata(&destination).await {
|
||||
Ok(metadata) if metadata.is_dir() => {
|
||||
// Proof GC may persist before its final rmdir. Only an empty
|
||||
// leaf can be retired; nested prefixes and new evidence fail
|
||||
// closed under the same namespace ownership as publication.
|
||||
match os::remove_dir_with_owner(&destination, owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) if is_dir_not_empty_error(&err) => return Err(DiskError::OutdatedXLMeta),
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
}
|
||||
Ok(_) => return Err(DiskError::OutdatedXLMeta),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
@@ -1255,25 +1280,27 @@ impl LocalDisk {
|
||||
// The directory owner accompanies the blocking rename, so cancelling
|
||||
// its waiter cannot delete staged files while activation is running.
|
||||
let publication_owner: Option<Arc<dyn Send + Sync>> = Some(Arc::new((owner.clone(), staged.clone())));
|
||||
os::rename_all_with_owner(
|
||||
staged.path(),
|
||||
&destination,
|
||||
&self.io_get_bucket_path(volume)?,
|
||||
&self.publication_root,
|
||||
publication_owner,
|
||||
)
|
||||
.await?;
|
||||
os::rename_all_with_owner(staged.path(), &destination, &bucket, &self.publication_root, publication_owner).await?;
|
||||
if sync {
|
||||
let bucket = self.io_get_bucket_path(volume)?;
|
||||
let mut dirs_to_sync = Vec::new();
|
||||
let mut path = root.as_path();
|
||||
loop {
|
||||
os::fsync_dir_with_owner(path, owner.clone()).await.map_err(to_file_error)?;
|
||||
dirs_to_sync.push(path.to_path_buf());
|
||||
if path == bucket {
|
||||
break;
|
||||
}
|
||||
path = path.parent().ok_or(DiskError::InvalidPath)?;
|
||||
}
|
||||
os::fsync_dir_with_owner(&temporary, owner.clone())
|
||||
dirs_to_sync.push(temporary);
|
||||
let sync_owner = owner.clone();
|
||||
self.blocking_queue
|
||||
.run_fsync_cancellable(move |_| {
|
||||
let _owner = sync_owner;
|
||||
for dir in dirs_to_sync {
|
||||
os::fsync_dir_std(&dir)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
@@ -1708,18 +1735,15 @@ impl LocalDisk {
|
||||
}
|
||||
self.commit_reserved_version_delete_with_namespace_owner(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
for name in [STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE] {
|
||||
match os::remove_file_with_owner(rollback.join(name), owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
}
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(&rollback, owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
os::remove_files_and_fsync_with_owner(
|
||||
&rollback,
|
||||
&[STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE],
|
||||
effective_durability(volume).syncs_commit_metadata(),
|
||||
None,
|
||||
owner.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
let (_, complete) = self
|
||||
.finish_version_delete(volume, object, transaction, true, owner.clone())
|
||||
.await?;
|
||||
@@ -1773,18 +1797,17 @@ impl LocalDisk {
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
}
|
||||
write_delete_rollback_file(self.io_root(), volume, &root, transaction, FINISHED, &[], owner.clone()).await?;
|
||||
let receipt = CommitReceipt {
|
||||
journal: journal.clone(),
|
||||
finished: true,
|
||||
};
|
||||
write_delete_rollback_file(
|
||||
let receipt_bytes = serde_json::to_vec(&receipt)?;
|
||||
write_delete_rollback_files(
|
||||
self.io_root(),
|
||||
volume,
|
||||
&root,
|
||||
transaction,
|
||||
DELETE_QUORUM_COMMITTED_FILE,
|
||||
&serde_json::to_vec(&receipt)?,
|
||||
&[(FINISHED, &[]), (DELETE_QUORUM_COMMITTED_FILE, &receipt_bytes)],
|
||||
owner.clone(),
|
||||
)
|
||||
.await?;
|
||||
@@ -1793,21 +1816,15 @@ impl LocalDisk {
|
||||
if observed.state != DeleteRecoveryState::CommitFinished {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
for name in [TARGET, PREPARED, FINISHED] {
|
||||
match os::remove_file_with_owner(rollback.join(name), owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
}
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(&rollback, owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
os::remove_file_with_owner(rollback.join(DELETE_QUORUM_COMMITTED_FILE), owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
os::remove_files_and_fsync_with_owner(
|
||||
&rollback,
|
||||
&[TARGET, PREPARED, FINISHED],
|
||||
effective_durability(volume).syncs_commit_metadata(),
|
||||
Some(DELETE_QUORUM_COMMITTED_FILE),
|
||||
owner.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
self.delete_unleased_with_namespace_owner(
|
||||
volume,
|
||||
&format!("{object}/{transaction}"),
|
||||
@@ -2264,6 +2281,131 @@ mod tests {
|
||||
(temp, disk, info, bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_recovery_batch_parallelism_preserves_overlapping_key_order() {
|
||||
let opts = DeleteOptions {
|
||||
recoverable_delete: true,
|
||||
..Default::default()
|
||||
};
|
||||
for names in [
|
||||
vec!["a", "a"],
|
||||
vec!["a", "a-0", "a/child"],
|
||||
vec!["a//b", "a/b"],
|
||||
vec!["a/./b", "a/b"],
|
||||
vec!["a/../b", "b"],
|
||||
] {
|
||||
let groups: Vec<_> = names
|
||||
.into_iter()
|
||||
.map(|name| super::super::FileInfoVersions {
|
||||
name: name.to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
super::super::delete_version_batch_concurrency(&groups, &opts),
|
||||
1,
|
||||
"overlapping or noncanonical keys retain serial execution"
|
||||
);
|
||||
}
|
||||
let groups: Vec<_> = ["a/one", "a/two"]
|
||||
.into_iter()
|
||||
.map(|name| super::super::FileInfoVersions {
|
||||
name: name.to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let expected = if cfg!(target_os = "linux") {
|
||||
super::super::DELETE_VERSION_PREPARE_CONCURRENCY
|
||||
} else {
|
||||
1
|
||||
};
|
||||
assert_eq!(super::super::delete_version_batch_concurrency(&groups, &opts), expected);
|
||||
for options in [
|
||||
DeleteOptions::default(),
|
||||
DeleteOptions {
|
||||
undo_write: true,
|
||||
..opts.clone()
|
||||
},
|
||||
DeleteOptions {
|
||||
undo_delete: true,
|
||||
..opts
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
super::super::delete_version_batch_concurrency(&groups, &options),
|
||||
1,
|
||||
"legacy and rollback ordering is unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_batch_prepares_independent_keys_while_one_metadata_lease_is_held() {
|
||||
let (temp, disk, info, _) = fixture().await;
|
||||
let disk = Arc::new(disk);
|
||||
let mut healthy = info.clone();
|
||||
healthy.name = "healthy".to_owned();
|
||||
let healthy_root = temp.path().join("recovery/healthy");
|
||||
fs::create_dir_all(&healthy_root).await.expect("healthy object directory");
|
||||
fs::write(healthy_root.join(STORAGE_FORMAT_FILE), test_meta(healthy))
|
||||
.await
|
||||
.expect("healthy source");
|
||||
let corrupt_root = temp.path().join("recovery/corrupt");
|
||||
fs::create_dir_all(&corrupt_root).await.expect("corrupt object directory");
|
||||
fs::write(corrupt_root.join(STORAGE_FORMAT_FILE), b"invalid xl.meta")
|
||||
.await
|
||||
.expect("corrupt source");
|
||||
let held = os::acquire_metadata_mutation_lease(&temp.path().join("recovery/object"), None).await;
|
||||
let groups = ["object", "corrupt", "healthy"]
|
||||
.into_iter()
|
||||
.map(|name| super::super::FileInfoVersions {
|
||||
name: name.to_owned(),
|
||||
versions: vec![FileInfo {
|
||||
name: name.to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let worker_disk = disk.clone();
|
||||
let worker = tokio::spawn(async move {
|
||||
worker_disk
|
||||
.delete_versions_with_namespace_owner(
|
||||
"recovery",
|
||||
groups,
|
||||
DeleteOptions {
|
||||
recoverable_delete: true,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
});
|
||||
let prepared = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while fs::try_exists(healthy_root.join(STORAGE_FORMAT_FILE))
|
||||
.await
|
||||
.expect("inspect healthy metadata")
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(temp.path().join("recovery/object/xl.meta").exists(), "the locked source must stay intact");
|
||||
drop(held);
|
||||
let results = worker.await.expect("complete every disk result");
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![None, Some(DiskError::FileCorrupt), None],
|
||||
"completion order cannot change per-key error positions"
|
||||
);
|
||||
assert!(
|
||||
prepared.is_ok(),
|
||||
"one key's metadata lease cannot serialize unrelated transaction preparation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_absent_publish_refreshes_prepared_witness() {
|
||||
let (_temp, disk, _info, _original) = fixture().await;
|
||||
@@ -2763,6 +2905,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Data movement copies a delete marker onto a target that holds no
|
||||
// metadata yet. Rolling back that sub-quorum write removes the marker it
|
||||
// published, and must not report the backup it never wrote as missing:
|
||||
// that NotFound read as the first error of rustfs/backlog#2873 defect F.
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_rollback_of_marker_on_missing_object_is_clean() {
|
||||
for applied in [true, false] {
|
||||
let (_temp, disk, _info, _original) = fixture().await;
|
||||
let root = disk.io_get_object_path("recovery", "object").expect("object path");
|
||||
fs::remove_file(root.join(STORAGE_FORMAT_FILE)).await.expect("absent object");
|
||||
let transaction = Uuid::new_v4();
|
||||
let marker = FileInfo {
|
||||
volume: "recovery".to_owned(),
|
||||
name: "object".to_owned(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
deleted: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
disk.delete_version_recoverable(
|
||||
"recovery",
|
||||
"object",
|
||||
marker,
|
||||
true,
|
||||
DeleteOptions {
|
||||
old_data_dir: Some(transaction),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("prepared marker application");
|
||||
assert!(root.join(STORAGE_FORMAT_FILE).is_file());
|
||||
if !applied {
|
||||
// A drive that prepared the journal but never published.
|
||||
fs::remove_file(root.join(STORAGE_FORMAT_FILE)).await.unwrap();
|
||||
}
|
||||
let observed = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("inspect prepared marker");
|
||||
assert_eq!(observed.state, DeleteRecoveryState::NewPrepared);
|
||||
assert!(observed.journal.as_ref().unwrap().original_digest.is_none());
|
||||
|
||||
let restored = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
observed.fingerprint,
|
||||
DeleteRecoveryAction::Rollback,
|
||||
)
|
||||
.await
|
||||
.expect("rollback of a marker published on a missing object");
|
||||
assert_eq!(restored.state, DeleteRecoveryState::Absent);
|
||||
assert!(
|
||||
!root.join(STORAGE_FORMAT_FILE).exists(),
|
||||
"rollback removes the uncommitted marker (applied={applied})"
|
||||
);
|
||||
assert!(!root.join(transaction.to_string()).exists());
|
||||
// The NotFound this rename logged read as the first error of the
|
||||
// failed write. Assert the attempt rather than the log, which
|
||||
// tracing's process-wide callsite cache can hide under cargo test.
|
||||
let backup = root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
assert!(
|
||||
!crate::disk::local::DELETE_ROLLBACK_BACKUP_RESTORES
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&backup),
|
||||
"a rollback must not restore a backup it never wrote (applied={applied})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_preparation_records_existing_decision_before_new_generation() {
|
||||
let (_temp, disk, info, original) = fixture().await;
|
||||
@@ -3335,6 +3550,208 @@ mod tests {
|
||||
assert!(!disk.io_get_object_path("recovery", "object").expect("object path").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_fingerprint_ignores_prefix_child_keys() {
|
||||
// `object/child` is an independent key that only shares this key's
|
||||
// directory as a prefix. Its delete removes `object/child/` under its
|
||||
// own exact-key lock, so it can vanish (or appear) between this key's
|
||||
// inspection and settlement.
|
||||
// A content-addressed child key may look like a UUID in a spelling
|
||||
// that this protocol never writes.
|
||||
let names = ["child".to_owned(), Uuid::new_v4().simple().to_string()];
|
||||
for (child_appears, name) in [false, true]
|
||||
.into_iter()
|
||||
.flat_map(|appears| names.iter().map(move |name| (appears, name)))
|
||||
{
|
||||
let (_temp, disk, _info, _original) = fixture().await;
|
||||
let root = disk.io_get_object_path("recovery", "object").expect("object path");
|
||||
let child = root.join(name);
|
||||
if !child_appears {
|
||||
std::fs::create_dir_all(&child).expect("create child key prefix");
|
||||
std::fs::write(child.join(STORAGE_FORMAT_FILE), b"child").expect("write child key metadata");
|
||||
}
|
||||
let transaction = Uuid::new_v4();
|
||||
let applied = apply(&disk, transaction).await;
|
||||
let committed = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
applied.fingerprint,
|
||||
DeleteRecoveryAction::PublishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("durable set decision");
|
||||
let finished = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
committed.fingerprint,
|
||||
DeleteRecoveryAction::FinishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("physical cleanup");
|
||||
assert_eq!(finished.state, DeleteRecoveryState::CommitFinished);
|
||||
let observed = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("GC inspection");
|
||||
if child_appears {
|
||||
std::fs::create_dir_all(&child).expect("create child key prefix");
|
||||
std::fs::write(child.join(STORAGE_FORMAT_FILE), b"child").expect("write child key metadata");
|
||||
} else {
|
||||
std::fs::remove_dir_all(&child).expect("concurrent child key delete");
|
||||
}
|
||||
let settled = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
observed.fingerprint,
|
||||
DeleteRecoveryAction::FinalizeCommit,
|
||||
)
|
||||
.await
|
||||
.expect("a child key change is not a change of this key's protocol evidence");
|
||||
assert_eq!(settled.state, DeleteRecoveryState::Absent);
|
||||
assert!(!root.join(transaction.to_string()).exists(), "transaction proof must be finalized");
|
||||
assert_eq!(root.exists(), child_appears, "only a live child key may keep the prefix directory");
|
||||
if child_appears {
|
||||
assert_eq!(std::fs::read(child.join(STORAGE_FORMAT_FILE)).expect("child key survives"), b"child");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_settlement_ignores_non_canonical_data_dir_spelling() {
|
||||
// `object/<simple hex>` names a child key, even when it spells a data
|
||||
// dir referenced by this key's metadata.
|
||||
for legacy in [false, true] {
|
||||
let (_temp, disk, _info, _original) = fixture().await;
|
||||
let root = disk.io_get_object_path("recovery", "object").expect("object path");
|
||||
let data_dir = Uuid::new_v4();
|
||||
let metadata = test_meta(test_file_info("object", Uuid::nil(), Some(data_dir), None));
|
||||
let alias = root.join(data_dir.simple().to_string());
|
||||
let transaction = Uuid::new_v4();
|
||||
if legacy {
|
||||
legacy_fixture(&disk, &metadata, transaction).await;
|
||||
std::fs::create_dir(&alias).expect("create child key prefix");
|
||||
let state = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("inspect legacy residue");
|
||||
assert_eq!(state.state, DeleteRecoveryState::LegacyAmbiguous);
|
||||
assert!(state.explicit_delete_safe);
|
||||
let cleared = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
state.fingerprint,
|
||||
DeleteRecoveryAction::DiscardLegacy,
|
||||
)
|
||||
.await
|
||||
.expect("discard legacy residue");
|
||||
assert_eq!(cleared.state, DeleteRecoveryState::Absent);
|
||||
} else {
|
||||
std::fs::write(root.join(STORAGE_FORMAT_FILE), &metadata).expect("write data dir metadata");
|
||||
std::fs::create_dir(root.join(data_dir.to_string())).expect("create data dir");
|
||||
std::fs::write(root.join(data_dir.to_string()).join("part.1"), b"x").expect("write part");
|
||||
std::fs::create_dir(&alias).expect("create child key prefix");
|
||||
let applied = apply(&disk, transaction).await;
|
||||
let committed = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
applied.fingerprint,
|
||||
DeleteRecoveryAction::PublishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("durable set decision");
|
||||
let finished = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
committed.fingerprint,
|
||||
DeleteRecoveryAction::FinishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("physical cleanup");
|
||||
assert_eq!(finished.state, DeleteRecoveryState::CommitFinished);
|
||||
assert!(!root.join(data_dir.to_string()).exists(), "the canonical data dir is deleted");
|
||||
}
|
||||
assert!(alias.is_dir(), "legacy={legacy}: a non-canonical spelling is not this key's data dir");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_fingerprint_still_rejects_own_evidence_changes() {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Change {
|
||||
RootFile,
|
||||
DataDirPart,
|
||||
DataDir,
|
||||
}
|
||||
for change in [Change::RootFile, Change::DataDirPart, Change::DataDir] {
|
||||
for finalize in [false, true] {
|
||||
let (_temp, disk, _info, _original) = fixture().await;
|
||||
let root = disk.io_get_object_path("recovery", "object").expect("object path");
|
||||
// An unreferenced data dir that exists before inspection.
|
||||
let data_dir = root.join(Uuid::new_v4().to_string());
|
||||
std::fs::create_dir(&data_dir).expect("create data dir");
|
||||
let transaction = Uuid::new_v4();
|
||||
let mut observed = apply(&disk, transaction).await;
|
||||
if finalize {
|
||||
let committed = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
observed.fingerprint,
|
||||
DeleteRecoveryAction::PublishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("durable set decision");
|
||||
let finished = disk
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
committed.fingerprint,
|
||||
DeleteRecoveryAction::FinishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("physical cleanup");
|
||||
assert_eq!(finished.state, DeleteRecoveryState::CommitFinished);
|
||||
observed = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("GC inspection");
|
||||
}
|
||||
match change {
|
||||
Change::RootFile => std::fs::write(root.join("part.1"), b"x").expect("write root file"),
|
||||
Change::DataDirPart => std::fs::write(data_dir.join("part.1"), b"x").expect("write data dir part"),
|
||||
Change::DataDir => std::fs::create_dir(root.join(Uuid::new_v4().to_string())).expect("create data dir"),
|
||||
}
|
||||
let action = if finalize {
|
||||
DeleteRecoveryAction::FinalizeCommit
|
||||
} else {
|
||||
DeleteRecoveryAction::PublishCommit
|
||||
};
|
||||
assert_eq!(
|
||||
disk.settle_delete_transaction("recovery", "object", transaction, observed.fingerprint, action)
|
||||
.await
|
||||
.expect_err("a change of this key's own evidence must invalidate the snapshot"),
|
||||
DiskError::OutdatedXLMeta,
|
||||
"{change:?} before {}",
|
||||
if finalize { "FinalizeCommit" } else { "PublishCommit" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_reclaims_legacy_reservation_after_commit_and_snapshot_release() {
|
||||
for batch in [false, true] {
|
||||
@@ -3773,6 +4190,51 @@ mod tests {
|
||||
assert!(witness.original_digest.is_none() && witness.target_digest.is_none() && witness.data_dirs.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_activation_preserves_occupied_transaction_directory() {
|
||||
let (_source_temp, source, _, _) = fixture().await;
|
||||
let transaction = Uuid::new_v4();
|
||||
let applied = apply(&source, transaction).await;
|
||||
let mut witness = source
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
applied.fingerprint,
|
||||
DeleteRecoveryAction::PublishCommit,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.journal
|
||||
.unwrap();
|
||||
witness.original_digest = None;
|
||||
witness.target_digest = None;
|
||||
witness.data_dirs.clear();
|
||||
for nested in [false, true] {
|
||||
let (_peer_temp, peer, _, _) = fixture().await;
|
||||
let root = peer.io_get_object_path("recovery", "object").unwrap();
|
||||
fs::remove_file(root.join(STORAGE_FORMAT_FILE)).await.unwrap();
|
||||
let retained = root.join(transaction.to_string());
|
||||
fs::create_dir(&retained).await.unwrap();
|
||||
if nested {
|
||||
fs::create_dir(retained.join("prefix-child")).await.unwrap();
|
||||
} else {
|
||||
fs::write(retained.join("unknown-file"), b"retained").await.unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
peer.activate_delete_preparation(&witness, None, None, None).await,
|
||||
Err(DiskError::OutdatedXLMeta)
|
||||
));
|
||||
assert!(!retained.join(PREPARED).exists());
|
||||
if nested {
|
||||
assert!(retained.join("prefix-child").is_dir());
|
||||
} else {
|
||||
assert_eq!(fs::read(retained.join("unknown-file")).await.unwrap(), b"retained");
|
||||
}
|
||||
assert!(!root.join(STORAGE_FORMAT_FILE).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_raw_discovery_keeps_malformed_records_and_later_keys() {
|
||||
let (temp, disk, _, _) = fixture().await;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct Observation {
|
||||
rebuilds: usize,
|
||||
normalizations: usize,
|
||||
before_recheck: Option<Box<dyn FnOnce()>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static OBSERVATION: RefCell<Option<Observation>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Observe;
|
||||
|
||||
impl Observe {
|
||||
fn new(before_recheck: Option<Box<dyn FnOnce()>>) -> Self {
|
||||
OBSERVATION.with(|state| {
|
||||
assert!(state.borrow().is_none(), "read-path observations must not nest");
|
||||
*state.borrow_mut() = Some(Observation {
|
||||
rebuilds: 0,
|
||||
normalizations: 0,
|
||||
before_recheck,
|
||||
});
|
||||
});
|
||||
Self
|
||||
}
|
||||
|
||||
fn normalizations(&self) -> usize {
|
||||
OBSERVATION.with(|state| state.borrow().as_ref().expect("active observation").normalizations)
|
||||
}
|
||||
|
||||
fn rebuilds(&self) -> usize {
|
||||
OBSERVATION.with(|state| state.borrow().as_ref().expect("active observation").rebuilds)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Observe {
|
||||
fn drop(&mut self) {
|
||||
OBSERVATION.with(|state| *state.borrow_mut() = None);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_bucket_rebuild() {
|
||||
OBSERVATION.with(|state| {
|
||||
if let Some(observation) = state.borrow_mut().as_mut() {
|
||||
observation.rebuilds += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn record_normalization() {
|
||||
OBSERVATION.with(|state| {
|
||||
if let Some(observation) = state.borrow_mut().as_mut() {
|
||||
observation.normalizations += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn before_bucket_recheck() {
|
||||
let hook = OBSERVATION.with(|state| state.borrow_mut().as_mut().and_then(|value| value.before_recheck.take()));
|
||||
if let Some(hook) = hook {
|
||||
hook();
|
||||
}
|
||||
}
|
||||
|
||||
async fn local_disk(root: &Path) -> LocalDisk {
|
||||
let endpoint = Endpoint::try_from(root.to_string_lossy().as_ref()).expect("local endpoint");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
disk
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn checked_read_paths_reuse_bucket_construction() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = local_disk(root.path()).await;
|
||||
std::fs::create_dir_all(root.path().join("bucket/prefix/object")).expect("object directories");
|
||||
for key in ["prefix/object/xl.meta", "", "prefix/../object/xl.meta"] {
|
||||
let expected = build_local_disk_object_path(disk.io_root(), "bucket", key);
|
||||
let observation = Observe::new(None);
|
||||
let actual = disk.io_get_checked_read_paths("bucket", key).expect("checked read paths");
|
||||
assert_eq!(actual, expected, "returned paths must retain their existing spelling");
|
||||
assert_eq!(observation.rebuilds(), 0, "the validated tuple already owns its bucket path");
|
||||
// path_join_buf already resolves dot components in object keys.
|
||||
let expected_normalizations = if cfg!(windows) { 3 } else { 0 };
|
||||
assert_eq!(observation.normalizations(), expected_normalizations);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
assert_eq!(
|
||||
disk.io_get_bucket_and_object_open_path("bucket", key)
|
||||
.expect("read-version paths"),
|
||||
expected
|
||||
);
|
||||
assert_eq!(observation.rebuilds(), 0, "read_version shares the same checked path core");
|
||||
assert_eq!(observation.normalizations(), 2 * expected_normalizations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn checked_read_paths_preserve_containment_errors() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = local_disk(root.path()).await;
|
||||
for (bucket, key) in [("../outside", "object"), ("bucket", "../outside"), ("bucket", "/outside")] {
|
||||
let old = disk
|
||||
.io_get_object_path(bucket, key)
|
||||
.and_then(|path| disk.io_get_bucket_path(bucket).map(|volume| (volume, path)));
|
||||
let new = disk.io_get_checked_read_paths(bucket, key);
|
||||
assert_eq!(new, old, "path and error behavior for {bucket}/{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn checked_read_paths_metadata_bytes_and_absence_errors() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let disk = local_disk(root.path()).await;
|
||||
let object = root.path().join("bucket/prefix/object");
|
||||
std::fs::create_dir_all(&object).expect("object");
|
||||
let metadata = b"opaque metadata bytes passed through the read API";
|
||||
std::fs::write(object.join(STORAGE_FORMAT_FILE), metadata).expect("metadata");
|
||||
{
|
||||
let observation = Observe::new(None);
|
||||
let actual = disk
|
||||
.read_metadata("bucket", "prefix/object/xl.meta")
|
||||
.await
|
||||
.expect("read metadata");
|
||||
assert_eq!(actual.as_ref(), metadata);
|
||||
assert_eq!(observation.rebuilds(), 0, "scanner metadata reads use the shared path core");
|
||||
assert_eq!(observation.normalizations(), if cfg!(windows) { 3 } else { 0 });
|
||||
}
|
||||
assert!(matches!(
|
||||
disk.read_metadata("bucket", "missing/xl.meta").await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
assert!(matches!(
|
||||
disk.read_metadata("missing", "object/xl.meta").await,
|
||||
Err(DiskError::VolumeNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn checked_read_paths_recheck_bucket_after_object_validation() {
|
||||
let root = tempfile::tempdir().expect("disk root");
|
||||
let outside = tempfile::tempdir().expect("outside root");
|
||||
let disk = local_disk(root.path()).await;
|
||||
std::fs::create_dir_all(root.path().join("bucket/object")).expect("object");
|
||||
std::fs::write(root.path().join("bucket/object/xl.meta"), b"inside").expect("inside metadata");
|
||||
let bucket = root.path().join("bucket");
|
||||
let saved = root.path().join("saved");
|
||||
let target = outside.path().to_path_buf();
|
||||
let _observation = Observe::new(Some(Box::new(move || {
|
||||
std::fs::rename(&bucket, saved).expect("replace bucket after full-path validation");
|
||||
std::os::unix::fs::symlink(target, bucket).expect("bucket replacement symlink");
|
||||
})));
|
||||
assert!(
|
||||
matches!(disk.io_get_checked_read_paths("bucket", "object/xl.meta"), Err(DiskError::InvalidPath)),
|
||||
"the second bucket check must remain effective before queuing native IO"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_path_normalization_matches_component_normalizer() {
|
||||
for value in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"./a",
|
||||
"a/./b",
|
||||
"a//b/",
|
||||
"a/../b",
|
||||
"../../a",
|
||||
"/",
|
||||
"//a///b/",
|
||||
"/a/../b",
|
||||
"/../../a",
|
||||
r"C:\a\b",
|
||||
r"C:\a\..\b",
|
||||
r"\\server\share\a",
|
||||
r"\\?\C:\a\.\b",
|
||||
] {
|
||||
let path = Path::new(value);
|
||||
let expected = normalize_path_components(path);
|
||||
let observation = Observe::new(None);
|
||||
let actual = normalize_path_cow(path);
|
||||
assert_eq!(
|
||||
actual.components().collect::<Vec<_>>(),
|
||||
expected.components().collect::<Vec<_>>(),
|
||||
"{value}"
|
||||
);
|
||||
let needs_buffer = cfg!(windows)
|
||||
|| path
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir | std::path::Component::CurDir));
|
||||
assert_eq!(matches!(actual, std::borrow::Cow::Owned(_)), needs_buffer, "{value}");
|
||||
assert_eq!(observation.normalizations(), usize::from(needs_buffer), "{value}");
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,11 @@ pub mod os;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use io_admission::DiskIoClass;
|
||||
pub use io_admission::with_background_disk_io;
|
||||
pub(crate) use io_admission::{acquire_context_disk_io_permit, current_disk_io_context, with_optional_disk_io_context};
|
||||
pub(crate) use io_admission::{
|
||||
acquire_context_disk_io_permit, current_disk_io_context, current_resource_unit, with_optional_disk_io_context,
|
||||
with_optional_resource_unit,
|
||||
};
|
||||
pub use io_admission::{with_background_disk_io, with_background_resource_unit};
|
||||
|
||||
pub const RUSTFS_META_BUCKET: &str = ".rustfs.sys";
|
||||
pub const MIGRATING_META_BUCKET: &str = ".minio.sys";
|
||||
@@ -46,6 +49,9 @@ pub const FORMAT_CONFIG_FILE: &str = "format.json";
|
||||
pub const HEALING_MARKER_PATH: &str = "healing.bin";
|
||||
pub const STORAGE_FORMAT_FILE: &str = "xl.meta";
|
||||
pub const STORAGE_FORMAT_FILE_BACKUP: &str = "xl.meta.bkp";
|
||||
/// Original upload authority after it stops admitting active part mutations.
|
||||
pub(crate) const MULTIPART_RECOVERY_DIR: &str = ".abort-authority";
|
||||
pub(crate) const MULTIPART_COMMIT_METADATA_FILE: &str = "xl.meta.complete";
|
||||
pub const PART_TRANSACTION_NEW_META: &str = "new.meta";
|
||||
pub const PART_TRANSACTION_OLD_META: &str = "old.meta";
|
||||
pub const PART_TRANSACTION_ROLLBACK: &str = "rollback";
|
||||
@@ -79,12 +85,7 @@ use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
||||
use rustfs_madmin::info_commands::DiskMetrics;
|
||||
use rustfs_rio::ChunkReaderBox;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
path::PathBuf,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
time::Duration,
|
||||
};
|
||||
use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use uuid::Uuid;
|
||||
@@ -1907,12 +1908,6 @@ pub struct WalkDirOptions {
|
||||
// Override the remote stream stall timeout for long background walks.
|
||||
#[serde(default)]
|
||||
pub stall_timeout_ms: Option<u64>,
|
||||
|
||||
/// In-process completion state for bounded local walks. This is skipped
|
||||
/// from RPC serialization; remote peers retain the legacy natural-EOF
|
||||
/// behavior until they support an explicit capability.
|
||||
#[serde(skip)]
|
||||
pub producer_limit_reached: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl WalkDirOptions {
|
||||
@@ -2757,7 +2752,6 @@ mod tests {
|
||||
skip_total_timeout: false,
|
||||
timeout_ms: Some(10_000),
|
||||
stall_timeout_ms: Some(20_000),
|
||||
producer_limit_reached: None,
|
||||
};
|
||||
|
||||
assert_eq!(opts.bucket, "test-bucket");
|
||||
|
||||
+1542
-192
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
use crate::erasure::codec::workspace::RustfsCodecDecodeWorkspace;
|
||||
use crate::erasure::coding::Erasure;
|
||||
use rustfs_erasure_codec::galois_8::ReedSolomon;
|
||||
use smallvec::SmallVec;
|
||||
use std::io;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
@@ -213,7 +214,7 @@ impl RustfsCodecDecodeEngine {
|
||||
// All slots must be present here: the verification path reconstructs
|
||||
// missing parity alongside data (see `reconstruct_into`), so a `None`
|
||||
// slot is an internal invariant violation, not a degraded-read state.
|
||||
let mut shard_refs = Vec::with_capacity(self.data_shards + self.parity_shards);
|
||||
let mut shard_refs: SmallVec<[&[u8]; 16]> = SmallVec::with_capacity(self.data_shards + self.parity_shards);
|
||||
for (index, shard) in shards.iter().enumerate() {
|
||||
let shard = shard.as_ref().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
@@ -423,6 +424,30 @@ mod tests {
|
||||
engine.reconstruct_into(shards, &mut workspace).map(|_| ())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstruct_verifies_sources_across_descriptor_boundary() {
|
||||
let data = (0..1025)
|
||||
.map(|index| u8::try_from((index * 29 + 17) % 251).expect("bounded byte"))
|
||||
.collect::<Vec<_>>();
|
||||
for (data_shards, parity_shards) in [(8, 8), (9, 8)] {
|
||||
let erasure = Erasure::new(data_shards, parity_shards, 2048);
|
||||
let expected = encoded_shards(&erasure, &data);
|
||||
let engine = RustfsCodecDecodeEngine::new(&erasure).expect("decode engine");
|
||||
let mut shards = expected.clone();
|
||||
shards[0] = None;
|
||||
shards[data_shards] = None;
|
||||
reconstruct_with(&engine, &mut shards).expect("restore missing data and parity");
|
||||
assert_eq!(shards, expected, "every restored byte must match at width {}", shards.len());
|
||||
|
||||
let mut corrupt = expected.clone();
|
||||
corrupt[0] = None;
|
||||
corrupt[data_shards] = None;
|
||||
corrupt[data_shards + 1].as_mut().expect("surplus parity present")[0] ^= 1;
|
||||
let error = reconstruct_with(&engine, &mut corrupt).expect_err("corrupt surplus source must fail closed");
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_shards_complete_ignores_parity_slots() {
|
||||
let shards = vec![Some(vec![1]), Some(vec![2]), None];
|
||||
|
||||
@@ -189,12 +189,11 @@ impl LegacyReedSolomonEncoder {
|
||||
.is_some_and(|bytes| bytes <= LEGACY_REED_SOLOMON_CACHE_MAX_LOGICAL_SHARD_BYTES_PER_WORKSPACE)
|
||||
}
|
||||
|
||||
fn encode(&self, shards: SmallVec<[&mut [u8]; 16]>) -> io::Result<()> {
|
||||
let mut shards_vec: Vec<&mut [u8]> = shards.into_vec();
|
||||
if shards_vec.is_empty() {
|
||||
fn encode(&self, mut shards: SmallVec<[&mut [u8]; 16]>) -> io::Result<()> {
|
||||
if shards.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let shard_len = shards_vec[0].len();
|
||||
let shard_len = shards[0].len();
|
||||
let cached_encoder = self
|
||||
.encoder_cache
|
||||
.write()
|
||||
@@ -214,7 +213,7 @@ impl LegacyReedSolomonEncoder {
|
||||
.map_err(|e| io::Error::other(format!("Failed to create SIMD encoder: {e:?}")))?,
|
||||
}
|
||||
};
|
||||
for (i, shard) in shards_vec.iter().enumerate().take(self.data_shards) {
|
||||
for (i, shard) in shards.iter().enumerate().take(self.data_shards) {
|
||||
encoder
|
||||
.add_original_shard(shard)
|
||||
.map_err(|e| io::Error::other(format!("Failed to add shard {i}: {e:?}")))?;
|
||||
@@ -223,8 +222,8 @@ impl LegacyReedSolomonEncoder {
|
||||
.encode()
|
||||
.map_err(|e| io::Error::other(format!("SIMD encoding failed: {e:?}")))?;
|
||||
for (i, recovery_shard) in result.recovery_iter().enumerate() {
|
||||
if i + self.data_shards < shards_vec.len() {
|
||||
shards_vec[i + self.data_shards].copy_from_slice(recovery_shard);
|
||||
if i + self.data_shards < shards.len() {
|
||||
shards[i + self.data_shards].copy_from_slice(recovery_shard);
|
||||
}
|
||||
}
|
||||
drop(result);
|
||||
@@ -393,14 +392,13 @@ impl ReedSolomonEncoder {
|
||||
}
|
||||
|
||||
/// Encode data shards with parity.
|
||||
pub fn encode(&self, shards: SmallVec<[&mut [u8]; 16]>) -> io::Result<()> {
|
||||
let mut shards_vec: Vec<&mut [u8]> = shards.into_vec();
|
||||
if shards_vec.is_empty() {
|
||||
pub fn encode(&self, mut shards: SmallVec<[&mut [u8]; 16]>) -> io::Result<()> {
|
||||
if shards.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(ref rs) = self.encoder {
|
||||
rs.encode(&mut shards_vec)
|
||||
rs.encode(shards.as_mut_slice())
|
||||
.map_err(|e| io::Error::other(format!("Reed-Solomon encode failed: {e:?}")))
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -1010,7 +1008,7 @@ impl Erasure {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let mut shard_refs = Vec::with_capacity(expected_shards);
|
||||
let mut shard_refs: SmallVec<[&[u8]; 16]> = SmallVec::with_capacity(expected_shards);
|
||||
let mut shard_len = None;
|
||||
for (index, shard) in shards.iter().enumerate() {
|
||||
let shard = shard
|
||||
@@ -1208,6 +1206,53 @@ mod tests {
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::ReadBuf;
|
||||
|
||||
#[test]
|
||||
fn encode_preserves_codec_bytes_across_descriptor_boundary() {
|
||||
let data = (0..1025)
|
||||
.map(|index| u8::try_from((index * 29 + 17) % 251).expect("bounded byte"))
|
||||
.collect::<Vec<_>>();
|
||||
for uses_legacy in [false, true] {
|
||||
for (data_shards, parity_shards) in [(4, 2), (8, 8), (9, 8)] {
|
||||
let erasure = Erasure::new_with_options(data_shards, parity_shards, 2048, uses_legacy);
|
||||
let shards = erasure.encode_data(&data).expect("encode boundary layout");
|
||||
let joined = shards
|
||||
.iter()
|
||||
.take(data_shards)
|
||||
.flat_map(|shard| shard.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(&joined[..data.len()], &data);
|
||||
assert!(joined[data.len()..].iter().all(|byte| *byte == 0));
|
||||
if uses_legacy {
|
||||
let mut reference = reed_solomon_simd::ReedSolomonEncoder::new(data_shards, parity_shards, shards[0].len())
|
||||
.expect("independent legacy encoder");
|
||||
for shard in shards.iter().take(data_shards) {
|
||||
reference.add_original_shard(shard).expect("add original shard");
|
||||
}
|
||||
let result = reference.encode().expect("independent legacy parity");
|
||||
for (index, parity) in result.recovery_iter().enumerate() {
|
||||
assert_eq!(shards[data_shards + index].as_ref(), parity, "legacy parity {index}");
|
||||
}
|
||||
} else {
|
||||
let reference = ReedSolomon::new(data_shards, parity_shards).expect("independent modern codec");
|
||||
let refs = shards.iter().map(Bytes::as_ref).collect::<Vec<_>>();
|
||||
assert!(reference.verify(&refs).expect("verify independently generated parity"));
|
||||
}
|
||||
let mut optional = optional_shards(&shards);
|
||||
assert!(
|
||||
erasure
|
||||
.verify_data_and_parity(&optional)
|
||||
.expect("verify complete boundary layout")
|
||||
);
|
||||
optional[data_shards].as_mut().expect("parity present")[0] ^= 1;
|
||||
assert!(
|
||||
!erasure
|
||||
.verify_data_and_parity(&optional)
|
||||
.expect("detect corrupt boundary parity")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn erasure_with_invalid_dimensions(data_shards: usize, parity_shards: usize, block_size: usize) -> Erasure {
|
||||
Erasure {
|
||||
data_shards,
|
||||
|
||||
@@ -55,7 +55,7 @@ mod set_disk;
|
||||
mod storage_api_contracts;
|
||||
mod store;
|
||||
|
||||
pub use disk::with_background_disk_io;
|
||||
pub use disk::{with_background_disk_io, with_background_resource_unit};
|
||||
pub use store::PoolMetaWriteGateStatus;
|
||||
|
||||
// pub mod checksum;
|
||||
|
||||
@@ -283,6 +283,22 @@ impl ObjectLockConfigSnapshot {
|
||||
self.metadata_transaction_guard.clone()
|
||||
}
|
||||
|
||||
/// Returns the snapshot's validated bucket incarnation ID only while both the
|
||||
/// lifecycle read fence and the metadata-transaction guard remain active and
|
||||
/// unreleased for `(store_id, bucket)`.
|
||||
pub(crate) fn validated_bucket_incarnation_for(
|
||||
&self,
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
expected_incarnation_id: Option<Uuid>,
|
||||
) -> Option<Uuid> {
|
||||
let guard = self.metadata_transaction_guard_for(store_id, bucket, expected_incarnation_id)?;
|
||||
if guard.is_released() {
|
||||
return None;
|
||||
}
|
||||
self.bucket_incarnation_id
|
||||
}
|
||||
|
||||
pub(crate) fn add_lock_fences(&self, opts: &mut ObjectOptions) {
|
||||
opts.bucket_lifecycle_lock_fence
|
||||
.get_or_insert_with(NamespaceLockFence::new)
|
||||
@@ -991,6 +1007,12 @@ pub struct ObjectOptions {
|
||||
|
||||
pub data_movement: bool,
|
||||
pub raw_data_movement_read: bool,
|
||||
/// Data-movement source cleanup only: transitioned source versions whose
|
||||
/// committed target copy now references their remote object. Deleting one
|
||||
/// of them appends no tier free version. Storage-owned; callers outside
|
||||
/// `ecstore` must leave it unset.
|
||||
#[doc(hidden)]
|
||||
pub data_movement_transferred_tier_versions: Option<crate::data_movement::TransferredTierVersions>,
|
||||
/// Internal, in-memory protection context. Never populated from S3 metadata.
|
||||
/// None selects the rollout default only for a new write, not for a rewrite.
|
||||
#[doc(hidden)]
|
||||
@@ -1020,8 +1042,9 @@ pub struct ObjectOptions {
|
||||
/// fence avoids recursively acquiring the read lock behind a queued writer.
|
||||
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
|
||||
/// Retains the actual lifecycle guard for an owned multipart commit. A
|
||||
/// lock-loss fence alone does not keep a cancelled caller's lock alive.
|
||||
pub bucket_lifecycle_lock_owner: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
|
||||
/// lock-loss fence alone does not keep a cancelled caller's lock alive. The
|
||||
/// owner also retains the continuous incarnation-validation coverage.
|
||||
pub bucket_lifecycle_lock_owner: Option<Arc<crate::store::BucketIncarnationFenceGuard>>,
|
||||
pub replication_request: bool,
|
||||
/// True when the inbound request carried the
|
||||
/// `{x-rustfs-,x-minio-}source-proxy-request` header family with the
|
||||
@@ -1170,6 +1193,7 @@ impl std::fmt::Debug for ObjectOptions {
|
||||
.field("scanner_publication_commit_scope", &self.scanner_publication_commit_scope)
|
||||
.field("data_movement", &self.data_movement)
|
||||
.field("raw_data_movement_read", &self.raw_data_movement_read)
|
||||
.field("data_movement_transferred_tier_versions", &self.data_movement_transferred_tier_versions)
|
||||
.field("include_part_checksums", &self.include_part_checksums)
|
||||
.field("src_pool_idx", &self.src_pool_idx)
|
||||
.field("user_defined_count", &self.user_defined.len())
|
||||
@@ -1429,7 +1453,27 @@ fn update_replication_fingerprint_optional_str(hasher: &mut Sha256, value: Optio
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
static EMPTY_USER_DEFINED: std::sync::LazyLock<Arc<HashMap<String, String>>> =
|
||||
std::sync::LazyLock::new(|| Arc::new(HashMap::new()));
|
||||
static EMPTY_USER_TAGS: std::sync::LazyLock<Arc<String>> = std::sync::LazyLock::new(|| Arc::new(String::new()));
|
||||
static EMPTY_PARTS: std::sync::LazyLock<Arc<Vec<ObjectPartInfo>>> = std::sync::LazyLock::new(|| Arc::new(Vec::new()));
|
||||
|
||||
#[inline]
|
||||
fn empty_user_defined() -> Arc<HashMap<String, String>> {
|
||||
Arc::clone(&EMPTY_USER_DEFINED)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn empty_user_tags() -> Arc<String> {
|
||||
Arc::clone(&EMPTY_USER_TAGS)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn empty_parts() -> Arc<Vec<ObjectPartInfo>> {
|
||||
Arc::clone(&EMPTY_PARTS)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ObjectInfo {
|
||||
pub bucket: String,
|
||||
pub name: String,
|
||||
@@ -1477,6 +1521,50 @@ pub struct ObjectInfo {
|
||||
pub multipart_completion_replayed: bool,
|
||||
}
|
||||
|
||||
impl Default for ObjectInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bucket: String::new(),
|
||||
name: String::new(),
|
||||
storage_class: None,
|
||||
mod_time: None,
|
||||
size: 0,
|
||||
actual_size: 0,
|
||||
is_dir: false,
|
||||
user_defined: empty_user_defined(),
|
||||
parity_blocks: 0,
|
||||
data_blocks: 0,
|
||||
version_id: None,
|
||||
data_dir: None,
|
||||
delete_marker: false,
|
||||
transitioned_object: TransitionedObject::default(),
|
||||
transition_version_state: rustfs_filemeta::TransitionVersionState::default(),
|
||||
restore_ongoing: false,
|
||||
restore_expires: None,
|
||||
user_tags: empty_user_tags(),
|
||||
parts: empty_parts(),
|
||||
is_latest: false,
|
||||
content_type: None,
|
||||
content_encoding: None,
|
||||
expires: None,
|
||||
num_versions: 0,
|
||||
successor_mod_time: None,
|
||||
put_object_reader: None,
|
||||
etag: None,
|
||||
inlined: false,
|
||||
metadata_only: false,
|
||||
version_only: false,
|
||||
replication_status_internal: None,
|
||||
replication_status: ReplicationStatusType::default(),
|
||||
version_purge_status_internal: None,
|
||||
version_purge_status: VersionPurgeStatusType::default(),
|
||||
replication_decision: String::new(),
|
||||
checksum: None,
|
||||
multipart_completion_replayed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for ObjectInfo {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
@@ -1826,43 +1914,77 @@ impl ObjectInfo {
|
||||
Self::from_file_info_with_version_id(fi, bucket, object, version_id)
|
||||
}
|
||||
|
||||
pub fn from_owned_file_info(mut fi: FileInfo, bucket: &str, object: &str, versioned: bool) -> ObjectInfo {
|
||||
let mut version_id = fi.version_id;
|
||||
|
||||
if versioned && version_id.is_none() {
|
||||
version_id = Some(Uuid::nil())
|
||||
}
|
||||
|
||||
let inlined = fi.inline_data();
|
||||
let free_version = fi.tier_free_version();
|
||||
let metadata = std::mem::take(&mut fi.metadata);
|
||||
let parts = std::mem::take(&mut fi.parts);
|
||||
Self::from_file_info_parts(&fi, inlined, free_version, metadata, parts, bucket, object, version_id)
|
||||
}
|
||||
|
||||
pub(crate) fn from_file_info_with_version_id(
|
||||
fi: &FileInfo,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<Uuid>,
|
||||
) -> ObjectInfo {
|
||||
Self::from_file_info_parts(
|
||||
fi,
|
||||
fi.inline_data(),
|
||||
fi.tier_free_version(),
|
||||
fi.metadata.clone(),
|
||||
fi.parts.clone(),
|
||||
bucket,
|
||||
object,
|
||||
version_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn from_file_info_parts(
|
||||
fi: &FileInfo,
|
||||
inlined: bool,
|
||||
free_version: bool,
|
||||
mut metadata: HashMap<String, String>,
|
||||
parts: Vec<ObjectPartInfo>,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<Uuid>,
|
||||
) -> ObjectInfo {
|
||||
let name = decode_dir_object(object);
|
||||
|
||||
// etag
|
||||
let (content_type, content_encoding, etag) = {
|
||||
let content_type = fi.metadata.get("content-type").cloned();
|
||||
let content_encoding = fi.metadata.get("content-encoding").cloned();
|
||||
let etag = fi.metadata.get("etag").cloned();
|
||||
let content_type = metadata.get("content-type").cloned();
|
||||
let content_encoding = metadata.get("content-encoding").cloned();
|
||||
let etag = metadata.remove("etag");
|
||||
|
||||
(content_type, content_encoding, etag)
|
||||
};
|
||||
|
||||
// tags
|
||||
let user_tags: Arc<String> = fi
|
||||
.metadata
|
||||
.get(AMZ_OBJECT_TAGGING)
|
||||
.map(|s| Arc::new(s.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let inlined = fi.inline_data();
|
||||
let user_tags: Arc<String> = metadata
|
||||
.remove(AMZ_OBJECT_TAGGING)
|
||||
.map(Arc::new)
|
||||
.unwrap_or_else(empty_user_tags);
|
||||
|
||||
// Parse expires from metadata (HTTP date format RFC 7231 or ISO 8601)
|
||||
let expires = fi.metadata.get("expires").and_then(|s| {
|
||||
let expires = metadata.remove("expires").and_then(|s| {
|
||||
// Try parsing as ISO 8601 first
|
||||
OffsetDateTime::parse(s, &time::format_description::well_known::Iso8601::DEFAULT)
|
||||
OffsetDateTime::parse(&s, &time::format_description::well_known::Iso8601::DEFAULT)
|
||||
.or_else(|_| {
|
||||
// Try RFC 2822 format
|
||||
OffsetDateTime::parse(s, &time::format_description::well_known::Rfc2822)
|
||||
OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc2822)
|
||||
})
|
||||
.or_else(|_| {
|
||||
// Try RFC 3339 format
|
||||
OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
|
||||
OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
@@ -1883,7 +2005,7 @@ impl ObjectInfo {
|
||||
|
||||
let mut replication_status = replication_status_from_filemeta(fi.replication_status());
|
||||
if replication_status.is_empty()
|
||||
&& let Some(status) = fi.metadata.get(metadata_keys::REPLICATION_STATUS).cloned()
|
||||
&& let Some(status) = metadata.get(metadata_keys::REPLICATION_STATUS)
|
||||
&& status == ReplicationStatusType::Replica.as_str()
|
||||
{
|
||||
replication_status = ReplicationStatusType::Replica;
|
||||
@@ -1899,19 +2021,15 @@ impl ObjectInfo {
|
||||
.or_else(|| fi.transition_version_id.map(|version_id| version_id.to_string()))
|
||||
.unwrap_or_default(),
|
||||
status: fi.transition_status.clone(),
|
||||
free_version: fi.tier_free_version(),
|
||||
free_version,
|
||||
tier: fi.transition_tier.clone(),
|
||||
};
|
||||
|
||||
let metadata = {
|
||||
let mut v = fi.metadata.clone();
|
||||
clean_metadata(&mut v);
|
||||
v
|
||||
};
|
||||
clean_metadata(&mut metadata);
|
||||
|
||||
let storage_class = Some(
|
||||
storageclass::effective_class(
|
||||
fi.metadata.get(metadata_keys::STORAGE_CLASS).map(String::as_str),
|
||||
metadata.get(metadata_keys::STORAGE_CLASS).map(String::as_str),
|
||||
(fi.transition_status == rustfs_filemeta::TRANSITION_COMPLETE && !fi.transition_tier.is_empty())
|
||||
.then_some(fi.transition_tier.as_str()),
|
||||
)
|
||||
@@ -1920,29 +2038,19 @@ impl ObjectInfo {
|
||||
|
||||
let mut restore_ongoing = false;
|
||||
let mut restore_expires = None;
|
||||
if let Some(restore_status) = fi.metadata.get(metadata_keys::RESTORE).cloned()
|
||||
&& let Ok(restore_status) = parse_restore_obj_status(&restore_status)
|
||||
if let Some(restore_status) = metadata.get(metadata_keys::RESTORE)
|
||||
&& let Ok(restore_status) = parse_restore_obj_status(restore_status)
|
||||
{
|
||||
restore_ongoing = restore_status.on_going();
|
||||
restore_expires = restore_status.expiry();
|
||||
}
|
||||
|
||||
// Convert parts from rustfs_filemeta::ObjectPartInfo to object_api::ObjectPartInfo
|
||||
let parts = fi
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| ObjectPartInfo {
|
||||
etag: part.etag.clone(),
|
||||
index: part.index.clone(),
|
||||
size: part.size,
|
||||
actual_size: part.actual_size,
|
||||
mod_time: part.mod_time,
|
||||
checksums: part.checksums.clone(),
|
||||
number: part.number,
|
||||
error: part.error.clone(),
|
||||
integrity: part.integrity.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let user_defined = if metadata.is_empty() {
|
||||
empty_user_defined()
|
||||
} else {
|
||||
Arc::new(metadata)
|
||||
};
|
||||
let parts = if parts.is_empty() { empty_parts() } else { Arc::new(parts) };
|
||||
|
||||
ObjectInfo {
|
||||
bucket: bucket.to_string(),
|
||||
@@ -1955,7 +2063,7 @@ impl ObjectInfo {
|
||||
delete_marker: fi.deleted,
|
||||
mod_time: fi.mod_time,
|
||||
size: fi.size,
|
||||
parts: Arc::new(parts),
|
||||
parts,
|
||||
is_latest: fi.is_latest,
|
||||
user_tags,
|
||||
content_type,
|
||||
@@ -1965,7 +2073,7 @@ impl ObjectInfo {
|
||||
successor_mod_time: fi.successor_mod_time,
|
||||
etag,
|
||||
inlined,
|
||||
user_defined: Arc::new(metadata),
|
||||
user_defined,
|
||||
transitioned_object,
|
||||
transition_version_state: fi.transition_version_state,
|
||||
checksum: fi.checksum.clone(),
|
||||
@@ -2085,7 +2193,7 @@ impl ObjectInfo {
|
||||
}
|
||||
}
|
||||
|
||||
let file_infos = match entry.file_info_versions(bucket) {
|
||||
let mut file_infos = match entry.file_info_versions(bucket) {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
if fail_on_decode_error {
|
||||
@@ -2096,19 +2204,18 @@ impl ObjectInfo {
|
||||
}
|
||||
};
|
||||
|
||||
let versions = if let Some(marker) = after_version_marker.take() {
|
||||
versions_after_marker(&file_infos, marker)
|
||||
} else {
|
||||
&file_infos.versions
|
||||
};
|
||||
let start_idx = after_version_marker
|
||||
.take()
|
||||
.and_then(|marker| version_marker_index(&file_infos, marker))
|
||||
.map_or(0, |idx| idx + 1);
|
||||
let versioned = vcfg.as_ref().is_some_and(|(v, _)| v.versioned(&entry.name));
|
||||
|
||||
for fi in versions.iter() {
|
||||
for fi in file_infos.versions.drain(start_idx..) {
|
||||
if !include_version_purge && !fi.version_purge_status().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let versioned = vcfg.clone().map(|v| v.0.versioned(&entry.name)).unwrap_or_default();
|
||||
objects.push(ObjectInfo::from_file_info(fi, bucket, &entry.name, versioned));
|
||||
objects.push(ObjectInfo::from_owned_file_info(fi, bucket, &entry.name, versioned));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -2156,6 +2263,23 @@ impl ObjectInfo {
|
||||
let mut prev_prefix = "";
|
||||
for entry in entries.entries() {
|
||||
if entry.is_object() {
|
||||
let matched_delimiter = delimiter.as_ref().and_then(|delimiter| {
|
||||
let remaining = if entry.name.starts_with(prefix) {
|
||||
&entry.name[prefix.len()..]
|
||||
} else {
|
||||
entry.name.as_str()
|
||||
};
|
||||
remaining.find(delimiter.as_str()).map(|idx| {
|
||||
let idx = prefix.len() + idx + delimiter.len();
|
||||
entry.name.get(0..idx)
|
||||
})
|
||||
});
|
||||
if let Some(Some(curr_prefix)) = matched_delimiter
|
||||
&& curr_prefix == prev_prefix
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let fi = match entry.to_fileinfo(bucket) {
|
||||
Ok(res) => Some(res),
|
||||
Err(err) => {
|
||||
@@ -2168,38 +2292,26 @@ impl ObjectInfo {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(delimiter) = &delimiter {
|
||||
let remaining = if entry.name.starts_with(prefix) {
|
||||
&entry.name[prefix.len()..]
|
||||
} else {
|
||||
entry.name.as_str()
|
||||
};
|
||||
if let Some(idx) = remaining.find(delimiter.as_str()) {
|
||||
let idx = prefix.len() + idx + delimiter.len();
|
||||
if let Some(curr_prefix) = entry.name.get(0..idx) {
|
||||
if curr_prefix == prev_prefix {
|
||||
continue;
|
||||
}
|
||||
if let Some(maybe_prefix) = matched_delimiter {
|
||||
if let Some(curr_prefix) = maybe_prefix {
|
||||
prev_prefix = curr_prefix;
|
||||
|
||||
prev_prefix = curr_prefix;
|
||||
|
||||
objects.push(ObjectInfo {
|
||||
is_dir: true,
|
||||
bucket: bucket.to_owned(),
|
||||
name: curr_prefix.to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
continue;
|
||||
objects.push(ObjectInfo {
|
||||
is_dir: true,
|
||||
bucket: bucket.to_owned(),
|
||||
name: curr_prefix.to_owned(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(fi) = fi else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let versioned = vcfg.clone().map(|v| v.0.versioned(&entry.name)).unwrap_or_default();
|
||||
objects.push(ObjectInfo::from_file_info(&fi, bucket, &entry.name, versioned));
|
||||
let versioned = vcfg.as_ref().is_some_and(|(v, _)| v.versioned(&entry.name));
|
||||
objects.push(ObjectInfo::from_owned_file_info(fi, bucket, &entry.name, versioned));
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -2302,13 +2414,16 @@ impl ObjectInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn versions_after_marker(file_infos: &rustfs_filemeta::FileInfoVersions, marker: VersionMarker) -> &[FileInfo] {
|
||||
let marker_idx = match marker {
|
||||
fn version_marker_index(file_infos: &rustfs_filemeta::FileInfoVersions, marker: VersionMarker) -> Option<usize> {
|
||||
match marker {
|
||||
VersionMarker::Null => file_infos.versions.iter().position(|version| version.version_id.is_none()),
|
||||
VersionMarker::Version(vid) => file_infos.find_version_index(vid),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
marker_idx
|
||||
#[cfg(test)]
|
||||
fn versions_after_marker(file_infos: &rustfs_filemeta::FileInfoVersions, marker: VersionMarker) -> &[FileInfo] {
|
||||
version_marker_index(file_infos, marker)
|
||||
.map(|idx| &file_infos.versions[idx + 1..])
|
||||
.unwrap_or(&file_infos.versions)
|
||||
}
|
||||
@@ -3609,4 +3724,78 @@ mod tests {
|
||||
opts.ensure_lifecycle_delete_all_journal();
|
||||
assert!(opts.lifecycle_delete_all_journal().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_owned_file_info_matches_borrowed_and_delimiter_skips_duplicate_prefix_decode() {
|
||||
let valid_bytes = rustfs_filemeta::test_data::create_realistic_object_xlmeta().expect("realistic xl.meta");
|
||||
let mut fi = rustfs_filemeta::FileMeta::load(&valid_bytes)
|
||||
.expect("load realistic xl.meta")
|
||||
.into_fileinfo("bucket", "photos/a.jpg", "", false, false, true)
|
||||
.expect("decode fileinfo");
|
||||
fi.metadata.insert("etag".to_string(), "etag-123".to_string());
|
||||
fi.metadata.insert("md5Sum".to_string(), "md5-123".to_string());
|
||||
fi.metadata.insert("expires".to_string(), "2024-01-16T10:30:00Z".to_string());
|
||||
fi.metadata
|
||||
.insert(AMZ_OBJECT_TAGGING.to_string(), "env=prod&team=storage".to_string());
|
||||
fi.metadata
|
||||
.insert(metadata_keys::STORAGE_CLASS.to_string(), "STANDARD".to_string());
|
||||
|
||||
fi.set_tier_free_version();
|
||||
let borrowed = ObjectInfo::from_file_info(&fi, "bucket", "photos/a.jpg", true);
|
||||
let owned = ObjectInfo::from_owned_file_info(fi, "bucket", "photos/a.jpg", true);
|
||||
assert!(owned.inlined);
|
||||
assert!(owned.transitioned_object.free_version);
|
||||
assert_eq!(
|
||||
(
|
||||
&owned.bucket,
|
||||
&owned.name,
|
||||
owned.version_id,
|
||||
owned.size,
|
||||
owned.inlined,
|
||||
owned.transitioned_object.free_version,
|
||||
&owned.etag,
|
||||
owned.expires,
|
||||
&owned.user_tags,
|
||||
&owned.user_defined,
|
||||
&owned.parts,
|
||||
&owned.storage_class
|
||||
),
|
||||
(
|
||||
&borrowed.bucket,
|
||||
&borrowed.name,
|
||||
borrowed.version_id,
|
||||
borrowed.size,
|
||||
borrowed.inlined,
|
||||
borrowed.transitioned_object.free_version,
|
||||
&borrowed.etag,
|
||||
borrowed.expires,
|
||||
&borrowed.user_tags,
|
||||
&borrowed.user_defined,
|
||||
&borrowed.parts,
|
||||
&borrowed.storage_class
|
||||
),
|
||||
);
|
||||
|
||||
let entries = MetaCacheEntriesSorted {
|
||||
o: rustfs_filemeta::MetaCacheEntries(vec![
|
||||
Some(rustfs_filemeta::MetaCacheEntry {
|
||||
name: "photos/a.jpg".to_string(),
|
||||
metadata: valid_bytes,
|
||||
cached: None,
|
||||
reusable: false,
|
||||
}),
|
||||
Some(rustfs_filemeta::MetaCacheEntry {
|
||||
name: "photos/b.jpg".to_string(),
|
||||
metadata: b"corrupt-duplicate-prefix-entry".to_vec(),
|
||||
cached: None,
|
||||
reusable: false,
|
||||
}),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let grouped = ObjectInfo::from_meta_cache_entries_sorted_infos(&entries, "bucket", "", Some("/".to_string())).await;
|
||||
assert_eq!(grouped.len(), 1);
|
||||
assert!(grouped[0].is_dir);
|
||||
assert_eq!(grouped[0].name, "photos/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +83,16 @@ pub(crate) enum ScannerPublicationLeasePurpose {
|
||||
Observational,
|
||||
}
|
||||
|
||||
/// Movement read admission granted with a publication lease. Lease-scoped
|
||||
/// target operations share it rather than queueing a second read on the gate.
|
||||
pub(crate) type ScannerPublicationLeasePermit = Arc<OwnedRwLockReadGuard<()>>;
|
||||
|
||||
pub(crate) struct ScannerPublicationLeaseEntry {
|
||||
pub(crate) expires_at: Instant,
|
||||
pub(crate) movement_generation: u64,
|
||||
pub(crate) namespace_generation: Option<u64>,
|
||||
pub(crate) purpose: ScannerPublicationLeasePurpose,
|
||||
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
||||
pub(crate) operation_guard: ScannerPublicationLeasePermit,
|
||||
}
|
||||
|
||||
pub(crate) struct NamespaceCommitGuard {
|
||||
@@ -106,6 +110,82 @@ impl Drop for NamespaceCommitGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// A durable decommission pause as seen by this instance's worker watchdog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct DecommissionPauseRecord {
|
||||
pub(crate) reason: String,
|
||||
/// The pool's settled progress when it paused; see
|
||||
/// [`DecommissionCountLedger::progress`].
|
||||
pub(crate) progress: u64,
|
||||
/// Consecutive pauses on `reason` without progress in between.
|
||||
pub(crate) repeats: u32,
|
||||
/// The watchdog does not relaunch the pool before this instant.
|
||||
pub(crate) relaunch_after: Instant,
|
||||
}
|
||||
|
||||
/// What this instance counted for one decommission generation of a pool.
|
||||
///
|
||||
/// Progress counters persist only at checkpoints and a resumed pass relists
|
||||
/// the bucket, so a retained entry (one whose source stays) is seen again on
|
||||
/// every pass. The ledger keeps its counted versions so a relist in the same
|
||||
/// process does not count them twice.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct DecommissionCountLedger {
|
||||
generation: Option<time::OffsetDateTime>,
|
||||
/// (bucket, object, version id, delete marker) of retained versions.
|
||||
retained: HashSet<(String, String, Option<uuid::Uuid>, bool)>,
|
||||
retained_key_bytes: usize,
|
||||
/// Entries whose source was cleaned up. Only moving an entry off the pool
|
||||
/// advances it; a relisted retained entry cannot.
|
||||
cleaned_entries: u64,
|
||||
}
|
||||
|
||||
impl DecommissionCountLedger {
|
||||
const MAX_RETAINED_VERSIONS: usize = 1024;
|
||||
const MAX_RETAINED_KEY_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Whether this version is counted now: a retained version once, and a
|
||||
/// cleaned-up version unless it was already counted while retained.
|
||||
/// When the retained ledger is full, omit that progress count until source
|
||||
/// cleanup. This bounds diagnostic memory without evicting deduplication
|
||||
/// evidence or blocking data movement.
|
||||
pub(crate) fn admit(&mut self, bucket: &str, object: &str, version: (Option<uuid::Uuid>, bool), cleaned: bool) -> bool {
|
||||
let key_bytes = bucket.len().saturating_add(object.len());
|
||||
if cleaned {
|
||||
// Most successful entries never needed retained-version tracking.
|
||||
if self.retained.is_empty() || key_bytes > Self::MAX_RETAINED_KEY_BYTES {
|
||||
return true;
|
||||
}
|
||||
let key = (bucket.to_owned(), object.to_owned(), version.0, version.1);
|
||||
if self.retained.remove(&key) {
|
||||
self.retained_key_bytes -= key_bytes;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if self.retained.len() >= Self::MAX_RETAINED_VERSIONS
|
||||
|| key_bytes > Self::MAX_RETAINED_KEY_BYTES.saturating_sub(self.retained_key_bytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let key = (bucket.to_owned(), object.to_owned(), version.0, version.1);
|
||||
if self.retained.insert(key) {
|
||||
self.retained_key_bytes += key_bytes;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_cleaned_entry(&mut self) {
|
||||
self.cleaned_entries = self.cleaned_entries.saturating_add(1);
|
||||
}
|
||||
|
||||
pub(crate) fn progress(&self) -> u64 {
|
||||
self.cleaned_entries
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime state owned by a single `ECStore` instance.
|
||||
///
|
||||
/// This is intentionally minimal in the first migration slice; subsequent
|
||||
@@ -213,6 +293,16 @@ 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<()>>,
|
||||
/// Set while an operator decommission start runs on this instance. A
|
||||
/// start runs detached from its caller, so this keeps abandoned requests
|
||||
/// and their retries from stacking concurrent starts and preflight heals.
|
||||
decommission_start_in_flight: AtomicBool,
|
||||
/// The last durable pause of each local decommission, keyed by pool
|
||||
/// index. The worker watchdog backs off relaunching a pool that keeps
|
||||
/// pausing on the same cause without progress.
|
||||
decommission_pauses: std::sync::Mutex<HashMap<usize, DecommissionPauseRecord>>,
|
||||
/// Per-pool counting ledger of the current decommission generation.
|
||||
decommission_count_ledgers: std::sync::Mutex<HashMap<usize, DecommissionCountLedger>>,
|
||||
/// 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
|
||||
@@ -286,6 +376,9 @@ impl InstanceContext {
|
||||
bucket_metadata_sys: std::sync::Mutex::new(None),
|
||||
background_cancel_token: OnceLock::new(),
|
||||
data_movement_operation_gate: Arc::new(RwLock::new(())),
|
||||
decommission_start_in_flight: AtomicBool::new(false),
|
||||
decommission_pauses: std::sync::Mutex::new(HashMap::new()),
|
||||
decommission_count_ledgers: std::sync::Mutex::new(HashMap::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),
|
||||
@@ -326,6 +419,16 @@ impl InstanceContext {
|
||||
F: std::future::Future + Send + 'static,
|
||||
F::Output: Send + 'static,
|
||||
{
|
||||
// Capture before spawning: Tokio tasks do not inherit request task-locals.
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let mutation = crate::cluster::rpc::runtime_sources::with_captured_read_version_context(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionContextBoundary::DetachedMutation,
|
||||
mutation,
|
||||
);
|
||||
#[cfg(not(feature = "diagnostic-read-version-caller"))]
|
||||
let context = rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context();
|
||||
#[cfg(not(feature = "diagnostic-read-version-caller"))]
|
||||
let mutation = rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(context, mutation);
|
||||
self.detached_mutations.spawn(mutation.in_current_span()).await
|
||||
}
|
||||
|
||||
@@ -345,6 +448,68 @@ impl InstanceContext {
|
||||
Arc::clone(&self.data_movement_operation_gate)
|
||||
}
|
||||
|
||||
/// Claim the single decommission start slot; `false` when a start is
|
||||
/// already running. Release it with [`Self::end_decommission_start`].
|
||||
pub(crate) fn try_begin_decommission_start(&self) -> bool {
|
||||
self.decommission_start_in_flight
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn end_decommission_start(&self) {
|
||||
self.decommission_start_in_flight.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) fn decommission_start_running(&self) -> bool {
|
||||
self.decommission_start_in_flight.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Record a durable pause of `pool_index`; `update` derives the new record
|
||||
/// from the previous one.
|
||||
pub(crate) fn record_decommission_pause(
|
||||
&self,
|
||||
pool_index: usize,
|
||||
update: impl FnOnce(Option<&DecommissionPauseRecord>) -> DecommissionPauseRecord,
|
||||
) -> DecommissionPauseRecord {
|
||||
let mut pauses = self
|
||||
.decommission_pauses
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let record = update(pauses.get(&pool_index));
|
||||
pauses.insert(pool_index, record.clone());
|
||||
record
|
||||
}
|
||||
|
||||
/// Run `update` on `pool_index`'s counting ledger for `generation`,
|
||||
/// starting a fresh ledger when the generation changed.
|
||||
pub(crate) fn with_decommission_count_ledger<T>(
|
||||
&self,
|
||||
pool_index: usize,
|
||||
generation: time::OffsetDateTime,
|
||||
update: impl FnOnce(&mut DecommissionCountLedger) -> T,
|
||||
) -> T {
|
||||
let mut ledgers = self
|
||||
.decommission_count_ledgers
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let ledger = ledgers.entry(pool_index).or_default();
|
||||
if ledger.generation != Some(generation) {
|
||||
*ledger = DecommissionCountLedger {
|
||||
generation: Some(generation),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
update(ledger)
|
||||
}
|
||||
|
||||
pub(crate) fn decommission_pause(&self, pool_index: usize) -> Option<DecommissionPauseRecord> {
|
||||
self.decommission_pauses
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&pool_index)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) async fn install_scanner_publication_lease(
|
||||
&self,
|
||||
token: Uuid,
|
||||
@@ -365,7 +530,7 @@ impl InstanceContext {
|
||||
movement_generation,
|
||||
namespace_generation,
|
||||
purpose,
|
||||
_operation_guard: operation_guard,
|
||||
operation_guard: Arc::new(operation_guard),
|
||||
},
|
||||
);
|
||||
true
|
||||
@@ -383,16 +548,35 @@ impl InstanceContext {
|
||||
&self,
|
||||
token: Uuid,
|
||||
) -> Option<(u64, Option<u64>, ScannerPublicationLeasePurpose)> {
|
||||
self.scanner_publication_lease_admission(token)
|
||||
.await
|
||||
.map(|(movement_generation, namespace_generation, purpose, _)| (movement_generation, namespace_generation, purpose))
|
||||
}
|
||||
|
||||
/// Return the live lease's generations and purpose together with a share
|
||||
/// of its movement read admission. The share keeps movement writers out
|
||||
/// for as long as the caller holds it, even if the lease is released or
|
||||
/// expires meanwhile, without queueing a new read behind a waiting writer.
|
||||
pub(crate) async fn scanner_publication_lease_admission(
|
||||
&self,
|
||||
token: Uuid,
|
||||
) -> Option<(u64, Option<u64>, ScannerPublicationLeasePurpose, ScannerPublicationLeasePermit)> {
|
||||
let mut leases = self.scanner_publication_leases.lock().await;
|
||||
let now = Instant::now();
|
||||
let (expires_at, movement_generation, namespace_generation, purpose) = leases
|
||||
.get(&token)
|
||||
.map(|entry| (entry.expires_at, entry.movement_generation, entry.namespace_generation, entry.purpose))?;
|
||||
let (expires_at, movement_generation, namespace_generation, purpose, permit) = leases.get(&token).map(|entry| {
|
||||
(
|
||||
entry.expires_at,
|
||||
entry.movement_generation,
|
||||
entry.namespace_generation,
|
||||
entry.purpose,
|
||||
Arc::clone(&entry.operation_guard),
|
||||
)
|
||||
})?;
|
||||
if expires_at <= now {
|
||||
leases.remove(&token);
|
||||
return None;
|
||||
}
|
||||
Some((movement_generation, namespace_generation, purpose))
|
||||
Some((movement_generation, namespace_generation, purpose, permit))
|
||||
}
|
||||
|
||||
pub(crate) async fn expire_scanner_publication_lease(&self, token: Uuid, expires_at: Instant) {
|
||||
@@ -865,6 +1049,38 @@ pub fn bootstrap_ctx() -> Arc<InstanceContext> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decommission_count_ledger_bounds_retained_entries() {
|
||||
let mut ledger = super::DecommissionCountLedger::default();
|
||||
for index in 0..1024 {
|
||||
assert!(ledger.admit("bucket", &format!("key-{index}"), (None, false), false));
|
||||
}
|
||||
assert!(!ledger.admit("bucket", "overflow", (None, false), false));
|
||||
assert_eq!(ledger.retained.len(), 1024);
|
||||
// An uncounted retained version can still be counted after cleanup.
|
||||
assert!(ledger.admit("bucket", "overflow", (None, false), true));
|
||||
assert!(!ledger.admit("bucket", "key-0", (None, false), true));
|
||||
assert!(ledger.admit("bucket", "next", (None, false), false));
|
||||
assert!(!ledger.admit("bucket", "next", (None, false), false));
|
||||
assert_eq!(ledger.retained.len(), 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_count_ledger_bounds_retained_key_bytes() {
|
||||
let mut ledger = super::DecommissionCountLedger::default();
|
||||
let bucket = "b".repeat(63);
|
||||
let object_prefix = "x".repeat(1020);
|
||||
let mut admitted = 0;
|
||||
for index in 0..1024 {
|
||||
admitted += usize::from(ledger.admit(&bucket, &format!("{object_prefix}{index:04}"), (None, false), false));
|
||||
}
|
||||
assert!(admitted > 0 && admitted < 1024);
|
||||
assert!(ledger.retained.iter().map(|(b, o, _, _)| b.len() + o.len()).sum::<usize>() <= 1024 * 1024);
|
||||
// Removal releases the byte reservation as well as the entry slot.
|
||||
assert!(!ledger.admit(&bucket, &format!("{object_prefix}0000"), (None, false), true));
|
||||
assert!(ledger.admit(&bucket, &format!("{object_prefix}1024"), (None, false), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_commit_guards_are_instance_local_and_count_until_last_owner() {
|
||||
let first = Arc::new(InstanceContext::new());
|
||||
@@ -990,6 +1206,132 @@ mod tests {
|
||||
assert!(finished.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detached_mutations_keep_operation_and_guards_after_caller_drops() {
|
||||
use rustfs_io_metrics::s3_http_metrics::{
|
||||
S3HttpRequestGuard, current_s3_http_operation_context, current_s3_http_operation_name, with_s3_http_operation_context,
|
||||
};
|
||||
|
||||
let mut request = S3HttpRequestGuard::new("DELETE");
|
||||
let operation = request.in_scope(|| {
|
||||
rustfs_io_metrics::s3_http_metrics::observe_s3_http_operation(rustfs_s3_ops::S3Operation::DeleteObject);
|
||||
current_s3_http_operation_context().expect("delete operation was captured")
|
||||
});
|
||||
for parent in [Some(operation), None] {
|
||||
let ctx = InstanceContext::new();
|
||||
let gate = Arc::new(RwLock::new(()));
|
||||
let guard = gate.clone().read_owned().await;
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
|
||||
let expected = parent.map(|_| "s3:DeleteObject");
|
||||
let mut caller = Box::pin(with_s3_http_operation_context(
|
||||
parent,
|
||||
ctx.run_detached_mutation(async move {
|
||||
release_rx.await.expect("release detached mutation");
|
||||
result_tx
|
||||
.send(current_s3_http_operation_name())
|
||||
.expect("report detached operation");
|
||||
drop(guard);
|
||||
}),
|
||||
));
|
||||
assert!(futures::poll!(caller.as_mut()).is_pending());
|
||||
drop(caller);
|
||||
assert_eq!(current_s3_http_operation_name(), None, "parent scope was restored");
|
||||
assert_eq!(ctx.detached_mutation_count(), 1);
|
||||
assert!(gate.try_write().is_err(), "mutation retains the owned guard after caller cancellation");
|
||||
release_tx.send(()).expect("detached mutation is still alive");
|
||||
assert_eq!(result_rx.await.expect("detached operation result"), expected);
|
||||
assert!(ctx.wait_for_detached_mutations(std::time::Duration::from_secs(5)).await);
|
||||
assert!(gate.try_write().is_ok(), "completion releases the mutation guard");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detached_mutation_captures_operation_when_polled_and_keeps_results() {
|
||||
use rustfs_io_metrics::s3_http_metrics::{
|
||||
S3HttpRequestGuard, current_s3_http_operation_context, current_s3_http_operation_name, observe_s3_http_operation,
|
||||
with_s3_http_operation_context,
|
||||
};
|
||||
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
let operation = request.in_scope(|| {
|
||||
observe_s3_http_operation(rustfs_s3_ops::S3Operation::PutObject);
|
||||
current_s3_http_operation_context().expect("PUT operation context")
|
||||
});
|
||||
let ctx = InstanceContext::new();
|
||||
let mutation = ctx.run_detached_mutation(async { (current_s3_http_operation_name(), Err::<(), _>("mutation failure")) });
|
||||
let result = with_s3_http_operation_context(Some(operation), mutation)
|
||||
.await
|
||||
.expect("mutation task completes");
|
||||
assert_eq!(result, (Some("s3:PutObject"), Err("mutation failure")));
|
||||
assert_eq!(current_s3_http_operation_name(), None);
|
||||
|
||||
let mutation = request.in_scope(|| ctx.run_detached_mutation(async { current_s3_http_operation_name() }));
|
||||
assert_eq!(mutation.await.expect("unscoped poll completes"), None);
|
||||
|
||||
let failure =
|
||||
with_s3_http_operation_context(Some(operation), ctx.run_detached_mutation(async { panic!("mutation panic") }))
|
||||
.await
|
||||
.expect_err("child panic remains a join failure");
|
||||
assert!(failure.is_panic());
|
||||
assert_eq!(current_s3_http_operation_name(), None);
|
||||
assert!(ctx.wait_for_detached_mutations(std::time::Duration::from_secs(5)).await);
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[tokio::test]
|
||||
async fn detached_mutations_keep_existing_context_after_waiter_drop_and_nested_spawn() {
|
||||
use crate::cluster::rpc::runtime_sources::{
|
||||
current_read_version_entry_source, current_read_version_request_caller, with_read_version_entry,
|
||||
with_read_version_request_caller,
|
||||
};
|
||||
use rustfs_io_metrics::internode_metrics::{ReadVersionEntrySource, ReadVersionRequestCaller};
|
||||
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let child_ctx = Arc::clone(&ctx);
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
|
||||
let mut caller = Box::pin(with_read_version_request_caller(ReadVersionRequestCaller::Object, async {
|
||||
with_read_version_entry(ReadVersionEntrySource::LifecycleRecovery, async {
|
||||
ctx.run_detached_mutation(async move {
|
||||
entered_tx.send(()).expect("mutation caller is present");
|
||||
release_rx.await.expect("mutation may finish");
|
||||
let observed = with_read_version_entry(ReadVersionEntrySource::ConfigRead, async {
|
||||
child_ctx
|
||||
.run_detached_mutation(async {
|
||||
(current_read_version_request_caller(), current_read_version_entry_source())
|
||||
})
|
||||
.await
|
||||
.expect("nested mutation joins")
|
||||
})
|
||||
.await;
|
||||
finished_tx.send(observed).expect("completion observer remains live");
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}));
|
||||
assert!(futures::poll!(caller.as_mut()).is_pending());
|
||||
entered_rx.await.expect("mutation entered");
|
||||
drop(caller);
|
||||
assert_eq!(current_read_version_request_caller(), None);
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
assert_eq!(ctx.detached_mutation_count(), 1);
|
||||
release_tx.send(()).expect("mutation remains alive after waiter drop");
|
||||
assert_eq!(
|
||||
finished_rx.await.expect("nested mutation completes"),
|
||||
(Some(ReadVersionRequestCaller::Object), Some(ReadVersionEntrySource::LifecycleRecovery))
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.run_detached_mutation(async { (current_read_version_request_caller(), current_read_version_entry_source()) })
|
||||
.await
|
||||
.expect("unscoped mutation joins"),
|
||||
(None, None)
|
||||
);
|
||||
assert!(ctx.wait_for_detached_mutations(std::time::Duration::from_secs(5)).await);
|
||||
}
|
||||
|
||||
// A fresh context (before any update) reflects the initial all-false state.
|
||||
#[tokio::test]
|
||||
async fn fresh_context_is_all_false() {
|
||||
|
||||
@@ -239,6 +239,9 @@ fn to_madmin_scanner_metrics(metrics: rustfs_scanner_metrics::metrics::ScannerMe
|
||||
guardrail_veto_total: metrics.usage_freshness.guardrail_veto_total,
|
||||
last_guardrail_veto_unix_secs: metrics.usage_freshness.last_guardrail_veto_unix_secs,
|
||||
last_guardrail_veto_reason: metrics.usage_freshness.last_guardrail_veto_reason,
|
||||
scope_fallback_total: metrics.usage_freshness.scope_fallback_total,
|
||||
last_scope_fallback_unix_secs: metrics.usage_freshness.last_scope_fallback_unix_secs,
|
||||
last_scope_fallback_reason: metrics.usage_freshness.last_scope_fallback_reason,
|
||||
disk_io_budget_wait_events: metrics.usage_freshness.disk_io_budget_wait_events,
|
||||
disk_io_budget_wait_millis: metrics.usage_freshness.disk_io_budget_wait_millis,
|
||||
last_disk_io_budget_wait_unix_secs: metrics.usage_freshness.last_disk_io_budget_wait_unix_secs,
|
||||
@@ -901,6 +904,9 @@ mod test {
|
||||
guardrail_veto_total: 4,
|
||||
last_guardrail_veto_unix_secs: 17,
|
||||
last_guardrail_veto_reason: "publication_blocked@<local>".to_string(),
|
||||
scope_fallback_total: 5,
|
||||
last_scope_fallback_unix_secs: 18,
|
||||
last_scope_fallback_reason: "dirty_snapshot_incomplete".to_string(),
|
||||
disk_io_budget_wait_events: 5,
|
||||
disk_io_budget_wait_millis: 2_500,
|
||||
last_disk_io_budget_wait_unix_secs: 18,
|
||||
@@ -910,6 +916,9 @@ mod test {
|
||||
|
||||
assert_eq!(scanner.usage_freshness.guardrail_veto_total, 4);
|
||||
assert_eq!(scanner.usage_freshness.last_guardrail_veto_unix_secs, 17);
|
||||
assert_eq!(scanner.usage_freshness.scope_fallback_total, 5);
|
||||
assert_eq!(scanner.usage_freshness.last_scope_fallback_unix_secs, 18);
|
||||
assert_eq!(scanner.usage_freshness.last_scope_fallback_reason, "dirty_snapshot_incomplete");
|
||||
assert_eq!(scanner.usage_freshness.last_guardrail_veto_reason, "publication_blocked@<local>");
|
||||
assert_eq!(scanner.usage_freshness.dirty_pending_buckets, 3);
|
||||
assert_eq!(scanner.usage_freshness.last_dirty_mark_unix_secs, 10);
|
||||
|
||||
@@ -18,8 +18,9 @@ use super::{
|
||||
encode_rebalance_stop_propagation_record,
|
||||
};
|
||||
use crate::core::pools::{
|
||||
PoolMeta, PoolRebalanceActivationFence, PoolRebalanceWorkerFence, acquire_pool_activation_fleet_proof,
|
||||
acquire_pool_rebalance_activation_locks, acquire_pool_rebalance_worker_locks, pool_meta_has_active_decommission,
|
||||
GateWaitBudget, PoolMeta, PoolRebalanceActivationFence, PoolRebalanceWorkerFence, acquire_gate_within,
|
||||
acquire_pool_activation_fleet_proof, acquire_pool_rebalance_activation_locks, acquire_pool_rebalance_worker_locks,
|
||||
pool_meta_has_active_decommission,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::ObjectOptions;
|
||||
@@ -36,6 +37,10 @@ use time::OffsetDateTime;
|
||||
use tracing::{debug, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) const REBALANCE_REFRESH_START_GATE: &str = "start gate (rebalance metadata refresh)";
|
||||
pub(crate) const REBALANCE_REFRESH_ACTIVATION_GATE: &str = "rebalance activation gate (rebalance metadata refresh)";
|
||||
pub(crate) const REBALANCE_REFRESH_MOVEMENT_GATE: &str = "data movement gate (rebalance metadata refresh)";
|
||||
|
||||
#[cfg(test)]
|
||||
static FAIL_NEXT_REBALANCE_ACTIVATION_SAVE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
@@ -383,6 +388,15 @@ fn merge_rebalance_status_refresh(current: &mut Option<RebalanceMeta>, persisted
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a persisted status refresh (`None` when no rebalance was recorded).
|
||||
/// Returns whether the movement snapshot changed.
|
||||
fn merge_persisted_rebalance_status(current: &mut Option<RebalanceMeta>, persisted: Option<RebalanceMeta>) -> bool {
|
||||
match persisted {
|
||||
Some(persisted) => merge_rebalance_status_refresh(current, persisted),
|
||||
None => clear_rebalance_status_refresh(current),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_rebalance_status_refresh(current: &mut Option<RebalanceMeta>) -> bool {
|
||||
if current.as_ref().is_none_or(|meta| !is_rebalance_actively_running(meta)) {
|
||||
current.take().is_some()
|
||||
@@ -417,6 +431,15 @@ impl ECStore {
|
||||
&self,
|
||||
expected_id: Option<&str>,
|
||||
stage: &str,
|
||||
) -> Result<Option<tokio::sync::OwnedRwLockWriteGuard<()>>> {
|
||||
self.rebalance_activation_write_guard_within(expected_id, stage, None).await
|
||||
}
|
||||
|
||||
async fn rebalance_activation_write_guard_within(
|
||||
&self,
|
||||
expected_id: Option<&str>,
|
||||
stage: &str,
|
||||
budget: Option<&GateWaitBudget>,
|
||||
) -> Result<Option<tokio::sync::OwnedRwLockWriteGuard<()>>> {
|
||||
let activation_gate = {
|
||||
let meta = self.rebalance_meta.read().await;
|
||||
@@ -426,7 +449,7 @@ impl ECStore {
|
||||
meta.as_ref().map(|meta| Arc::clone(&meta.activation_gate))
|
||||
};
|
||||
Ok(match activation_gate {
|
||||
Some(gate) => Some(gate.write_owned().await),
|
||||
Some(gate) => Some(acquire_gate_within(budget, REBALANCE_REFRESH_ACTIVATION_GATE, gate.write_owned()).await?),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
@@ -656,8 +679,14 @@ impl ECStore {
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn load_rebalance_meta(&self) -> Result<()> {
|
||||
let _start_guard = self.start_gate.lock().await;
|
||||
self.load_rebalance_meta_under_start_gate().await
|
||||
self.load_rebalance_meta_within(None).await
|
||||
}
|
||||
|
||||
/// `budget` bounds the waits for the start, activation and movement gates;
|
||||
/// once it is spent the load fails before it reads or publishes anything.
|
||||
pub(crate) async fn load_rebalance_meta_within(&self, budget: Option<&GateWaitBudget>) -> Result<()> {
|
||||
let _start_guard = acquire_gate_within(budget, REBALANCE_REFRESH_START_GATE, self.start_gate.lock()).await?;
|
||||
self.load_rebalance_meta_under_start_gate_within(budget).await
|
||||
}
|
||||
|
||||
/// Cancels local admission before refreshing the persisted stop target under the start gate.
|
||||
@@ -713,6 +742,12 @@ impl ECStore {
|
||||
}
|
||||
|
||||
pub(crate) async fn load_rebalance_meta_under_start_gate(&self) -> Result<()> {
|
||||
self.load_rebalance_meta_under_start_gate_within(None).await
|
||||
}
|
||||
|
||||
/// `budget` bounds the waits for the activation and movement writers;
|
||||
/// once it is spent the load fails before it reads or publishes anything.
|
||||
pub(crate) async fn load_rebalance_meta_under_start_gate_within(&self, budget: Option<&GateWaitBudget>) -> Result<()> {
|
||||
let mut meta = RebalanceMeta::new();
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_STATE,
|
||||
@@ -722,9 +757,11 @@ impl ECStore {
|
||||
"Loading rebalance metadata"
|
||||
);
|
||||
let pool = clone_first_arc(&self.pools, "rebalanceMeta: no pools available")?;
|
||||
let _activation_guard = self.rebalance_activation_write_guard(None, "load rebalance metadata").await?;
|
||||
let _activation_guard = self
|
||||
.rebalance_activation_write_guard_within(None, "load rebalance metadata", budget)
|
||||
.await?;
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
let _movement_guard = acquire_gate_within(budget, REBALANCE_REFRESH_MOVEMENT_GATE, movement_gate.write()).await?;
|
||||
let loaded = resolve_rebalance_meta_load_result(meta.load(pool).await)?;
|
||||
if loaded {
|
||||
let movement_changed = rebalance_movement_snapshot_changed(self.rebalance_meta.read().await.as_ref(), &meta);
|
||||
@@ -777,36 +814,60 @@ impl ECStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebalance metadata for an admin status read. It never waits on the
|
||||
/// start, activation or data-movement gates, which a pending start or an
|
||||
/// in-flight movement side effect can hold for as long as that work runs.
|
||||
/// With all three free, the refresh publishes as before. Otherwise the
|
||||
/// persisted metadata is merged into a private copy and nothing is
|
||||
/// published, so no movement transition becomes visible here without the
|
||||
/// movement writer.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn refresh_rebalance_status_meta(&self) -> Result<()> {
|
||||
let _start_guard = self.start_gate.lock().await;
|
||||
let pool = clone_first_arc(&self.pools, "refresh_rebalance_status_meta: no pools available")?;
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
let _activation_guard = self
|
||||
.rebalance_activation_write_guard(None, "refresh rebalance metadata")
|
||||
.await?;
|
||||
let movement_gate = self.ctx.data_movement_operation_gate();
|
||||
let _movement_guard = movement_gate.write().await;
|
||||
let loaded = persisted.load(pool).await;
|
||||
match loaded {
|
||||
Ok(()) => {
|
||||
pub async fn rebalance_status_meta_snapshot(&self) -> Result<Option<RebalanceMeta>> {
|
||||
// Transition order is start_gate -> activation_gate -> movement gate -> rebalance_meta.
|
||||
if let Ok(_start_guard) = self.start_gate.try_lock() {
|
||||
let activation_gate = self
|
||||
.rebalance_meta
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|meta| Arc::clone(&meta.activation_gate));
|
||||
let activation_guard = match activation_gate {
|
||||
Some(gate) => gate.try_write_owned().ok().map(Some),
|
||||
None => Some(None),
|
||||
};
|
||||
if let Some(_activation_guard) = activation_guard
|
||||
&& let Ok(_movement_guard) = self.ctx.data_movement_operation_gate().try_write_owned()
|
||||
{
|
||||
let persisted = self.load_rebalance_status_meta().await?;
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
if merge_rebalance_status_refresh(&mut rebalance_meta, persisted) {
|
||||
if merge_persisted_rebalance_status(&mut rebalance_meta, persisted) {
|
||||
self.ctx.advance_data_movement_operation_epoch();
|
||||
}
|
||||
}
|
||||
Err(Error::ConfigNotFound) => {
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
if clear_rebalance_status_refresh(&mut rebalance_meta) {
|
||||
self.ctx.advance_data_movement_operation_epoch();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(Error::other(format!("rebalance metadata refresh failed during pool status: {err}")));
|
||||
return Ok(rebalance_meta.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let persisted = self.load_rebalance_status_meta().await?;
|
||||
let mut snapshot = self.rebalance_meta.read().await.clone();
|
||||
merge_persisted_rebalance_status(&mut snapshot, persisted);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Persisted rebalance metadata, or `None` when no rebalance was recorded.
|
||||
async fn load_rebalance_status_meta(&self) -> Result<Option<RebalanceMeta>> {
|
||||
let pool = clone_first_arc(&self.pools, "refresh_rebalance_status_meta: no pools available")?;
|
||||
let mut persisted = RebalanceMeta::new();
|
||||
match persisted.load(pool).await {
|
||||
Ok(()) => Ok(Some(persisted)),
|
||||
Err(Error::ConfigNotFound) => Ok(None),
|
||||
Err(err) => Err(Error::other(format!("rebalance metadata refresh failed during pool status: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebalance status of one pool in a snapshot returned by
|
||||
/// [`ECStore::rebalance_status_meta_snapshot`].
|
||||
pub fn pool_rebalance_status(rebalance_meta: Option<&RebalanceMeta>, pool_index: usize) -> (RebalStatus, bool) {
|
||||
pool_rebalance_status_from_meta(rebalance_meta, pool_index)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
@@ -1234,11 +1295,6 @@ impl ECStore {
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn pool_rebalance_status(&self, pool_index: usize) -> (RebalStatus, bool) {
|
||||
let rebalance_meta = self.rebalance_meta.read().await;
|
||||
pool_rebalance_status_from_meta(rebalance_meta.as_ref(), pool_index)
|
||||
}
|
||||
|
||||
pub async fn current_rebalance_id(&self) -> Option<String> {
|
||||
let rebalance_meta = self.rebalance_meta.read().await;
|
||||
rebalance_meta
|
||||
@@ -1403,6 +1459,117 @@ mod tests {
|
||||
use crate::object_api::NamespaceLockFence;
|
||||
use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause, hermetic_set_disks_isolated};
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebalance_status_snapshot_does_not_wait_for_a_pending_start_or_stuck_movement() {
|
||||
let id = "rebalance-status-snapshot";
|
||||
let (_temp_dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(RebalanceMeta {
|
||||
id: id.to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Completed,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
// This node has not observed the persisted run yet.
|
||||
*store.rebalance_meta.write().await = None;
|
||||
|
||||
// A start waiting on the movement writer holds the start gate, and an
|
||||
// in-flight movement side effect holds its read admission.
|
||||
let pending_start = store.start_gate.lock().await;
|
||||
let stuck_side_effect = store.ctx.data_movement_operation_gate().read_owned().await;
|
||||
let epoch = store.ctx.data_movement_operation_epoch();
|
||||
let snapshot = tokio::time::timeout(std::time::Duration::from_secs(10), store.rebalance_status_meta_snapshot())
|
||||
.await
|
||||
.expect("a status read must not wait for a start or in-flight data movement")
|
||||
.expect("persisted rebalance metadata should be readable");
|
||||
assert_eq!(snapshot.as_ref().map(|meta| meta.id.as_str()), Some(id));
|
||||
assert_eq!(
|
||||
ECStore::pool_rebalance_status(snapshot.as_ref(), 0),
|
||||
(RebalStatus::Completed, false),
|
||||
"the status read reports the persisted run"
|
||||
);
|
||||
assert!(
|
||||
store.rebalance_meta.read().await.is_none(),
|
||||
"busy gates must keep the refresh from publishing"
|
||||
);
|
||||
assert_eq!(store.ctx.data_movement_operation_epoch(), epoch);
|
||||
|
||||
drop(stuck_side_effect);
|
||||
drop(pending_start);
|
||||
store
|
||||
.rebalance_status_meta_snapshot()
|
||||
.await
|
||||
.expect("free gates should allow the refresh to publish");
|
||||
assert_eq!(
|
||||
store.rebalance_meta.read().await.as_ref().map(|meta| meta.id.clone()),
|
||||
Some(id.to_string())
|
||||
);
|
||||
assert!(
|
||||
store.ctx.data_movement_operation_epoch() > epoch,
|
||||
"a published refresh advances the epoch"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebalance_status_snapshot_does_not_wait_for_an_in_flight_rebalance_entry() {
|
||||
let id = "rebalance-status-activation";
|
||||
let (_temp_dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(RebalanceMeta {
|
||||
id: id.to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Completed,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
// This node knows the run but not yet its per-pool state.
|
||||
let activation_gate = {
|
||||
let mut rebalance_meta = store.rebalance_meta.write().await;
|
||||
let meta = rebalance_meta.as_mut().expect("the fixture installs the run in memory");
|
||||
meta.pool_stats.clear();
|
||||
Arc::clone(&meta.activation_gate)
|
||||
};
|
||||
|
||||
// An in-flight rebalance entry holds the activation read gate.
|
||||
let in_flight_entry = activation_gate.read_owned().await;
|
||||
let epoch = store.ctx.data_movement_operation_epoch();
|
||||
let snapshot = tokio::time::timeout(std::time::Duration::from_secs(10), store.rebalance_status_meta_snapshot())
|
||||
.await
|
||||
.expect("a status read must not wait for an in-flight rebalance entry")
|
||||
.expect("persisted rebalance metadata should be readable");
|
||||
assert_eq!(
|
||||
snapshot.as_ref().map(|meta| meta.pool_stats.len()),
|
||||
Some(1),
|
||||
"the status read reports the persisted pool state"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.rebalance_meta
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.is_some_and(|meta| meta.pool_stats.is_empty()),
|
||||
"a busy activation gate must keep the refresh from publishing"
|
||||
);
|
||||
assert_eq!(store.ctx.data_movement_operation_epoch(), epoch);
|
||||
|
||||
drop(in_flight_entry);
|
||||
store
|
||||
.rebalance_status_meta_snapshot()
|
||||
.await
|
||||
.expect("free gates should allow the refresh to publish");
|
||||
assert_eq!(store.rebalance_meta.read().await.as_ref().map(|meta| meta.pool_stats.len()), Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebalance_status_read_recovers_after_metadata_lock_timeout() {
|
||||
let id = "rebalance-status-lock-retry";
|
||||
@@ -1422,7 +1589,7 @@ mod tests {
|
||||
.expect("hold metadata writer");
|
||||
let retried = Arc::new(tokio::sync::Notify::new());
|
||||
let refresh = super::super::worker::REBALANCE_METADATA_RETRY_PROBE
|
||||
.scope(Arc::clone(&retried), store.refresh_rebalance_status_meta());
|
||||
.scope(Arc::clone(&retried), store.rebalance_status_meta_snapshot());
|
||||
tokio::pin!(refresh);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
tokio::select! {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
use super::worker::{is_transient_rebalance_error, rebalance_migration_retry_delay, sleep_rebalance_migration_retry};
|
||||
use crate::bucket::replication::replication_state_from_filemeta;
|
||||
use crate::data_movement::DataMovementCopy;
|
||||
use crate::data_usage::DATA_USAGE_CACHE_NAME;
|
||||
use crate::error::{Error, Result, is_err_object_not_found, is_err_version_not_found};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions};
|
||||
@@ -15,6 +16,9 @@ use tokio::time::Duration;
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(crate) struct MigrationVersionResult {
|
||||
pub moved: bool,
|
||||
/// The copy found the source replaced by a newer unversioned object on the
|
||||
/// target, which holds no copy of the source version.
|
||||
pub superseded: bool,
|
||||
pub ignored: bool,
|
||||
pub cleanup_ignored: bool,
|
||||
pub failed: bool,
|
||||
@@ -67,9 +71,9 @@ fn rebalance_remote_tiered_opts(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_object_migration_read_opts(version_id: Option<String>) -> ObjectOptions {
|
||||
pub(super) fn rebalance_object_migration_read_opts(version: &FileInfo) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
version_id,
|
||||
version_id: Some(crate::data_movement::data_movement_exact_version_id(version.version_id)),
|
||||
no_lock: true,
|
||||
data_movement: true,
|
||||
raw_data_movement_read: true,
|
||||
@@ -99,6 +103,16 @@ pub(crate) trait MigrationBackend: Send + Sync {
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Replaces a real source read with an injected error, for the object the
|
||||
/// closure selects.
|
||||
#[cfg(test)]
|
||||
pub(super) type RebalanceSourceReadFault = std::sync::Arc<dyn Fn(&str, &str) -> Option<Error> + Send + Sync>;
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
pub(super) static REBALANCE_SOURCE_READ_FAULT: RebalanceSourceReadFault;
|
||||
}
|
||||
|
||||
pub(crate) struct RebalanceMigrationBackend<'a> {
|
||||
source: &'a SetDisks,
|
||||
store: std::sync::Arc<ECStore>,
|
||||
@@ -129,6 +143,10 @@ impl MigrationBackend for RebalanceMigrationBackend<'_> {
|
||||
h: HeaderMap,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<GetObjectReader> {
|
||||
#[cfg(test)]
|
||||
if let Ok(Some(err)) = REBALANCE_SOURCE_READ_FAULT.try_with(|fault| fault(bucket, object)) {
|
||||
return Err(err);
|
||||
}
|
||||
self.source.get_object_reader(bucket, object, range, h, opts).await
|
||||
}
|
||||
|
||||
@@ -163,7 +181,7 @@ pub(crate) async fn migrate_entry_version<Backend, F, Fut, D, DFut>(
|
||||
where
|
||||
Backend: MigrationBackend + ?Sized,
|
||||
F: FnMut(usize, String, GetObjectReader) -> Fut + Send,
|
||||
Fut: Future<Output = Result<()>> + Send,
|
||||
Fut: Future<Output = Result<DataMovementCopy>> + Send,
|
||||
D: FnMut(String, String, ObjectOptions) -> DFut + Send,
|
||||
DFut: Future<Output = Result<ObjectInfo>> + Send,
|
||||
{
|
||||
@@ -200,7 +218,7 @@ pub(super) async fn migrate_entry_version_with_retry_wait<Backend, F, Fut, D, DF
|
||||
where
|
||||
Backend: MigrationBackend + ?Sized,
|
||||
F: FnMut(usize, String, GetObjectReader) -> Fut + Send,
|
||||
Fut: Future<Output = Result<()>> + Send,
|
||||
Fut: Future<Output = Result<DataMovementCopy>> + Send,
|
||||
D: FnMut(String, String, ObjectOptions) -> DFut + Send,
|
||||
DFut: Future<Output = Result<ObjectInfo>> + Send,
|
||||
W: FnMut(Duration) -> WFut + Send,
|
||||
@@ -239,7 +257,7 @@ async fn migrate_entry_version_with_retry_wait_and_incarnation<Backend, F, Fut,
|
||||
where
|
||||
Backend: MigrationBackend + ?Sized,
|
||||
F: FnMut(usize, String, GetObjectReader) -> Fut + Send,
|
||||
Fut: Future<Output = Result<()>> + Send,
|
||||
Fut: Future<Output = Result<DataMovementCopy>> + Send,
|
||||
D: FnMut(String, String, ObjectOptions) -> DFut + Send,
|
||||
DFut: Future<Output = Result<ObjectInfo>> + Send,
|
||||
W: FnMut(Duration) -> WFut + Send,
|
||||
@@ -250,6 +268,7 @@ where
|
||||
if ignore_data_usage_cache && bucket == crate::disk::RUSTFS_META_BUCKET && version.name.contains(DATA_USAGE_CACHE_NAME) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: true,
|
||||
cleanup_ignored: false,
|
||||
failed: false,
|
||||
@@ -258,7 +277,10 @@ where
|
||||
};
|
||||
}
|
||||
|
||||
if version.is_remote() {
|
||||
// A tier free version carries no transition status and reads as a delete
|
||||
// marker, but it owns the remote object of a removed transitioned version:
|
||||
// move it as tier metadata, never as a delete marker that drops the owner.
|
||||
if version.is_remote() || version.tier_free_version() {
|
||||
if let Err(err) = set
|
||||
.move_remote_version_for_migration(
|
||||
&bucket,
|
||||
@@ -271,6 +293,7 @@ where
|
||||
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: true,
|
||||
cleanup_ignored: true,
|
||||
failed: false,
|
||||
@@ -281,6 +304,7 @@ where
|
||||
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: true,
|
||||
@@ -291,6 +315,7 @@ where
|
||||
|
||||
return MigrationVersionResult {
|
||||
moved: true,
|
||||
superseded: false,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: false,
|
||||
@@ -314,6 +339,7 @@ where
|
||||
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: true,
|
||||
cleanup_ignored: true,
|
||||
failed: false,
|
||||
@@ -324,6 +350,7 @@ where
|
||||
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: true,
|
||||
@@ -334,6 +361,7 @@ where
|
||||
|
||||
return MigrationVersionResult {
|
||||
moved: true,
|
||||
superseded: false,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: false,
|
||||
@@ -350,7 +378,7 @@ where
|
||||
&encode_dir_object(&version.name),
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&rebalance_object_migration_read_opts(version_id.clone()),
|
||||
&rebalance_object_migration_read_opts(version),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -359,6 +387,7 @@ where
|
||||
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: true,
|
||||
cleanup_ignored: true,
|
||||
failed: false,
|
||||
@@ -374,6 +403,7 @@ where
|
||||
if attempt + 1 >= max_attempts || !is_transient_rebalance_error(err) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: true,
|
||||
@@ -387,39 +417,45 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = transfer(pool_index, bucket.clone(), rd).await {
|
||||
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
ignored: true,
|
||||
cleanup_ignored: true,
|
||||
failed: false,
|
||||
stage: Some("write_target"),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
let copy = match transfer(pool_index, bucket.clone(), rd).await {
|
||||
Ok(copy) => copy,
|
||||
Err(err) => {
|
||||
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: true,
|
||||
cleanup_ignored: true,
|
||||
failed: false,
|
||||
stage: Some("write_target"),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
|
||||
last_error = Some(err);
|
||||
let Some(err) = last_error.as_ref() else {
|
||||
last_error = Some(err);
|
||||
let Some(err) = last_error.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if attempt + 1 >= max_attempts || !is_transient_rebalance_error(err) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: true,
|
||||
stage: Some("write_target"),
|
||||
error: last_error,
|
||||
};
|
||||
}
|
||||
|
||||
wait_retry(rebalance_migration_retry_delay(attempt, err)).await;
|
||||
continue;
|
||||
};
|
||||
if attempt + 1 >= max_attempts || !is_transient_rebalance_error(err) {
|
||||
return MigrationVersionResult {
|
||||
moved: false,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: true,
|
||||
stage: Some("write_target"),
|
||||
error: last_error,
|
||||
};
|
||||
}
|
||||
|
||||
wait_retry(rebalance_migration_retry_delay(attempt, err)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
return MigrationVersionResult {
|
||||
moved: true,
|
||||
superseded: copy == DataMovementCopy::Superseded,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: false,
|
||||
@@ -430,6 +466,7 @@ where
|
||||
|
||||
MigrationVersionResult {
|
||||
moved: false,
|
||||
superseded: false,
|
||||
ignored: false,
|
||||
cleanup_ignored: false,
|
||||
failed: true,
|
||||
|
||||
@@ -40,6 +40,8 @@ pub(crate) const REBALANCE_SOURCE_CLEANUP_DEFERRED_ERROR_PREFIX: &str = "deferre
|
||||
const REBALANCE_CLEANUP_WARNING_ENTRY_LIMIT: usize = 10;
|
||||
|
||||
mod control;
|
||||
#[cfg(test)]
|
||||
pub(crate) use control::{REBALANCE_REFRESH_ACTIVATION_GATE, REBALANCE_REFRESH_MOVEMENT_GATE, REBALANCE_REFRESH_START_GATE};
|
||||
mod entry;
|
||||
mod meta;
|
||||
mod migration;
|
||||
@@ -64,7 +66,8 @@ pub async fn test_store_with_persisted_rebalance_meta(
|
||||
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;
|
||||
let pools = vec![pool.clone()];
|
||||
let pool_meta = crate::core::pools::PoolMeta::new(&pools, &crate::core::pools::PoolMeta::default());
|
||||
let pool_meta = crate::core::pools::PoolMeta::new(&pools, &crate::core::pools::PoolMeta::default())
|
||||
.expect("new pool topology should produce valid pool metadata");
|
||||
pool_meta
|
||||
.save_for_startup(pools.clone())
|
||||
.await
|
||||
@@ -100,6 +103,17 @@ pub(crate) async fn test_two_pool_stores(
|
||||
test_pool_stores_with_contexts(rebalance_meta, false, 2, 2).await
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn test_three_pool_stores(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
) -> (
|
||||
Vec<tempfile::TempDir>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
) {
|
||||
test_pool_stores_with_contexts(rebalance_meta, false, 3, 2).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn test_two_pool_stores_with_isolated_node_contexts(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
@@ -133,6 +147,19 @@ pub(crate) async fn test_three_pool_stores_with_isolated_node_contexts(
|
||||
test_pool_stores_with_contexts(rebalance_meta, true, 3, 2).await
|
||||
}
|
||||
|
||||
/// Four drives per set, so a set can hold metadata on fewer than its read
|
||||
/// quorum of drives while each drive stays online.
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn test_three_pool_stores_with_four_disk_sets_with_isolated_node_contexts(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
) -> (
|
||||
Vec<tempfile::TempDir>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
std::sync::Arc<crate::store::ECStore>,
|
||||
) {
|
||||
test_pool_stores_with_contexts(rebalance_meta, true, 3, 4).await
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(
|
||||
rebalance_meta: Option<RebalanceMeta>,
|
||||
@@ -186,7 +213,8 @@ async fn test_pool_stores_with_contexts(
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
||||
let mut pool_meta =
|
||||
PoolMeta::new(&pools, &PoolMeta::default()).expect("new pool topology should produce valid pool metadata");
|
||||
pool_meta.version = POOL_META_VERSION;
|
||||
pool_meta
|
||||
.save_for_startup(pools.clone())
|
||||
|
||||
@@ -59,7 +59,7 @@ use super::{
|
||||
};
|
||||
use crate::bucket::replication::{ReplicationState, ReplicationStatusType, replication_state_to_filemeta};
|
||||
use crate::data_movement;
|
||||
use crate::data_movement::SourceCleanupError;
|
||||
use crate::data_movement::{DataMovementCopy, SourceCleanupError};
|
||||
use crate::data_usage::DATA_USAGE_CACHE_NAME;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::disk::error::DiskError;
|
||||
@@ -286,7 +286,7 @@ async fn test_migrate_entry_version_remote_version_is_moved_without_transfer() {
|
||||
let transfer_count = transfer_count.clone();
|
||||
async move {
|
||||
transfer_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -332,7 +332,7 @@ async fn test_migrate_entry_version_remote_not_found_is_cleanup_ignored() {
|
||||
let transfer_count = transfer_count.clone();
|
||||
async move {
|
||||
transfer_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -361,6 +361,51 @@ async fn test_migrate_entry_version_remote_not_found_is_cleanup_ignored() {
|
||||
assert_eq!(backend.get_calls(), 0);
|
||||
}
|
||||
|
||||
/// A tier free version reads as a delete marker with no transition status; it
|
||||
/// must still move as tier metadata so its remote owner reaches the target.
|
||||
#[tokio::test]
|
||||
async fn test_migrate_entry_version_moves_tier_free_version_as_remote_metadata() {
|
||||
let backend = MigrationBackendSpy::new(None, None);
|
||||
let mut version = FileInfo {
|
||||
name: "object.bin".to_string(),
|
||||
version_id: Some(uuid::Uuid::new_v4()),
|
||||
deleted: true,
|
||||
transition_tier: "WARM".to_string(),
|
||||
transitioned_objname: "remote/object.bin".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
version.set_tier_free_version();
|
||||
assert!(!version.is_remote());
|
||||
let delete_marker_calls = Arc::new(AtomicUsize::new(0));
|
||||
let delete_marker = {
|
||||
let delete_marker_calls = delete_marker_calls.clone();
|
||||
move |_: String, _: String, _: ObjectOptions| {
|
||||
delete_marker_calls.fetch_add(1, Ordering::SeqCst);
|
||||
async move { Ok::<_, Error>(ObjectInfo::default()) }
|
||||
}
|
||||
};
|
||||
|
||||
let result = migrate_entry_version(
|
||||
&backend,
|
||||
"bucket".to_string(),
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
Some(uuid::Uuid::new_v4()),
|
||||
3,
|
||||
false,
|
||||
|_, _, _| async move { Ok(DataMovementCopy::Committed) },
|
||||
delete_marker,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(!result.failed);
|
||||
assert_eq!(backend.move_remote_calls(), 1);
|
||||
assert_eq!(delete_marker_calls.load(Ordering::SeqCst), 0, "a free version is no delete marker");
|
||||
assert_eq!(backend.get_calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrate_entry_version_remote_overwrite_is_not_ignored() {
|
||||
let backend = MigrationBackendSpy::new(
|
||||
@@ -372,7 +417,7 @@ async fn test_migrate_entry_version_remote_overwrite_is_not_ignored() {
|
||||
))),
|
||||
);
|
||||
let version = version_remote();
|
||||
let mut transfer = |_, _, _| async move { Ok(()) };
|
||||
let mut transfer = |_, _, _| async move { Ok(DataMovementCopy::Committed) };
|
||||
|
||||
let result = migrate_entry_version(
|
||||
&backend,
|
||||
@@ -408,7 +453,7 @@ async fn test_migrate_entry_version_remote_failure_is_reported() {
|
||||
let transfer_count = transfer_count.clone();
|
||||
async move {
|
||||
transfer_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -441,7 +486,7 @@ async fn test_migrate_entry_version_remote_failure_is_reported() {
|
||||
async fn test_migrate_entry_version_deleted_version_routes_delete_through_store_and_moved() {
|
||||
let backend = MigrationBackendSpy::new(None, None);
|
||||
let version = version_deleted();
|
||||
let mut transfer = |_, _, _| async move { Ok(()) };
|
||||
let mut transfer = |_, _, _| async move { Ok(DataMovementCopy::Committed) };
|
||||
// The delete marker must be routed through the store closure (cross-pool routing), never
|
||||
// through the source SetDisks. Assert the closure is invoked and the source set is not.
|
||||
let delete_calls = Arc::new(AtomicUsize::new(0));
|
||||
@@ -483,7 +528,7 @@ async fn test_migrate_entry_version_deleted_version_routes_delete_through_store_
|
||||
async fn test_migrate_entry_version_deleted_version_not_found_is_ignored() {
|
||||
let backend = MigrationBackendSpy::new(None, None);
|
||||
let version = version_deleted();
|
||||
let mut transfer = |_, _, _| async move { Ok(()) };
|
||||
let mut transfer = |_, _, _| async move { Ok(DataMovementCopy::Committed) };
|
||||
let delete_calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut delete_marker = {
|
||||
let delete_calls = delete_calls.clone();
|
||||
@@ -522,7 +567,7 @@ async fn test_migrate_entry_version_deleted_version_not_found_is_ignored() {
|
||||
async fn test_migrate_entry_version_deleted_version_overwrite_is_not_ignored() {
|
||||
let backend = MigrationBackendSpy::new(None, None);
|
||||
let version = version_deleted();
|
||||
let mut transfer = |_, _, _| async move { Ok(()) };
|
||||
let mut transfer = |_, _, _| async move { Ok(DataMovementCopy::Committed) };
|
||||
let delete_calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut delete_marker = {
|
||||
let delete_calls = delete_calls.clone();
|
||||
@@ -567,7 +612,7 @@ async fn test_migrate_entry_version_reader_not_found_is_ignored() {
|
||||
let backend =
|
||||
MigrationBackendSpy::new(Some(Err(Error::ObjectNotFound("bucket".to_string(), "object.bin".to_string()))), None);
|
||||
let version = version_normal();
|
||||
let mut transfer = |_, _, _| async move { Ok(()) };
|
||||
let mut transfer = |_, _, _| async move { Ok(DataMovementCopy::Committed) };
|
||||
|
||||
let result = migrate_entry_version(
|
||||
&backend,
|
||||
@@ -602,7 +647,7 @@ async fn test_migrate_entry_version_reader_retries_before_success() {
|
||||
let transfer_count = transfer_count.clone();
|
||||
async move {
|
||||
transfer_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -691,7 +736,7 @@ async fn test_migrate_entry_version_reader_fails_after_retries() {
|
||||
let transfer_count = transfer_count.clone();
|
||||
async move {
|
||||
transfer_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -730,7 +775,7 @@ async fn test_migrate_entry_version_zero_max_attempts_still_attempts_once() {
|
||||
let transfer_count = transfer_count.clone();
|
||||
async move {
|
||||
transfer_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -773,7 +818,7 @@ async fn test_migrate_entry_version_transfer_retries_before_success() {
|
||||
if attempt == 0 {
|
||||
return Err(Error::SlowDown);
|
||||
}
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -809,7 +854,9 @@ async fn test_migrate_entry_version_transfer_retries_before_success() {
|
||||
assert_eq!(transfer_count.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(wait_count.load(Ordering::SeqCst), 1);
|
||||
let read_opts = backend.last_get_opts();
|
||||
assert_eq!(read_opts.version_id.as_deref(), version.version_id.map(|id| id.to_string()).as_deref());
|
||||
// `version_normal` is a null version; `None` would read the latest version.
|
||||
assert!(version.version_id.is_none());
|
||||
assert_eq!(read_opts.version_id, Some(uuid::Uuid::nil().to_string()));
|
||||
assert!(read_opts.no_lock);
|
||||
assert!(read_opts.data_movement);
|
||||
assert!(read_opts.raw_data_movement_read);
|
||||
@@ -1000,7 +1047,7 @@ async fn test_migrate_entry_version_ignores_data_usage_cache_when_enabled() {
|
||||
let transfer_count = transfer_count.clone();
|
||||
async move {
|
||||
transfer_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1043,7 +1090,7 @@ async fn test_migrate_entry_version_data_usage_cache_moves_when_ignore_disabled(
|
||||
let transfer_count = transfer_count.clone();
|
||||
async move {
|
||||
transfer_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
Ok(DataMovementCopy::Committed)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2184,7 +2231,7 @@ async fn test_rebalance_stage_wrapped_lock_timeout_retries_real_migration_loop()
|
||||
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if succeeds_on_retry && attempt > 0 {
|
||||
return Ok(());
|
||||
return Ok(DataMovementCopy::Committed);
|
||||
}
|
||||
Err(data_movement::data_movement_stage_error_for_test(
|
||||
"rebalance_object",
|
||||
@@ -2453,7 +2500,7 @@ async fn test_migrate_entry_version_transfer_failure_reports_write_target_stage(
|
||||
#[tokio::test]
|
||||
async fn test_migrate_entry_version_reader_failure_reports_read_source_stage() {
|
||||
let backend = AlwaysFailGetBackend::new();
|
||||
let mut transfer = |_, _, _| async { Ok(()) };
|
||||
let mut transfer = |_, _, _| async { Ok(DataMovementCopy::Committed) };
|
||||
let version = version_normal();
|
||||
|
||||
let result = migrate_entry_version(
|
||||
|
||||
@@ -641,7 +641,13 @@ impl SetDisks {
|
||||
state = "agreed_entry",
|
||||
"Rebalance listing agreed entry"
|
||||
);
|
||||
Box::pin(cb1(entry))
|
||||
{
|
||||
let future = cb1(entry);
|
||||
Box::pin(async move {
|
||||
future.await;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
})),
|
||||
partial: Some(Box::new(move |entries: MetaCacheEntries, _: &[Option<DiskError>]| {
|
||||
let resolver = resolver.clone();
|
||||
|
||||
@@ -3399,12 +3399,15 @@ impl TierConfigMgr {
|
||||
|
||||
pub async fn remove(&mut self, tier_name: &str, force: bool) -> std::result::Result<(), AdminError> {
|
||||
self.ensure_generation_is_idle(tier_name)?;
|
||||
let driver = match self.get_driver(tier_name).await {
|
||||
Ok(driver) => driver,
|
||||
Err(err) if err.code == ERR_TIER_NOT_FOUND.code => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if force && !self.tiers.contains_key(tier_name) {
|
||||
return Ok(());
|
||||
}
|
||||
if !force {
|
||||
let driver = match self.get_driver(tier_name).await {
|
||||
Ok(driver) => driver,
|
||||
Err(err) if err.code == ERR_TIER_NOT_FOUND.code => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
match driver.in_use().await {
|
||||
Err(err) => {
|
||||
let mut e = ERR_TIER_PERM_ERR.clone();
|
||||
@@ -8819,8 +8822,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_remove_unknown_tier_is_idempotent() {
|
||||
let mut mgr = empty_mgr();
|
||||
// Unknown tier: get_driver returns NotFound, which `remove` swallows.
|
||||
mgr.remove("NOPE", false).await.expect("removing an unknown tier is a no-op");
|
||||
for force in [false, true] {
|
||||
mgr.remove("NOPE", force).await.expect("removing an unknown tier is a no-op");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -8885,6 +8889,68 @@ mod tests {
|
||||
assert!(!mgr.driver_cache.contains_key("COLD-A"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_force_skips_uncached_driver_construction() {
|
||||
let mut mgr = empty_mgr();
|
||||
let mut tier = build_s3_tier("COLD-A");
|
||||
tier.s3.as_mut().expect("S3 fixture must have a backend").endpoint = "https://169.254.169.254".to_string();
|
||||
mgr.tiers.insert("COLD-A".to_string(), tier);
|
||||
assert!(mgr.driver_cache.is_empty());
|
||||
|
||||
let err = mgr
|
||||
.remove("COLD-A", false)
|
||||
.await
|
||||
.expect_err("non-force removal must retain endpoint validation");
|
||||
assert_eq!(err.code, ERR_TIER_INVALID_CONFIG.code);
|
||||
assert!(err.message.contains("tier endpoint is not allowed"), "{}", err.message);
|
||||
assert!(mgr.tiers.contains_key("COLD-A"));
|
||||
assert!(mgr.driver_cache.is_empty());
|
||||
|
||||
mgr.remove("COLD-A", true)
|
||||
.await
|
||||
.expect("force removal must not construct a driver for the forbidden endpoint");
|
||||
assert!(!mgr.tiers.contains_key("COLD-A"));
|
||||
assert!(mgr.driver_cache.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persisted_private_tiers_rebuild_uncached_drivers_without_rebinding() {
|
||||
let endpoint = "https://192.168.10.200:9000";
|
||||
for provider in ["s3", "minio", "rustfs"] {
|
||||
let tier: TierConfig = serde_json::from_value(serde_json::json!({
|
||||
"type": provider,
|
||||
"name": "COLD-A",
|
||||
(provider): {
|
||||
"name": "COLD-A",
|
||||
"endpoint": endpoint,
|
||||
"accessKey": "access",
|
||||
"secretKey": "secret",
|
||||
"bucket": "tier-bucket",
|
||||
"prefix": "archive",
|
||||
"region": "us-east-1"
|
||||
}
|
||||
}))
|
||||
.expect("private tier fixture must decode");
|
||||
let identity = tier_backend_identity(&tier).expect("private tier identity must encode");
|
||||
let mut persisted = empty_mgr();
|
||||
persisted.tiers.insert("COLD-A".to_string(), tier);
|
||||
let blob = encode_external_tiering_config_blob(&persisted).expect("private tier config must persist");
|
||||
let mut loaded = decode_external_tiering_config_blob(&blob).expect("private tier config must reload");
|
||||
assert!(loaded.driver_cache.is_empty(), "{provider} reload must start with a cold cache");
|
||||
|
||||
loaded
|
||||
.get_driver("COLD-A")
|
||||
.await
|
||||
.expect("a persisted private tier must construct its driver without editing its endpoint");
|
||||
assert!(loaded.driver_cache.contains_key("COLD-A"));
|
||||
let config = &loaded.tiers["COLD-A"];
|
||||
assert_eq!(config.name, "COLD-A");
|
||||
assert_eq!(tier_backend_identity(config).expect("reloaded identity must encode"), identity);
|
||||
let wire = serde_json::to_value(config).expect("reloaded tier must serialize");
|
||||
assert_eq!(wire[provider]["endpoint"], endpoint, "{provider} endpoint must remain unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persisted_remove_preserves_force_semantics() {
|
||||
let mut candidate = empty_mgr();
|
||||
@@ -16102,6 +16168,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_remove_uncached_invalid_endpoint_preserves_persisted_reference_proof() {
|
||||
for has_free_version in [false, true] {
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
let mut tier = build_rustfs_tier("COLD-A");
|
||||
tier.rustfs.as_mut().expect("RustFS fixture must have a backend").endpoint = "https://169.254.169.254".to_string();
|
||||
let identity = tier_backend_identity(&tier).expect("forbidden endpoint must retain a valid backend identity");
|
||||
let mut persisted = empty_mgr();
|
||||
persisted.tiers.insert("COLD-A".to_string(), tier.clone_with_credentials());
|
||||
persisted
|
||||
.save_tiering_config_if_current(store.clone(), None)
|
||||
.await
|
||||
.expect("cold-cache remove fixture must persist");
|
||||
let (_, original_version) = load_tier_config_for_update(store.clone())
|
||||
.await
|
||||
.expect("original config version must load");
|
||||
if has_free_version {
|
||||
let mut object = transitioned_tier_object("photos", "free-version.jpg", "COLD-A", Some(identity));
|
||||
object.transitioned_object.status = "pending".to_string();
|
||||
object.transitioned_object.free_version = true;
|
||||
store.add_listed_version(object);
|
||||
}
|
||||
let manager = TierConfigMgr::new();
|
||||
manager.write().await.tiers.insert("COLD-A".to_string(), tier);
|
||||
assert!(manager.read().await.driver_cache.is_empty());
|
||||
|
||||
let result = TierConfigMgr::remove_and_save_with(&manager, store.clone(), "COLD-A", true).await;
|
||||
if has_free_version {
|
||||
match result.expect_err("a free-version reference must block force removal without constructing a driver") {
|
||||
TierConfigUpdateError::Publish(err) => {
|
||||
assert_eq!(err.code, ERR_TIER_BACKEND_IN_USE.code);
|
||||
assert!(err.message.contains("photos/free-version.jpg"), "{}", err.message);
|
||||
}
|
||||
other => panic!("reference proof must surface as a publish error: {other:?}"),
|
||||
}
|
||||
} else {
|
||||
result.expect("force removal without references must persist despite a forbidden endpoint");
|
||||
}
|
||||
|
||||
{
|
||||
let live = manager.read().await;
|
||||
assert_eq!(live.tiers.contains_key("COLD-A"), has_free_version);
|
||||
assert!(live.driver_cache.is_empty());
|
||||
}
|
||||
let (loaded, current_version) = load_tier_config_for_update(store)
|
||||
.await
|
||||
.expect("post-removal config must reload");
|
||||
assert_eq!(loaded.tiers.contains_key("COLD-A"), has_free_version);
|
||||
if has_free_version {
|
||||
assert_eq!(current_version, original_version, "blocked removal must not save the candidate");
|
||||
assert_eq!(
|
||||
tier_backend_identity(&loaded.tiers["COLD-A"]).expect("preserved tier identity must encode"),
|
||||
identity
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_reference_proof_blocks_remove_before_config_save() {
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
@@ -17708,7 +17832,10 @@ mod tests {
|
||||
TierConfigMgr::update_candidate_with_config_lock(
|
||||
&manager,
|
||||
store,
|
||||
TierCandidateMutation::Remove("COLD-A".to_string(), true),
|
||||
// Non-force removal still validates the remote driver before
|
||||
// entering reference proof; force removal intentionally skips
|
||||
// that construction so an invalid endpoint can be removed.
|
||||
TierCandidateMutation::Remove("COLD-A".to_string(), false),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user