Compare commits

...

38 Commits

Author SHA1 Message Date
Zhengchao An 830055ba9c Merge branch 'main' into codex/backlog-1548-protocols-workspace-lints 2026-07-29 22:35:01 +08:00
cxymds 55be5af661 fix(ecstore): fence bucket metadata generations (#5427) 2026-07-29 22:34:11 +08:00
Henry Guo 3f20fbd77b fix(scanner): report active first-cycle status (#5397)
* fix(scanner): report active first-cycle status

* fix(scanner): publish cycle activity consistently

* test(common): satisfy Rust 1.97 waker lint

* fix(scanner): satisfy Rust 1.97 clippy

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 22:33:25 +08:00
Zhengchao An 3c0d315a9c ci: sample runner state during the nextest step (#5440)
The Test and Lint lane intermittently stalls until the inner 75m timeout
kills the cargo process group (issue #5394). The evidence collected since
#5402 shows the stall can begin in the build phase before any test runs
(run 30449339653: last output at minute 4 of the nextest step, then 71
silent minutes until SIGTERM), but the post-mortem pgrep always reports
nothing because GNU timeout has already terminated the whole process
group by the time it runs.

Add a background sampler to the nextest step that appends system and
process snapshots (loadavg, PSI, memory, disk, top-RSS processes,
cargo/rustc/linker/build-script processes, D-state processes) to the
existing test-and-lint artifact every 60 seconds, and record kernel
OOM/kill events in the post-mortem diagnostics. The last samples before
a timeout identify the wedged process or the resource pressure that
caused the stall. This only instruments the failure mode where the
runner survives; jobs whose runner disappears entirely still upload no
artifacts and need runner-pool-side logs.

Refs #5394
2026-07-29 22:32:16 +08:00
cxymds 83f21eaa64 test(entrypoint): preserve cargo toolchain environment (#5385)
Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 22:27:20 +08:00
houseme ec25d09495 perf(utils): reduce highway hash key setup (#5434)
* fix: keep hotpath gate csv paths clean

Redirect the enhanced bench driver's verbose output away from command substitution so run_hotpath_warp_ab.sh records only the candidate cell path before passing baseline_compare.csv files to the gate.

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

* perf(utils): reduce highway hash key setup

Cache the parsed HighwayHash keys as compile-time constants and add a criterion benchmark for the PUT hash hotpath.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-29 14:23:40 +00:00
cxymds cc24ef173c fix(ecstore): defer delete cleanup for snapshot reads (#5408)
* feat(ecstore): add local snapshot leases

* feat(ecstore): add remote snapshot lease RPCs

* feat(ecstore): protect streaming GETs with snapshot leases

* fix(ecstore): defer version cleanup for snapshot reads

* fix(ecstore): cover batch snapshot cleanup safely

* fix(e2e): stub snapshot lease RPCs in lock mock

* fix(e2e): stub snapshot lease RPCs in lock mock

* fix(ecstore): bind deferred delete cleanup intents

* fix(rpc): keep snapshot lease checks CI-compatible
2026-07-29 22:23:01 +08:00
Zhengchao An c195b18fb8 ci(keystone): inherit workspace lint policy (#5429) 2026-07-29 22:21:01 +08:00
cxymds d670023341 fix(tiering): use exact GCS generations (#5376)
* feat(tiering): model provider version capabilities

* feat(tiering): persist opaque remote versions

* fix(tiering): use exact GCS generations

* fix(tiering): gate remote version state safely

* fix(tiering): gate remote version state writes

* fix(tiering): preserve remote version state on delete

* fix(tiering): accept unversioned transition responses

* fix(tiering): replay exact cleanup journals

* test(tiering): pin empty exact cleanup guard

* test(tiering): accept strict missing journal errors

* test(tiering): exercise free-version identity guard

* test(tiering): reach destination identity guard

* test(tiering): persist version identity drift

* test(tiering): bind version drift fixture
2026-07-29 14:20:50 +00:00
overtrue a8c2e3e253 ci(protocols): inherit workspace lint policy 2026-07-29 21:45:10 +08:00
cxymds 91597db9d2 feat(ecstore): protect streaming GETs with snapshot leases (#5391)
* feat(ecstore): add local snapshot leases

* feat(ecstore): add remote snapshot lease RPCs

* feat(ecstore): protect streaming GETs with snapshot leases

* fix(e2e): stub snapshot lease RPCs in lock mock

* fix(rpc): keep snapshot lease checks CI-compatible

* fix(ecstore): retain GET lock for missing lease disk

* chore(proto): preserve node service formatting

* fix(ecstore): harden snapshot lease deadlines

* fix(ecstore): bound late lease cleanup
2026-07-29 21:24:31 +08:00
cxymds 225918f30e test(ecstore): remove invalid transition version drift case (#5392)
* test(ecstore): remove invalid transition version drift case

* test(ecstore): rendezvous concurrent resend commits

* test(ecstore): satisfy multipart barrier lint

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 20:17:41 +08:00
houseme f7c1b13c0f refactor(deps): replace md5 crate with md-5 (#5432)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-29 11:29:37 +00:00
唐小鸭 f329d330df feat(kms): support safe local KMS evaluation workflows (#5418)
* feat(kms): enable safe local KMS evaluation workflow

* test(kms): align SSE reconfigure coverage

---------

Co-authored-by: cxymds <cxymds@gmail.com>
2026-07-29 10:19:52 +00:00
Zhengchao An e154e0e4a2 chore(heal): enforce workspace lint policy (#5426) 2026-07-29 10:00:51 +00:00
Zhengchao An f235e81755 fix(data-usage): make empty checks exhaustive (#5424) 2026-07-29 09:35:18 +00:00
Zhengchao An d42bc52f8b test(heal): avoid live disk removal race (#5421) 2026-07-29 17:29:18 +08:00
Zhengchao An d48870df97 fix(rpc): authenticate non-disk mutation bodies (#5425) 2026-07-29 09:17:30 +00:00
Zhengchao An 453e3d0faa ci(concurrency): inherit workspace lint policy (#5420) 2026-07-29 16:40:56 +08:00
cxymds b65210b1db fix(ilm): preserve restore source version mode (#5406)
* fix(ilm): preserve restore source version mode

* test(ilm): cover suspended null-version restore

* fix(ilm): reconcile worker results from journal
2026-07-29 16:16:40 +08:00
Zhengchao An a7f035a8c3 fix(ecstore): fail peer metadata reloads closed instead of caching fabricated defaults (#5396)
The LoadBucketMetadata peer-notification handler loaded bucket metadata
with the fabricating loader (ConfigNotFound -> BucketMetadata::new) and
unconditionally cached the result. On a transient read-quorum dip during
a reload notification, a peer cached an authoritative "no Object Lock"
default for a lock-enabled bucket, disabling the batch-delete retention
gate (object_lock_delete_check_required) on that node until the next
refresh, and wiping its bucket-target/durability sync state.

Production changes:
- New BucketMetadataSys::reload_from_store (metadata_sys::
  reload_bucket_metadata): the peer reload path uses the presence-aware
  loader and installs only metadata actually read from persisted
  storage. A load miss returns an error (surfaced to the notifying peer
  as success=false) and leaves the cache untouched; deletion still
  propagates only through the dedicated DeleteBucketMetadata
  notification.
- The reload runs under the outer metadata-sys write guard, load
  included, mirroring update(): every other cache installer holds that
  lock, so a reload snapshot can never land after - and roll back - a
  newer concurrent install (the stale-load lost-update from the
  review), and the install-plus-registry-sync sequence stays atomic
  against concurrent removes and reloads (previously only the set call
  was write-guarded, with the load outside any lock).
- The peer-visible miss error is a fixed string: the notifying peer
  substring-matches error text against network-failure needles
  (is_network_like_error), so interpolating a bucket name (e.g. a legal
  bucket literally named "unavailable") could mark a healthy peer
  offline.
- get_config's lazy insert routes through set(), picking up the
  negative-cache invalidation.

An earlier draft instead guarded set() with a per-config updated_at
freshness comparison. Adversarial validation rejected it (three roles
independently): update_config stamps with the handling node's wall
clock, so within the skew the cluster already tolerates (+/-300s RPC
auth window) a config rewritten with an earlier stamp - e.g. revoking a
public-read policy through a second node, or any same-field rewrite
after an NTP step-back - would be skipped by every peer forever,
silently pinning the revoked permissive config with no re-convergence
path (the 15-minute refresh also routed through the guard). Race
staleness is second-scale while skew is minute-scale, so no tolerance
bound can separate them; the write-guard serialization closes the same
race without clocks and preserves the refresh loop's unconditional
converge-to-disk property, which is the cluster's self-healing
mechanism.

Startup audit (BucketMetadataSys::init): concurrent_load's
insert-if-vacant still installs a fabricated default when a transient
miss hits at boot - indistinguishable from a legacy bucket without a
metadata file at this layer - bounded by the next successful persisted
load. Making the object-lock gate fail closed on such entries is filed
as a follow-up, alongside the bare "unavailable" needle in
is_network_like_error and the Swift cache-only metadata writes.

Tests:
- bucket::metadata_sys::tests::
  peer_reload_never_caches_fabricated_defaults_as_authoritative:
  miss installs nothing / miss keeps the existing entry intact
  (asserting the dedicated non-persisted error) / persisted reload
  converges the cache over a stale entry.
- node_service::tests::
  test_load_bucket_metadata_failure_skips_scanner_maintenance:
  a failed reload reports failure and does not advance scanner
  maintenance activity (previously recorded even on a miss).
- The handler success path stays uncovered at the RPC layer (needs an
  isolated global object layer, like the pre-existing ignored test);
  the composition is pinned at the sys level instead.

Verification:
- cargo fmt --check and cargo clippy --lib --tests clean on
  rustfs-ecstore and rustfs.
- Targeted suites green; full cargo test -p rustfs-ecstore --lib:
  3198/3200 with two parallelism-sensitive lock-test flakes from the
  known baseline (pass in isolation; a different pair flakes per run).
- Adversarial validation (high-risk tier, all seven roles as
  independent parallel reviewers) run per AGENTS.md; all findings
  fixed or rebutted with evidence, three out-of-scope findings filed
  as follow-up tasks.

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 08:13:36 +00:00
Zhengchao An 87d97a5f48 fix(data-usage): preserve nonempty replication stats (#5422) 2026-07-29 15:59:04 +08:00
cxymds d9efd6b853 feat(ecstore): add remote snapshot lease RPCs (#5389)
* feat(ecstore): add local snapshot leases

* feat(ecstore): add remote snapshot lease RPCs

* fix(rpc): keep snapshot lease checks CI-compatible
2026-07-29 15:06:18 +08:00
cxymds 2801b2500d fix(tiering): gate remote version state writes (#5382)
* feat(tiering): model provider version capabilities

* feat(tiering): persist opaque remote versions

* fix(tiering): gate remote version state safely

* fix(tiering): gate remote version state writes

* fix(tiering): preserve remote version state on delete

* fix(tiering): accept unversioned transition responses

* fix(tiering): replay exact cleanup journals

* test(tiering): pin empty exact cleanup guard

* test(tiering): accept strict missing journal errors

* test(tiering): exercise free-version identity guard

* test(tiering): reach destination identity guard

* test(tiering): persist version identity drift

* test(tiering): bind version drift fixture

* test(config): keep fleet gate tests after constants
2026-07-29 15:06:08 +08:00
cxymds 02f4dbeb68 fix(heal): fail closed on ambiguous metadata rescue (#5361)
* fix(heal): fail closed on ambiguous metadata rescue

* fix(heal): preserve uncertain dangling state

* test(heal): satisfy strict clippy checks

* fix(heal): retain explicit version metadata recovery

* fix(heal): preserve metadata rescue diagnostics

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 15:06:01 +08:00
cxymds f7757e6437 test(ecstore): rendezvous concurrent resend commits (#5411)
* test(ecstore): rendezvous concurrent resend commits

* test(ecstore): satisfy multipart barrier lint
2026-07-29 15:05:53 +08:00
cxymds 1cb1b02b08 fix(ecstore): prevent deleted bucket recreation (#5380)
* fix(ecstore): prevent deleted bucket recreation

* fix(ecstore): reject empty metadata bucket names

* fix(ecstore): avoid recursive bucket metadata lookup

* fix(ecstore): bound lazy metadata future stack use

* test(ecstore): create bucket before metadata reload

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 14:49:18 +08:00
Zhengchao An 3fe74a5019 fix(swift): merge account and container metadata POSTs (#5414) 2026-07-29 06:36:07 +00:00
Zhengchao An 451cbc099b fix(tier): keep converging peers that reject a config reload (#5412) 2026-07-29 14:24:26 +08:00
cxymds cb62079ba6 test(ilm): make active cancellation deterministic (#5403)
* ci: preserve timeout evidence for workspace tests

* test(ilm): make active cancellation deterministic

* ci: allow cold doctest compilation to finish

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 13:53:59 +08:00
Zhengchao An d39ffdb1cd fix(filemeta): canonicalize version order after insert (#5413) 2026-07-29 05:49:00 +00:00
Zhengchao An 7d698abc1f ci(checksums): inherit workspace lint policy (#5415) 2026-07-29 12:53:45 +08:00
hector a1a65ad65d fix(action): change quay.io image repository name (#5417) 2026-07-29 12:53:26 +08:00
cxymds 358af6a8de ci: preserve timeout evidence for workspace tests (#5402)
* ci: preserve timeout evidence for workspace tests

* ci: allow cold doctest compilation to finish

* ci: allow cold nextest compilation to finish

* ci: allow cold nextest compilation to finish

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 04:49:03 +00:00
Zhengchao An 90d1a15d13 fix(ecstore): classify peer RPC failures by gRPC status code (#5400) 2026-07-29 11:35:09 +08:00
cxymds 2423ba8e3f fix(tiering): base restore expiry on completion (#5366)
* fix(tiering): base restore expiry on completion

* fix(tiering): import restore metadata types

* fix(restore): resolve metadata finalization build errors

* test(ecstore): import restore expiry helper
2026-07-29 02:55:00 +00:00
cxymds 294c79c156 fix(tiering): gate remote version state safely (#5374)
* feat(tiering): model provider version capabilities

* feat(tiering): persist opaque remote versions

* fix(tiering): gate remote version state safely

* fix(tiering): preserve remote version state on delete

* fix(tiering): accept unversioned transition responses

* fix(tiering): replay exact cleanup journals

* test(tiering): pin empty exact cleanup guard

* test(tiering): accept strict missing journal errors

* test(tiering): exercise free-version identity guard

* test(tiering): reach destination identity guard

* test(tiering): persist version identity drift

* test(tiering): bind version drift fixture

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 02:43:28 +00:00
cxymds 3d80578abd feat(ecstore): add local data-dir snapshot leases (#5388)
feat(ecstore): add local snapshot leases

Co-authored-by: houseme <housemecn@gmail.com>
2026-07-29 02:42:35 +00:00
115 changed files with 10037 additions and 1578 deletions
+88 -14
View File
@@ -156,16 +156,99 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }} cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Prepare test evidence
run: |
mkdir -p artifacts/test-and-lint
{
echo "run_id=${GITHUB_RUN_ID}"
echo "job=${GITHUB_JOB}"
echo "runner=${RUNNER_NAME}"
echo "started_at=$(date --utc --iso-8601=seconds)"
} > artifacts/test-and-lint/run-metadata.txt
# Clippy runs before the test pass: lint failures are the most common # Clippy runs before the test pass: lint failures are the most common
# CI-only breakage and should surface in minutes, not after 20+ minutes # CI-only breakage and should surface in minutes, not after 20+ minutes
# of tests. # of tests.
- name: Run clippy lints - name: Run clippy lints
run: cargo clippy --all-targets -- -D warnings run: cargo clippy --all-targets -- -D warnings
- name: Run tests - name: Run nextest tests
run: | run: |
cargo nextest run --profile ci --all --exclude e2e_test mkdir -p artifacts/test-and-lint
cargo test --all --doc # Evidence sampler for issue #5394: the post-mortem pgrep below runs
# only after `timeout` has already TERM'd the whole cargo process
# group, so it cannot name a wedged process. Sample system and
# process state every 60s instead; the last samples before the
# timeout show what was stuck (rustc, linker, build script, memory
# pressure, ...). The log rides along in the existing artifact.
(
while true; do
{
echo "=== $(date --utc --iso-8601=seconds)"
echo "--- load"; cat /proc/loadavg
echo "--- psi"; grep -H . /proc/pressure/* 2>/dev/null || true
echo "--- mem"; free -m
echo "--- disk"; df -h / /home/runner 2>/dev/null || df -h /
echo "--- top-rss"
ps -eo pid,ppid,stat,etime,rss,pcpu,args --sort=-rss | head -15
echo "--- build/test processes"
ps -eo pid,ppid,stat,etime,rss,pcpu,args | grep -E '[c]argo|[r]ustc|[n]extest|[c]ollect2|rust-ll[d]|[b]uild-script|deps[/]' || true
echo "--- d-state (uninterruptible IO)"
ps -eo pid,stat,etime,args | awk 'NR > 1 && $2 ~ /D/' || true
echo
} >> artifacts/test-and-lint/sampler.log 2>&1 || true
sleep 60
done
) &
sampler_pid=$!
trap 'kill "${sampler_pid}" 2>/dev/null || true' EXIT
set +e
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 75m \
cargo nextest run --profile ci --all --exclude e2e_test \
--status-level all --final-status-level all \
2>&1 | tee artifacts/test-and-lint/nextest.log
status=${PIPESTATUS[0]}
{
echo "command=cargo nextest run --profile ci --all --exclude e2e_test"
echo "exit_status=${status}"
echo "finished_at=$(date --utc --iso-8601=seconds)"
echo
echo "Remaining test-related processes:"
pgrep -af 'cargo|nextest|target/.*/deps/' || true
echo
echo "Kernel OOM / kill events:"
dmesg -T 2>/dev/null | grep -iE 'oom|out of memory|killed process' | tail -20 || true
} > artifacts/test-and-lint/nextest-diagnostics.txt
exit "${status}"
- name: Run documentation tests
run: |
mkdir -p artifacts/test-and-lint
set +e
timeout --verbose --signal=TERM --kill-after=30s 15m \
cargo test --all --doc \
2>&1 | tee artifacts/test-and-lint/doctest.log
status=${PIPESTATUS[0]}
{
echo "command=cargo test --all --doc"
echo "exit_status=${status}"
echo "finished_at=$(date --utc --iso-8601=seconds)"
echo
echo "Remaining test-related processes:"
pgrep -af 'cargo|rustdoc|target/.*/deps/' || true
} > artifacts/test-and-lint/doctest-diagnostics.txt
exit "${status}"
- name: Upload test reports and diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: junit-test-and-lint-${{ github.run_number }}
path: |
target/nextest/ci/junit.xml
artifacts/test-and-lint
retention-days: 3
if-no-files-found: error
# rustfs/backlog#1289: fail if a seed rule's log anchor no longer exists # rustfs/backlog#1289: fail if a seed rule's log anchor no longer exists
# verbatim in the source tree (log message drifted without updating the # verbatim in the source tree (log message drifted without updating the
@@ -174,15 +257,6 @@ jobs:
- name: Check log-analyzer rule anchors - name: Check log-analyzer rule anchors
run: ./scripts/check_log_analyzer_rules.sh run: ./scripts/check_log_analyzer_rules.sh
- name: Upload test junit report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: junit-test-and-lint-${{ github.run_number }}
path: target/nextest/ci/junit.xml
retention-days: 3
if-no-files-found: ignore
# Explicit gate for migration-critical suites. These tests already ran in # Explicit gate for migration-critical suites. These tests already ran in
# the full nextest pass above; a single filtered nextest invocation keeps # the full nextest pass above; a single filtered nextest invocation keeps
# the named gate without rebuilding or re-running them one package at a time. # the named gate without rebuilding or re-running them one package at a time.
@@ -328,7 +402,7 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build debug binary - name: Build debug binary
run: cargo build -p rustfs --bins run: cargo build -p rustfs --bins --features e2e-test-hooks
- name: Upload debug binary - name: Upload debug binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -358,7 +432,7 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build debug binary with rio-v2 - name: Build debug binary with rio-v2
run: cargo build -p rustfs --bins --features rio-v2 run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
- name: Upload debug binary - name: Upload debug binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+1 -1
View File
@@ -66,7 +66,7 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
REGISTRY_DOCKERHUB: rustfs/rustfs REGISTRY_DOCKERHUB: rustfs/rustfs
REGISTRY_GHCR: ghcr.io/${{ github.repository }} REGISTRY_GHCR: ghcr.io/${{ github.repository }}
REGISTRY_QUAY: quay.io/${{ secrets.QUAY_USERNAME }}/rustfs REGISTRY_QUAY: quay.io/rustfs/rustfs
DOCKER_PLATFORMS: linux/amd64,linux/arm64 DOCKER_PLATFORMS: linux/amd64,linux/arm64
jobs: jobs:
Generated
+13 -8
View File
@@ -3671,12 +3671,13 @@ dependencies = [
"flatbuffers", "flatbuffers",
"flate2", "flate2",
"futures", "futures",
"hex",
"http 1.4.2", "http 1.4.2",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"local-ip-address", "local-ip-address",
"md5", "md-5 0.11.0",
"opentelemetry-proto", "opentelemetry-proto",
"prost 0.14.4", "prost 0.14.4",
"rand 0.10.2", "rand 0.10.2",
@@ -5644,9 +5645,9 @@ dependencies = [
[[package]] [[package]]
name = "lazy-regex" name = "lazy-regex"
version = "3.6.0" version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" checksum = "4994ba703f78b083e2f7946dac9251abd83fd43a0365f030e99b69be5b4b9ef9"
dependencies = [ dependencies = [
"lazy-regex-proc_macros", "lazy-regex-proc_macros",
"once_cell", "once_cell",
@@ -5655,9 +5656,9 @@ dependencies = [
[[package]] [[package]]
name = "lazy-regex-proc_macros" name = "lazy-regex-proc_macros"
version = "3.6.0" version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" checksum = "fd97232314824e6dbef1918a871bb93f51070455e3715bf26e19a6d01aa977a0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -8890,7 +8891,7 @@ dependencies = [
"libmimalloc-sys", "libmimalloc-sys",
"libsystemd", "libsystemd",
"matchit 0.9.2", "matchit 0.9.2",
"md5", "md-5 0.11.0",
"metrics", "metrics",
"metrics-util", "metrics-util",
"mimalloc", "mimalloc",
@@ -9449,9 +9450,10 @@ dependencies = [
"async-trait", "async-trait",
"base64 0.23.0", "base64 0.23.0",
"chacha20poly1305", "chacha20poly1305",
"hex",
"insta", "insta",
"jiff", "jiff",
"md5", "md-5 0.11.0",
"moka", "moka",
"rand 0.10.2", "rand 0.10.2",
"reqwest", "reqwest",
@@ -9460,6 +9462,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
"subtle",
"temp-env", "temp-env",
"tempfile", "tempfile",
"thiserror 2.0.19", "thiserror 2.0.19",
@@ -9717,7 +9720,7 @@ dependencies = [
"hyper-util", "hyper-util",
"ipnetwork", "ipnetwork",
"libunftp", "libunftp",
"md5", "md-5 0.11.0",
"percent-encoding", "percent-encoding",
"proptest", "proptest",
"quick-xml", "quick-xml",
@@ -10128,6 +10131,7 @@ dependencies = [
"bytes", "bytes",
"convert_case 0.11.0", "convert_case 0.11.0",
"crc-fast", "crc-fast",
"criterion",
"flate2", "flate2",
"futures", "futures",
"hex-simd", "hex-simd",
@@ -11829,6 +11833,7 @@ dependencies = [
"futures-util", "futures-util",
"libc", "libc",
"pin-project-lite", "pin-project-lite",
"slab",
"tokio", "tokio",
] ]
-1
View File
@@ -265,7 +265,6 @@ memmap2 = "0.9.11"
lz4 = "1.28.1" lz4 = "1.28.1"
matchit = "0.9.2" matchit = "0.9.2"
md-5 = "0.11.0" md-5 = "0.11.0"
md5 = "0.8.1"
mime_guess = "2.0.5" mime_guess = "2.0.5"
moka = { version = "0.12.15" } moka = { version = "0.12.15" }
netif = "0.1.6" netif = "0.1.6"
+3
View File
@@ -25,6 +25,9 @@ keywords = ["checksum-calculation", "verification", "integrity", "authenticity",
categories = ["web-programming", "development-tools", "network-programming"] categories = ["web-programming", "development-tools", "network-programming"]
documentation = "https://docs.rs/rustfs-checksums/latest/rustfs_checksum/" documentation = "https://docs.rs/rustfs-checksums/latest/rustfs_checksum/"
[lints]
workspace = true
[dependencies] [dependencies]
bytes = { workspace = true, features = ["serde"] } bytes = { workspace = true, features = ["serde"] }
crc-fast = { workspace = true } crc-fast = { workspace = true }
+145 -33
View File
@@ -1114,6 +1114,8 @@ pub struct ScannerLastMinute {
pub struct ScannerMetricsReport { pub struct ScannerMetricsReport {
pub collected_at: DateTime<Utc>, pub collected_at: DateTime<Utc>,
pub current_cycle: u64, pub current_cycle: u64,
#[serde(default)]
pub current_cycle_active: bool,
pub current_started: DateTime<Utc>, pub current_started: DateTime<Utc>,
pub cycles_completed_at: Vec<DateTime<Utc>>, pub cycles_completed_at: Vec<DateTime<Utc>>,
pub ongoing_buckets: usize, pub ongoing_buckets: usize,
@@ -2051,7 +2053,7 @@ impl Metrics {
pub fn record_scanner_transition_failed(&self, count: u64) { pub fn record_scanner_transition_failed(&self, count: u64) {
self.scanner_transition_failed.fetch_add(count, Ordering::Relaxed); self.scanner_transition_failed.fetch_add(count, Ordering::Relaxed);
self.record_scanner_source_failed(ScannerWorkSource::Lifecycle, count); self.record_scanner_source_failed(ScannerWorkSource::Lifecycle, count);
if !self.current_scan_cycle_work_active.load(Ordering::Relaxed) { if !self.current_scan_cycle_work_active.load(Ordering::Acquire) {
self.record_last_cycle_scanner_source_work(ScannerWorkSource::Lifecycle, ScannerSourceWorkUpdate::failed(count)); self.record_last_cycle_scanner_source_work(ScannerWorkSource::Lifecycle, ScannerSourceWorkUpdate::failed(count));
} }
} }
@@ -2336,6 +2338,21 @@ impl Metrics {
*self.cycle_info.write().await = cycle; *self.cycle_info.write().await = cycle;
} }
/// Publish a scanner cycle and its work-accounting baseline as one state transition.
pub async fn start_scan_cycle_work_with_cycle(&self, cycle: CurrentCycle) -> ScanCycleWorkSnapshot {
let mut current_cycle = self.cycle_info.write().await;
let snapshot = self.start_scan_cycle_work();
*current_cycle = Some(cycle);
snapshot
}
/// Publish the completed work snapshot and idle cycle state as one state transition.
pub async fn finish_scan_cycle_work_with_cycle(&self, start: ScanCycleWorkSnapshot, cycle: CurrentCycle) {
let mut current_cycle = self.cycle_info.write().await;
self.finish_scan_cycle_work(start);
*current_cycle = Some(cycle);
}
/// Read the current cycle record. /// Read the current cycle record.
pub async fn get_cycle(&self) -> Option<CurrentCycle> { pub async fn get_cycle(&self) -> Option<CurrentCycle> {
self.cycle_info.read().await.clone() self.cycle_info.read().await.clone()
@@ -2464,7 +2481,7 @@ impl Metrics {
&self.current_scan_cycle_replication_repair_work_start, &self.current_scan_cycle_replication_repair_work_start,
&replication_repair_snapshot, &replication_repair_snapshot,
); );
self.current_scan_cycle_work_active.store(true, Ordering::Relaxed); self.current_scan_cycle_work_active.store(true, Ordering::Release);
snapshot snapshot
} }
@@ -2476,11 +2493,11 @@ impl Metrics {
self.record_scan_cycle_work(work); self.record_scan_cycle_work(work);
self.record_scan_cycle_source_work(&source_work); self.record_scan_cycle_source_work(&source_work);
self.record_scan_cycle_replication_repair_work(&replication_repair_work); self.record_scan_cycle_replication_repair_work(&replication_repair_work);
self.current_scan_cycle_work_active.store(false, Ordering::Relaxed); self.current_scan_cycle_work_active.store(false, Ordering::Release);
} }
pub fn current_scan_cycle_has_unresolved_heal_work(&self) -> bool { pub fn current_scan_cycle_has_unresolved_heal_work(&self) -> bool {
if !self.current_scan_cycle_work_active.load(Ordering::Relaxed) { if !self.current_scan_cycle_work_active.load(Ordering::Acquire) {
return false; return false;
} }
@@ -2746,13 +2763,41 @@ impl Metrics {
pub async fn report(&self) -> ScannerMetricsReport { pub async fn report(&self) -> ScannerMetricsReport {
let mut m = ScannerMetricsReport::default(); let mut m = ScannerMetricsReport::default();
let has_cycle = if let Some(cycle) = self.get_cycle().await { let has_cycle = {
m.current_cycle = cycle.current; let cycle = self.cycle_info.read().await;
m.cycles_completed_at = cycle.cycle_completed; let has_cycle = if let Some(cycle) = cycle.as_ref() {
m.current_started = cycle.started; m.current_cycle = cycle.current;
true m.cycles_completed_at = cycle.cycle_completed.clone();
} else { m.current_started = cycle.started;
false true
} else {
false
};
m.current_cycle_active = self.current_scan_cycle_work_active.load(Ordering::Acquire);
if m.current_cycle_active {
let current_work = self.scan_cycle_work_since(self.current_scan_cycle_work_start());
let current_source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
let current_replication_repair_work =
self.scanner_replication_repair_work_since(&self.current_scan_cycle_replication_repair_work_start_values());
m.current_cycle_objects_scanned = current_work.objects_scanned;
m.current_cycle_directories_scanned = current_work.directories_scanned;
m.current_cycle_bucket_drive_scans = current_work.bucket_drive_scans;
m.current_cycle_bucket_drive_failures = current_work.bucket_drive_failures;
m.current_cycle_yield_events = current_work.yield_events;
m.current_cycle_yield_duration_seconds = current_work.yield_duration_millis as f64 / 1000.0;
m.current_cycle_throttle_sleep_events = current_work.throttle_sleep_events;
m.current_cycle_throttle_sleep_duration_seconds = current_work.throttle_sleep_duration_millis as f64 / 1000.0;
m.current_cycle_ilm_actions = current_work.ilm_actions;
m.current_cycle_lifecycle_expiry_actions = current_work.lifecycle_expiry_actions;
m.current_cycle_lifecycle_transition_actions = current_work.lifecycle_transition_actions;
m.current_cycle_heal_objects = current_work.heal_objects;
m.current_cycle_replication_checks = current_work.replication_checks;
m.current_cycle_usage_saves = current_work.usage_saves;
m.current_cycle_source_work = self.scanner_source_work_snapshots(&current_source_work);
m.current_cycle_replication_repair =
self.scanner_replication_repair_work_snapshots(&current_replication_repair_work);
}
has_cycle
}; };
if !has_cycle && let Some(init_time) = crate::get_global_init_time().await { if !has_cycle && let Some(init_time) = crate::get_global_init_time().await {
@@ -2793,28 +2838,6 @@ impl Metrics {
m.current_disk_scan_concurrency_limit = disk_scan_concurrency_limit; m.current_disk_scan_concurrency_limit = disk_scan_concurrency_limit;
m.current_disk_bucket_scans_queued = disk_bucket_scans_queued; m.current_disk_bucket_scans_queued = disk_bucket_scans_queued;
m.current_disk_bucket_scans_active = disk_bucket_scans_active; m.current_disk_bucket_scans_active = disk_bucket_scans_active;
if self.current_scan_cycle_work_active.load(Ordering::Relaxed) {
let current_work = self.scan_cycle_work_since(self.current_scan_cycle_work_start());
let current_source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
let current_replication_repair_work =
self.scanner_replication_repair_work_since(&self.current_scan_cycle_replication_repair_work_start_values());
m.current_cycle_objects_scanned = current_work.objects_scanned;
m.current_cycle_directories_scanned = current_work.directories_scanned;
m.current_cycle_bucket_drive_scans = current_work.bucket_drive_scans;
m.current_cycle_bucket_drive_failures = current_work.bucket_drive_failures;
m.current_cycle_yield_events = current_work.yield_events;
m.current_cycle_yield_duration_seconds = current_work.yield_duration_millis as f64 / 1000.0;
m.current_cycle_throttle_sleep_events = current_work.throttle_sleep_events;
m.current_cycle_throttle_sleep_duration_seconds = current_work.throttle_sleep_duration_millis as f64 / 1000.0;
m.current_cycle_ilm_actions = current_work.ilm_actions;
m.current_cycle_lifecycle_expiry_actions = current_work.lifecycle_expiry_actions;
m.current_cycle_lifecycle_transition_actions = current_work.lifecycle_transition_actions;
m.current_cycle_heal_objects = current_work.heal_objects;
m.current_cycle_replication_checks = current_work.replication_checks;
m.current_cycle_usage_saves = current_work.usage_saves;
m.current_cycle_source_work = self.scanner_source_work_snapshots(&current_source_work);
m.current_cycle_replication_repair = self.scanner_replication_repair_work_snapshots(&current_replication_repair_work);
}
let last_cycle_result = self.last_scan_cycle_result.load(Ordering::Relaxed); let last_cycle_result = self.last_scan_cycle_result.load(Ordering::Relaxed);
m.last_cycle_result = scan_cycle_result_label(last_cycle_result).to_string(); m.last_cycle_result = scan_cycle_result_label(last_cycle_result).to_string();
m.last_cycle_result_code = last_cycle_result as u64; m.last_cycle_result_code = last_cycle_result as u64;
@@ -4142,6 +4165,8 @@ mod tests {
let report = metrics.report().await; let report = metrics.report().await;
assert!(report.current_cycle_active);
assert_eq!(report.current_cycle, 0);
assert_eq!(report.current_cycle_objects_scanned, 7); assert_eq!(report.current_cycle_objects_scanned, 7);
assert_eq!(report.current_cycle_directories_scanned, 3); assert_eq!(report.current_cycle_directories_scanned, 3);
assert_eq!(report.current_cycle_bucket_drive_scans, 2); assert_eq!(report.current_cycle_bucket_drive_scans, 2);
@@ -4158,6 +4183,8 @@ mod tests {
metrics.finish_scan_cycle_work(start); metrics.finish_scan_cycle_work(start);
let report = metrics.report().await; let report = metrics.report().await;
assert!(!report.current_cycle_active);
assert_eq!(report.current_cycle, 0);
assert_eq!(report.current_cycle_objects_scanned, 0); assert_eq!(report.current_cycle_objects_scanned, 0);
assert_eq!(report.current_cycle_directories_scanned, 0); assert_eq!(report.current_cycle_directories_scanned, 0);
assert_eq!(report.current_cycle_bucket_drive_scans, 0); assert_eq!(report.current_cycle_bucket_drive_scans, 0);
@@ -4184,6 +4211,91 @@ mod tests {
assert_eq!(report.last_cycle_usage_saves, 2); assert_eq!(report.last_cycle_usage_saves, 2);
} }
#[tokio::test]
async fn scan_cycle_activity_and_cycle_state_publish_together() {
let metrics = Metrics::new();
let cycle_started = Utc::now() - chrono::Duration::seconds(5);
let active_cycle = CurrentCycle {
current: 12,
next: 13,
started: cycle_started,
..Default::default()
};
let cycle_state = metrics.cycle_info.read().await;
let mut start_transition = Box::pin(metrics.start_scan_cycle_work_with_cycle(active_cycle));
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
assert!(start_transition.as_mut().poll(&mut context).is_pending());
assert!(!metrics.current_scan_cycle_work_active.load(Ordering::Acquire));
drop(cycle_state);
let start = start_transition.await;
let active = metrics.report().await;
assert!(active.current_cycle_active);
assert_eq!(active.current_cycle, 12);
assert_eq!(active.current_started, cycle_started);
let idle_cycle = CurrentCycle {
current: 0,
next: 13,
started: cycle_started,
..Default::default()
};
let cycle_state = metrics.cycle_info.read().await;
let mut finish_transition = Box::pin(metrics.finish_scan_cycle_work_with_cycle(start, idle_cycle));
assert!(finish_transition.as_mut().poll(&mut context).is_pending());
assert!(metrics.current_scan_cycle_work_active.load(Ordering::Acquire));
drop(cycle_state);
finish_transition.await;
let idle = metrics.report().await;
assert!(!idle.current_cycle_active);
assert_eq!(idle.current_cycle, 0);
}
#[tokio::test]
async fn report_keeps_cycle_identity_and_work_in_one_snapshot() {
let metrics = Metrics::new();
let cycle_ten = CurrentCycle {
current: 10,
next: 11,
started: Utc::now() - chrono::Duration::seconds(10),
..Default::default()
};
let cycle_ten_start = metrics.start_scan_cycle_work_with_cycle(cycle_ten.clone()).await;
metrics.operations[Metric::ScanObject as usize].store(1, Ordering::Relaxed);
let paths = metrics.current_paths.write().await;
let mut report = Box::pin(metrics.report());
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
assert!(report.as_mut().poll(&mut context).is_pending());
metrics
.finish_scan_cycle_work_with_cycle(cycle_ten_start, CurrentCycle { current: 0, ..cycle_ten })
.await;
let cycle_eleven_start = metrics
.start_scan_cycle_work_with_cycle(CurrentCycle {
current: 11,
next: 12,
started: Utc::now(),
..Default::default()
})
.await;
metrics.operations[Metric::ScanObject as usize].store(101, Ordering::Relaxed);
drop(paths);
let snapshot = report.await;
assert_eq!(snapshot.current_cycle, 10);
assert_eq!(snapshot.current_cycle_objects_scanned, 1);
metrics
.finish_scan_cycle_work_with_cycle(cycle_eleven_start, CurrentCycle::default())
.await;
}
#[tokio::test] #[tokio::test]
async fn scanner_cycle_ilm_actions_ignore_global_ilm_work() { async fn scanner_cycle_ilm_actions_ignore_global_ilm_work() {
let metrics = Metrics::new(); let metrics = Metrics::new();
+3
View File
@@ -10,6 +10,9 @@ description = "Shared concurrency contract types for RustFS - workload admission
keywords = ["rustfs", "concurrency", "admission", "backpressure", "workers"] keywords = ["rustfs", "concurrency", "admission", "backpressure", "workers"]
categories = ["concurrency", "filesystem"] categories = ["concurrency", "filesystem"]
[lints]
workspace = true
[dependencies] [dependencies]
# Internal crates # Internal crates
rustfs-io-core = { workspace = true } rustfs-io-core = { workspace = true }
+33
View File
@@ -116,6 +116,27 @@ pub const ENV_OBJECT_GET_SKIP_BITROT_VERIFY: &str = "RUSTFS_OBJECT_GET_SKIP_BITR
/// Default: bitrot verification is enabled on GetObject reads (do not skip). /// Default: bitrot verification is enabled on GetObject reads (do not skip).
pub const DEFAULT_OBJECT_GET_SKIP_BITROT_VERIFY: bool = false; pub const DEFAULT_OBJECT_GET_SKIP_BITROT_VERIFY: bool = false;
/// Request writing the complete remote-tier version state into object metadata.
///
/// This remains ineffective until
/// [`ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED`] is also enabled.
pub const ENV_TIER_REMOTE_VERSION_STATE_WRITE: &str = "RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE";
pub const DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE: bool = false;
/// Operator-attested fleet-wide confirmation for
/// [`ENV_TIER_REMOTE_VERSION_STATE_WRITE`].
///
/// This flag is an operational contract, not automatic capability discovery.
/// Operators may enable it only after every node that can write or read
/// transitioned object metadata supports the remote version-state schema and
/// semantics. Keeping the confirmation separate makes a single-node request or
/// a writer whose local opt-in is removed fail closed.
pub const ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: &str = "RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED";
pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
// ============================================================================= // =============================================================================
// Concurrent Request Fix - Timeout and Backpressure Configuration // Concurrent Request Fix - Timeout and Backpressure Configuration
// ============================================================================= // =============================================================================
@@ -617,3 +638,15 @@ pub const ENV_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY: &str = "RUSTFS_OBJ
/// Default read-ahead disable concurrency threshold: 4. /// Default read-ahead disable concurrency threshold: 4.
pub const DEFAULT_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY: usize = 4; pub const DEFAULT_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY: usize = 4;
#[cfg(test)]
mod remote_version_state_tests {
#[test]
fn remote_version_state_gate_uses_stable_environment_names() {
assert_eq!(super::ENV_TIER_REMOTE_VERSION_STATE_WRITE, "RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE");
assert_eq!(
super::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
"RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED"
);
}
}
+161 -11
View File
@@ -506,8 +506,35 @@ pub struct ReplicationStats {
} }
impl ReplicationStats { impl ReplicationStats {
pub fn is_empty(&self) -> bool {
let Self {
pending_size,
replicated_size,
failed_size,
failed_count,
pending_count,
missed_threshold_size,
after_threshold_size,
missed_threshold_count,
after_threshold_count,
replicated_count,
} = self;
*pending_size == 0
&& *replicated_size == 0
&& *failed_size == 0
&& *failed_count == 0
&& *pending_count == 0
&& *missed_threshold_size == 0
&& *after_threshold_size == 0
&& *missed_threshold_count == 0
&& *after_threshold_count == 0
&& *replicated_count == 0
}
#[deprecated(note = "use is_empty instead")]
pub fn empty(&self) -> bool { pub fn empty(&self) -> bool {
self.replicated_size == 0 && self.failed_size == 0 && self.failed_count == 0 self.is_empty()
} }
} }
@@ -520,16 +547,19 @@ pub struct ReplicationAllStats {
} }
impl ReplicationAllStats { impl ReplicationAllStats {
pub fn is_empty(&self) -> bool {
let Self {
replica_size,
replica_count,
targets,
} = self;
*replica_size == 0 && *replica_count == 0 && targets.values().all(ReplicationStats::is_empty)
}
#[deprecated(note = "use is_empty instead")]
pub fn empty(&self) -> bool { pub fn empty(&self) -> bool {
if self.replica_size != 0 && self.replica_count != 0 { self.is_empty()
return false;
}
for v in self.targets.values() {
if !v.empty() {
return false;
}
}
true
} }
} }
@@ -783,7 +813,7 @@ impl DataUsageCache {
return Some(root); return Some(root);
} }
let mut flat = self.flatten(&root); let mut flat = self.flatten(&root);
if flat.replication_stats.as_ref().is_some_and(|stats| stats.empty()) { if flat.replication_stats.as_ref().is_some_and(ReplicationAllStats::is_empty) {
flat.replication_stats = None; flat.replication_stats = None;
} }
Some(flat) Some(flat)
@@ -1582,6 +1612,126 @@ mod tests {
assert_eq!(map["BETWEEN_1024B_AND_1_MB"], u64::MAX); assert_eq!(map["BETWEEN_1024B_AND_1_MB"], u64::MAX);
} }
#[test]
fn replication_stats_empty_checks_every_field() {
type SetField = fn(&mut ReplicationStats);
let cases: [(&str, SetField); 10] = [
("pending_size", |stats| stats.pending_size = 1),
("replicated_size", |stats| stats.replicated_size = 1),
("failed_size", |stats| stats.failed_size = 1),
("failed_count", |stats| stats.failed_count = 1),
("pending_count", |stats| stats.pending_count = 1),
("missed_threshold_size", |stats| stats.missed_threshold_size = 1),
("after_threshold_size", |stats| stats.after_threshold_size = 1),
("missed_threshold_count", |stats| stats.missed_threshold_count = 1),
("after_threshold_count", |stats| stats.after_threshold_count = 1),
("replicated_count", |stats| stats.replicated_count = 1),
];
assert!(ReplicationStats::default().is_empty());
for (field, set_nonzero) in cases {
let mut stats = ReplicationStats::default();
set_nonzero(&mut stats);
assert!(!stats.is_empty(), "{field} must make replication stats non-empty");
}
}
#[test]
fn replication_all_stats_empty_checks_aggregate_fields_independently() {
let cases = [
(
"replica_size",
ReplicationAllStats {
replica_size: 1,
..Default::default()
},
),
(
"replica_count",
ReplicationAllStats {
replica_count: 1,
..Default::default()
},
),
];
assert!(ReplicationAllStats::default().is_empty());
for (field, stats) in cases {
assert!(!stats.is_empty(), "{field} must make aggregate replication stats non-empty");
}
let empty_targets = ReplicationAllStats {
targets: HashMap::from([("arn:test:empty".to_string(), ReplicationStats::default())]),
..Default::default()
};
assert!(empty_targets.is_empty(), "all-empty targets must keep aggregate stats empty");
let stats = ReplicationAllStats {
targets: HashMap::from([
("arn:test:empty".to_string(), ReplicationStats::default()),
(
"arn:test:non-empty".to_string(),
ReplicationStats {
pending_count: 1,
..Default::default()
},
),
]),
..Default::default()
};
assert!(!stats.is_empty(), "a non-empty target must make aggregate replication stats non-empty");
}
#[test]
fn size_recursive_prunes_empty_and_preserves_pending_replication_stats() {
let root = hash_path("bucket");
let child = hash_path("bucket/child");
let mut cache = DataUsageCache::default();
cache.replace_hashed(&root, &None, &DataUsageEntry::default());
cache.replace_hashed(
&child,
&Some(root.clone()),
&DataUsageEntry {
replication_stats: Some(ReplicationAllStats::default()),
..Default::default()
},
);
assert!(
cache
.size_recursive("bucket")
.expect("bucket usage should flatten")
.replication_stats
.is_none()
);
cache.replace_hashed(
&child,
&Some(root.clone()),
&DataUsageEntry {
replication_stats: Some(ReplicationAllStats {
targets: HashMap::from([(
"arn:test:pending".to_string(),
ReplicationStats {
pending_count: 1,
..Default::default()
},
)]),
..Default::default()
}),
..Default::default()
},
);
let flattened = cache.size_recursive("bucket").expect("bucket usage should flatten");
let replication = flattened
.replication_stats
.expect("pending-only replication stats must survive pruning");
assert_eq!(replication.targets["arn:test:pending"].pending_count, 1);
}
#[test] #[test]
fn test_data_usage_cache_merge_adds_missing_child() { fn test_data_usage_cache_merge_adds_missing_child() {
let mut base = DataUsageCache::default(); let mut base = DataUsageCache::default();
+2 -1
View File
@@ -70,7 +70,8 @@ walkdir.workspace = true
base64 = { workspace = true } base64 = { workspace = true }
rand = { workspace = true, features = ["serde"] } rand = { workspace = true, features = ["serde"] }
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
md5 = { workspace = true } hex = { workspace = true }
md-5 = { workspace = true }
opentelemetry-proto = { workspace = true } opentelemetry-proto = { workspace = true }
prost.workspace = true prost.workspace = true
sha2 = { workspace = true } sha2 = { workspace = true }
+5 -2
View File
@@ -24,9 +24,10 @@ mod tests {
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart}; use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use base64::Engine; use base64::Engine;
use md5::{Digest as Md5Digest, Md5};
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType}; use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
use serial_test::serial; use serial_test::serial;
use sha2::{Digest, Sha256}; use sha2::Sha256;
use tracing::info; use tracing::info;
fn create_s3_client(env: &RustFSTestEnvironment) -> Client { fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
@@ -70,7 +71,9 @@ mod tests {
} }
fn content_md5_base64(body: &[u8]) -> String { fn content_md5_base64(body: &[u8]) -> String {
let digest = md5::compute(body); let mut hasher = Md5::new();
hasher.update(body);
let digest = hasher.finalize();
base64::engine::general_purpose::STANDARD.encode(digest.as_slice()) base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
} }
+18 -5
View File
@@ -25,6 +25,7 @@ use hyper::body::Incoming;
use hyper::server::conn::http1; use hyper::server::conn::http1;
use hyper::service::service_fn; use hyper::service::service_fn;
use hyper_util::rt::{TokioIo, TokioTimer}; use hyper_util::rt::{TokioIo, TokioTimer};
use md5::{Digest as Md5Digest, Md5};
use s3s::access::{S3Access, S3AccessContext}; use s3s::access::{S3Access, S3AccessContext};
use s3s::auth::SimpleAuth; use s3s::auth::SimpleAuth;
use s3s::dto::{ use s3s::dto::{
@@ -827,13 +828,25 @@ fn ensure_body_growth(current: usize, added: usize) -> S3Result {
async fn md5_digest(body: Bytes, permit: OwnedSemaphorePermit) -> S3Result<([u8; 16], OwnedSemaphorePermit)> { async fn md5_digest(body: Bytes, permit: OwnedSemaphorePermit) -> S3Result<([u8; 16], OwnedSemaphorePermit)> {
if body.len() < 1024 * 1024 { if body.len() < 1024 * 1024 {
return Ok((md5::compute(body).0, permit)); return Ok((md5_bytes(body), permit));
} }
tokio::task::spawn_blocking(move || (md5::compute(body).0, permit)) tokio::task::spawn_blocking(move || (md5_bytes(body), permit))
.await .await
.map_err(|error| s3s::s3_error!(InternalError, "MD5 worker failed: {error}")) .map_err(|error| s3s::s3_error!(InternalError, "MD5 worker failed: {error}"))
} }
fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] {
let mut hasher = Md5::new();
hasher.update(input.as_ref());
hasher.finalize().into()
}
fn md5_hex(input: impl AsRef<[u8]>) -> String {
let mut hasher = Md5::new();
hasher.update(input.as_ref());
hex::encode(hasher.finalize())
}
fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result { fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result {
let total_bytes = state let total_bytes = state
.total_bytes .total_bytes
@@ -1005,7 +1018,7 @@ impl S3 for FakeBackend {
Some(value) => value, Some(value) => value,
None => { None => {
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?; let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
format!("{:x}", md5::Digest(digest)) hex::encode(digest)
} }
}; };
let version = ObjectVersion { let version = ObjectVersion {
@@ -1208,7 +1221,7 @@ impl S3 for FakeBackend {
} }
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?; let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?; let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
let e_tag = format!("{:x}", md5::Digest(digest)); let e_tag = hex::encode(digest);
let mut state = lock(&self.store); let mut state = lock(&self.store);
let existing_bytes = state let existing_bytes = state
.uploads .uploads
@@ -1336,7 +1349,7 @@ impl S3 for FakeBackend {
.collect(); .collect();
let (body, digests, _body_permits) = assemble_multipart(assembly_parts, total_len, _body_permits).await?; let (body, digests, _body_permits) = assemble_multipart(assembly_parts, total_len, _body_permits).await?;
let part_count = requested.len(); let part_count = requested.len();
let e_tag = source_etag(&headers)?.unwrap_or_else(|| format!("{:x}-{part_count}", md5::compute(digests))); let e_tag = source_etag(&headers)?.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
let version = ObjectVersion { let version = ObjectVersion {
version_id: upload.version_id.clone(), version_id: upload.version_id.clone(),
body, body,
+4 -1
View File
@@ -30,6 +30,7 @@ use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ServerSideEncryption; use aws_sdk_s3::types::ServerSideEncryption;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use http::header::{CONTENT_TYPE, HOST}; use http::header::{CONTENT_TYPE, HOST};
use md5::{Digest as Md5Digest, Md5};
use rustfs_signer::constants::UNSIGNED_PAYLOAD; use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4; use rustfs_signer::sign_v4;
use s3s::Body; use s3s::Body;
@@ -68,7 +69,9 @@ pub fn skip_if_kms_admin_tool_unavailable(test_name: &str) -> bool {
} }
pub fn sse_customer_key_md5_base64(key: &str) -> String { pub fn sse_customer_key_md5_base64(key: &str) -> String {
BASE64.encode(md5::compute(key).0) let mut hasher = Md5::new();
hasher.update(key.as_bytes());
BASE64.encode(hasher.finalize())
} }
pub async fn kms_admin_request( pub async fn kms_admin_request(
@@ -25,12 +25,18 @@ use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64};
use crate::common::{TEST_BUCKET, init_logging}; use crate::common::{TEST_BUCKET, init_logging};
use aws_sdk_s3::types::ServerSideEncryption; use aws_sdk_s3::types::ServerSideEncryption;
use base64::Engine; use base64::Engine;
use md5::compute; use md5::{Digest as Md5Digest, Md5};
use serial_test::serial; use serial_test::serial;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tracing::{info, warn}; use tracing::{info, warn};
fn md5_hex(input: impl AsRef<[u8]>) -> String {
let mut hasher = Md5::new();
hasher.update(input.as_ref());
hex::encode(hasher.finalize())
}
/// Test encryption of zero-byte files (empty files) /// Test encryption of zero-byte files (empty files)
#[tokio::test] #[tokio::test]
#[serial] #[serial]
@@ -294,7 +300,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
info!("🔍 Testing invalid SSE-C key length"); info!("🔍 Testing invalid SSE-C key length");
let invalid_short_key = "short"; // Too short let invalid_short_key = "short"; // Too short
let invalid_key_b64 = base64::engine::general_purpose::STANDARD.encode(invalid_short_key); let invalid_key_b64 = base64::engine::general_purpose::STANDARD.encode(invalid_short_key);
let invalid_key_md5 = format!("{:x}", compute(invalid_short_key)); let invalid_key_md5 = md5_hex(invalid_short_key);
let invalid_key_result = s3_client let invalid_key_result = s3_client
.put_object() .put_object()
+11 -2
View File
@@ -26,6 +26,7 @@ use chrono::{Duration as ChronoDuration, Utc};
use flate2::{Compression, write::GzEncoder}; use flate2::{Compression, write::GzEncoder};
use http::HeaderValue; use http::HeaderValue;
use http::header::{CONTENT_TYPE, HOST}; use http::header::{CONTENT_TYPE, HOST};
use md5::{Digest as Md5Digest, Md5};
use rustfs_signer::constants::UNSIGNED_PAYLOAD; use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4; use rustfs_signer::sign_v4;
use s3s::Body; use s3s::Body;
@@ -50,7 +51,15 @@ fn encode_post_policy(conditions: Vec<serde_json::Value>) -> String {
} }
fn sse_customer_key_md5_base64(key: &str) -> String { fn sse_customer_key_md5_base64(key: &str) -> String {
base64::engine::general_purpose::STANDARD.encode(md5::compute(key).0) let mut hasher = Md5::new();
hasher.update(key.as_bytes());
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
}
fn md5_hex(input: impl AsRef<[u8]>) -> String {
let mut hasher = Md5::new();
hasher.update(input.as_ref());
hex::encode(hasher.finalize())
} }
/// Env var consumed by the local SSE-S3 DEK provider when KMS is not configured. /// Env var consumed by the local SSE-S3 DEK provider when KMS is not configured.
@@ -5664,7 +5673,7 @@ async fn test_signed_put_object_extract_returns_archive_etag() -> Result<(), Box
client.create_bucket().bucket(bucket).send().await?; client.create_bucket().bucket(bucket).send().await?;
let archive = make_tar(&[("alpha.txt", b"alpha-body")], &[]).await; let archive = make_tar(&[("alpha.txt", b"alpha-body")], &[]).await;
let expected_etag = format!("\"{:x}\"", md5::compute(&archive)); let expected_etag = format!("\"{}\"", md5_hex(&archive));
let response = client let response = client
.put_object() .put_object()
@@ -23,7 +23,8 @@ use rustfs_protos::{
proto_gen::node_service::{ proto_gen::node_service::{
BatchGenerallyLockRequest, BatchGenerallyLockResponse, BatchReadVersionRequest, BatchReadVersionResponse, BatchGenerallyLockRequest, BatchGenerallyLockResponse, BatchReadVersionRequest, BatchReadVersionResponse,
GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult, PingRequest, PingResponse, GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult, PingRequest, PingResponse,
node_service_server::NodeService, SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
SnapshotLeaseResponse, node_service_server::NodeService,
}, },
}; };
use std::pin::Pin; use std::pin::Pin;
@@ -104,6 +105,27 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs")) Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
} }
async fn acquire_snapshot_lease(
&self,
_request: Request<SnapshotLeaseRequest>,
) -> Result<Response<SnapshotLeaseResponse>, Status> {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn renew_snapshot_lease(
&self,
_request: Request<SnapshotLeaseRenewRequest>,
) -> Result<Response<SnapshotLeaseResponse>, Status> {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn release_snapshot_lease(
&self,
_request: Request<SnapshotLeaseReleaseRequest>,
) -> Result<Response<SnapshotLeaseMutationResponse>, Status> {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> { async fn lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
let request = request.into_inner(); let request = request.into_inner();
let args: LockRequest = match serde_json::from_str(&request.args) { let args: LockRequest = match serde_json::from_str(&request.args) {
+10 -2
View File
@@ -95,6 +95,7 @@ const MANUAL_ASYNC_PARALLEL_OBJECTS: usize = 64;
const MANUAL_ACTIVE_CANCEL_OBJECTS: usize = 512; const MANUAL_ACTIVE_CANCEL_OBJECTS: usize = 512;
const MANUAL_RESTART_CANCEL_OBJECTS: usize = 512; const MANUAL_RESTART_CANCEL_OBJECTS: usize = 512;
const MANUAL_ACTIVE_CANCEL_RUNNING_TIMEOUT: StdDuration = StdDuration::from_secs(15); const MANUAL_ACTIVE_CANCEL_RUNNING_TIMEOUT: StdDuration = StdDuration::from_secs(15);
const MANUAL_TRANSITION_CANCEL_BARRIER_ENV: &str = "RUSTFS_E2E_MANUAL_TRANSITION_CANCEL_BARRIER";
const MANUAL_ASYNC_CONFLICT_TERMINAL_TIMEOUT: StdDuration = StdDuration::from_secs(90); const MANUAL_ASYNC_CONFLICT_TERMINAL_TIMEOUT: StdDuration = StdDuration::from_secs(90);
const MANUAL_RESTART_RECOVERY_TIMEOUT: StdDuration = StdDuration::from_secs(80); const MANUAL_RESTART_RECOVERY_TIMEOUT: StdDuration = StdDuration::from_secs(80);
const OBJECT_KEY: &str = "tier/鲁A12345/report.bin"; const OBJECT_KEY: &str = "tier/鲁A12345/report.bin";
@@ -1684,8 +1685,15 @@ async fn test_manual_transition_async_active_cancel_reports_terminal_cancelled()
cold_client.create_bucket().bucket(TIER_BUCKET).send().await?; cold_client.create_bucket().bucket(TIER_BUCKET).send().await?;
let mut hot = RustFSTestEnvironment::new().await?; let mut hot = RustFSTestEnvironment::new().await?;
hot.start_rustfs_server_with_env(vec![], &[("RUSTFS_SCANNER_ENABLED", "false"), ("RUSTFS_SCANNER_CYCLE", "3600")]) hot.start_rustfs_server_with_env(
.await?; vec![],
&[
("RUSTFS_SCANNER_ENABLED", "false"),
("RUSTFS_SCANNER_CYCLE", "3600"),
(MANUAL_TRANSITION_CANCEL_BARRIER_ENV, "1"),
],
)
.await?;
let hot_client = hot.create_s3_client(); let hot_client = hot.create_s3_client();
add_rustfs_tier(&hot, &cold).await?; add_rustfs_tier(&hot, &cold).await?;
+4 -1
View File
@@ -22,6 +22,7 @@ use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration}; use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder; use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use base64::Engine; use base64::Engine;
use md5::{Digest as Md5Digest, Md5};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -102,10 +103,12 @@ impl Intercept for ResponseHeaderCapture {
fn customer_key(byte: u8) -> CustomerKey { fn customer_key(byte: u8) -> CustomerKey {
let raw = [byte; 32]; let raw = [byte; 32];
let mut hasher = Md5::new();
hasher.update(raw);
CustomerKey { CustomerKey {
raw: String::from_utf8_lossy(&raw).into_owned(), raw: String::from_utf8_lossy(&raw).into_owned(),
encoded: base64::engine::general_purpose::STANDARD.encode(raw), encoded: base64::engine::general_purpose::STANDARD.encode(raw),
md5: base64::engine::general_purpose::STANDARD.encode(md5::compute(raw).0), md5: base64::engine::general_purpose::STANDARD.encode(hasher.finalize()),
} }
} }
+4 -3
View File
@@ -127,8 +127,8 @@ pub mod bucket {
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config, get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
get_object_lock_config, get_public_access_block_config, get_quota_config, get_replication_config, get_object_lock_config, get_public_access_block_config, get_quota_config, get_replication_config,
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
init_bucket_metadata_sys, list_bucket_targets, remove_bucket_metadata, set_bucket_metadata, update, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
update_bucket_targets_under_transaction_lock, update_config_with, update, update_bucket_targets_under_transaction_lock, update_config_with,
}; };
} }
@@ -305,7 +305,8 @@ pub mod disk {
CheckPartsResp, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, CheckPartsResp, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore,
FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize, FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
PartTransactionAction, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, PartTransactionAction, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
STORAGE_FORMAT_FILE, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk, validate_batch_read_version_item_count, STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk,
validate_batch_read_version_item_count,
}; };
pub use bytes::Bytes; pub use bytes::Bytes;
pub use endpoint::Endpoint; pub use endpoint::Endpoint;
@@ -554,6 +554,7 @@ async fn delete_free_version_remote_object(
oi: &ObjectInfo, oi: &ObjectInfo,
tier_config_mgr: &Arc<RwLock<TierConfigMgr>>, tier_config_mgr: &Arc<RwLock<TierConfigMgr>>,
) -> Result<(), std::io::Error> { ) -> Result<(), std::io::Error> {
let version_id_exact = validate_transition_remote_version(oi)?;
let identity = tier_destination_id_from_metadata(&oi.user_defined)? let identity = tier_destination_id_from_metadata(&oi.user_defined)?
.ok_or_else(|| std::io::Error::other("tier free-version has no durable backend identity"))?; .ok_or_else(|| std::io::Error::other("tier free-version has no durable backend identity"))?;
delete_object_from_remote_tier_idempotent_with_manager_and_identity( delete_object_from_remote_tier_idempotent_with_manager_and_identity(
@@ -562,7 +563,7 @@ async fn delete_free_version_remote_object(
&oi.transitioned_object.tier, &oi.transitioned_object.tier,
identity, identity,
tier_config_mgr, tier_config_mgr,
false, version_id_exact,
) )
.await?; .await?;
Ok(()) Ok(())
@@ -4201,6 +4202,23 @@ pub async fn get_transitioned_object_reader(
get_transitioned_object_reader_with_tier_manager(bucket, object, rs, h, oi, opts, &tier_config_mgr).await get_transitioned_object_reader_with_tier_manager(bucket, object, rs, h, oi, opts, &tier_config_mgr).await
} }
fn validate_transition_remote_version(oi: &ObjectInfo) -> Result<bool, std::io::Error> {
let version = oi.transitioned_object.version_id.as_str();
match oi.transition_version_state {
rustfs_filemeta::TransitionVersionState::Unknown => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"remote tier object version state is unknown",
)),
rustfs_filemeta::TransitionVersionState::KnownDisabled if version.is_empty() => Ok(false),
rustfs_filemeta::TransitionVersionState::SuspendedNull if version == "null" => Ok(true),
rustfs_filemeta::TransitionVersionState::Exact if !version.is_empty() && version != "null" => Ok(true),
_ => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"remote tier object version state conflicts with its version ID",
)),
}
}
pub(crate) async fn get_transitioned_object_reader_with_tier_manager( pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
bucket: &str, bucket: &str,
object: &str, object: &str,
@@ -4210,6 +4228,7 @@ pub(crate) async fn get_transitioned_object_reader_with_tier_manager(
opts: &ObjectOptions, opts: &ObjectOptions,
tier_config_mgr: &Arc<RwLock<TierConfigMgr>>, tier_config_mgr: &Arc<RwLock<TierConfigMgr>>,
) -> Result<GetObjectReader, std::io::Error> { ) -> Result<GetObjectReader, std::io::Error> {
validate_transition_remote_version(oi)?;
let expected_identity = tier_destination_id_from_metadata(&oi.user_defined)?; let expected_identity = tier_destination_id_from_metadata(&oi.user_defined)?;
let lease = match expected_identity { let lease = match expected_identity {
Some(identity) => { Some(identity) => {
@@ -5506,6 +5525,7 @@ mod tests {
tier: tier.clone(), tier: tier.clone(),
..Default::default() ..Default::default()
}, },
transition_version_state: rustfs_filemeta::TransitionVersionState::Exact,
..Default::default() ..Default::default()
}; };
@@ -5569,6 +5589,7 @@ mod tests {
tier, tier,
..Default::default() ..Default::default()
}, },
transition_version_state: rustfs_filemeta::TransitionVersionState::Exact,
..Default::default() ..Default::default()
}; };
@@ -5591,6 +5612,70 @@ mod tests {
assert_eq!(backend.get_count().await, 0); assert_eq!(backend.get_count().await, 0);
} }
#[cfg(feature = "test-util")]
#[tokio::test]
async fn transitioned_get_rejects_unknown_version_state_before_backend_io() {
let manager = TierConfigMgr::new();
let tier = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&manager, &tier).await;
let object_info = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
size: 1,
transitioned_object: TransitionedObject {
name: "remote/object".to_string(),
version_id: String::new(),
status: crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE.to_string(),
tier,
..Default::default()
},
transition_version_state: rustfs_filemeta::TransitionVersionState::Unknown,
..Default::default()
};
let err = match get_transitioned_object_reader_with_tier_manager(
&object_info.bucket,
&object_info.name,
&None,
&HeaderMap::new(),
&object_info,
&ObjectOptions::default(),
&manager,
)
.await
{
Ok(_) => panic!("unknown remote version state must fail before backend IO"),
Err(err) => err,
};
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(backend.get_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
async fn free_version_delete_rejects_unknown_version_state_before_backend_io() {
let manager = TierConfigMgr::new();
let backend = register_mock_tier(&manager, "WARM").await;
let object_info = ObjectInfo {
transitioned_object: TransitionedObject {
name: "remote/object".to_string(),
version_id: "legacy-version".to_string(),
tier: "WARM".to_string(),
..Default::default()
},
transition_version_state: rustfs_filemeta::TransitionVersionState::Unknown,
..Default::default()
};
let err = super::delete_free_version_remote_object(&object_info, &manager)
.await
.expect_err("unknown remote version state must fail before backend IO");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")] #[cfg(feature = "test-util")]
#[tokio::test] #[tokio::test]
async fn free_version_remote_delete_requires_persisted_destination_identity() { async fn free_version_remote_delete_requires_persisted_destination_identity() {
@@ -5640,6 +5725,7 @@ mod tests {
oi.transitioned_object.tier = "WARM".to_string(); oi.transitioned_object.tier = "WARM".to_string();
oi.transitioned_object.name = "remote/object".to_string(); oi.transitioned_object.name = "remote/object".to_string();
oi.transitioned_object.version_id = "remote-version".to_string(); oi.transitioned_object.version_id = "remote-version".to_string();
oi.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
let local_delete_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let local_delete_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let legacy_err = delete_free_version_remote_object_then(&oi, &manager, { let legacy_err = delete_free_version_remote_object_then(&oi, &manager, {
@@ -5650,7 +5736,8 @@ mod tests {
}) })
.await .await
.expect_err("legacy free-version without identity must be retained"); .expect_err("legacy free-version without identity must be retained");
assert!(legacy_err.to_string().contains("no durable backend identity")); assert_eq!(legacy_err.kind(), std::io::ErrorKind::Other);
assert_eq!(old_backend.remove_count().await, 0);
assert_eq!(local_delete_calls.load(Ordering::Relaxed), 0); assert_eq!(local_delete_calls.load(Ordering::Relaxed), 0);
let mut invalid_metadata = HashMap::new(); let mut invalid_metadata = HashMap::new();
@@ -5781,6 +5868,7 @@ mod tests {
oi.transitioned_object.tier = "WARM".to_string(); oi.transitioned_object.tier = "WARM".to_string();
oi.transitioned_object.name = "remote/object".to_string(); oi.transitioned_object.name = "remote/object".to_string();
oi.transitioned_object.version_id = "remote-version".to_string(); oi.transitioned_object.version_id = "remote-version".to_string();
oi.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
let err = match get_transitioned_object_reader_with_tier_manager( let err = match get_transitioned_object_reader_with_tier_manager(
"bucket", "bucket",
@@ -5796,7 +5884,12 @@ mod tests {
Ok(_) => panic!("identity-bound GET must reject a same-name tier rebind"), Ok(_) => panic!("identity-bound GET must reject a same-name tier rebind"),
Err(err) => err, Err(err) => err,
}; };
assert!(err.to_string().contains("identity no longer matches")); assert_eq!(err.kind(), std::io::ErrorKind::Other);
let admin_err = err
.get_ref()
.and_then(|source| source.downcast_ref::<crate::client::admin_handler_utils::AdminError>())
.expect("identity mismatch should retain the typed tier error");
assert_eq!(admin_err.code, crate::services::tier::tier::ERR_TIER_INVALID_CONFIG.code);
assert_eq!(new_backend.get_count().await, 0); assert_eq!(new_backend.get_count().await, 0);
oi.user_defined = Arc::new(HashMap::new()); oi.user_defined = Arc::new(HashMap::new());
@@ -5902,7 +5995,8 @@ mod tests {
version_id: "remote-version".to_string(), version_id: "remote-version".to_string(),
tier_name: "WARM".to_string(), tier_name: "WARM".to_string(),
backend_identity: Some([1; 32]), backend_identity: Some([1; 32]),
version_id_exact: false, version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
}; };
let err = state let err = state
@@ -6013,7 +6107,8 @@ mod tests {
version_id: "remote-version".to_string(), version_id: "remote-version".to_string(),
tier_name: "WARM".to_string(), tier_name: "WARM".to_string(),
backend_identity: Some([1; 32]), backend_identity: Some([1; 32]),
version_id_exact: false, version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
}; };
state state
@@ -8879,7 +8974,7 @@ mod tests {
.await .await
.expect("first worker result should persist"); .expect("first worker result should persist");
assert_eq!(first.state, ManualTransitionJobState::Running); assert_eq!(first.state, ManualTransitionJobState::Running);
assert_eq!(first.report.transition_completed, 1); assert_eq!(first.report.transition_completed, 0);
assert_eq!(first.report.transition_failed, 0); assert_eq!(first.report.transition_failed, 0);
let duplicate = record_manual_transition_worker_result( let duplicate = record_manual_transition_worker_result(
@@ -8892,11 +8987,11 @@ mod tests {
.await .await
.expect("duplicate worker result should be idempotent"); .expect("duplicate worker result should be idempotent");
assert_eq!(duplicate.state, ManualTransitionJobState::Running); assert_eq!(duplicate.state, ManualTransitionJobState::Running);
assert_eq!(duplicate.report.transition_completed, 1); assert_eq!(duplicate.report.transition_completed, 0);
assert_eq!(duplicate.report.transition_failed, 0); assert_eq!(duplicate.report.transition_failed, 0);
let second_key = manual_transition_worker_result_task_key(&bucket, "logs/b", None); let second_key = manual_transition_worker_result_task_key(&bucket, "logs/b", None);
let final_record = record_manual_transition_worker_result( let pending_record = record_manual_transition_worker_result(
ecstore.clone(), ecstore.clone(),
job_id, job_id,
&second_key, &second_key,
@@ -8905,7 +9000,14 @@ mod tests {
) )
.await .await
.expect("second distinct worker result should persist"); .expect("second distinct worker result should persist");
assert_eq!(pending_record.state, ManualTransitionJobState::Running);
assert_eq!(pending_record.report.transition_completed, 0);
assert_eq!(pending_record.report.transition_failed, 0);
let final_record =
reconcile_manual_transition_worker_results(ecstore.clone(), job_id, ManualTransitionQueueSnapshot::default())
.await
.expect("worker result journal should reconcile");
assert_eq!(final_record.state, ManualTransitionJobState::Partial); assert_eq!(final_record.state, ManualTransitionJobState::Partial);
assert_eq!(final_record.report.transition_completed, 1); assert_eq!(final_record.report.transition_completed, 1);
assert_eq!(final_record.report.transition_failed, 1); assert_eq!(final_record.report.transition_failed, 1);
@@ -8940,7 +9042,7 @@ mod tests {
.expect("worker result job record should save"); .expect("worker result job record should save");
let task_key = manual_transition_worker_result_task_key(&bucket, "logs/fail", None); let task_key = manual_transition_worker_result_task_key(&bucket, "logs/fail", None);
let final_record = record_manual_transition_worker_result_with_reason( let pending_record = record_manual_transition_worker_result_with_reason(
ecstore.clone(), ecstore.clone(),
job_id, job_id,
&task_key, &task_key,
@@ -8950,7 +9052,12 @@ mod tests {
) )
.await .await
.expect("worker result with failure reason should persist"); .expect("worker result with failure reason should persist");
assert!(pending_record.report.tier_failure_by_reason.is_empty());
assert_eq!(pending_record.report.transition_failed, 0);
let final_record = reconcile_manual_transition_worker_results(ecstore, job_id, ManualTransitionQueueSnapshot::default())
.await
.expect("worker failure reason should reconcile");
assert_eq!( assert_eq!(
final_record final_record
.report .report
@@ -10081,6 +10188,87 @@ mod tests {
(backend, identity_hex) (backend, identity_hex)
} }
#[cfg(feature = "test-util")]
#[tokio::test]
async fn journal_replay_rejects_unknown_version_state_before_backend_io() {
let (_disk_paths, ecstore) = setup_test_env().await;
let (backend, _) = register_recovery_mock_tier(&ecstore).await;
let identity = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
.await
.expect("mock tier lease should be available")
.backend_identity();
let je = Jentry {
obj_name: "remote/object".to_string(),
version_id: "legacy-version".to_string(),
tier_name: "WARM".to_string(),
backend_identity: Some(identity),
version_id_exact: false,
version_state: rustfs_filemeta::TransitionVersionState::Unknown,
};
let err = crate::bucket::lifecycle::tier_delete_journal::process_tier_delete_journal_entry(ecstore, &je)
.await
.expect_err("unknown journal state must fail before backend IO");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
async fn journal_replay_deletes_confirmed_exact_provider_token() {
let (_disk_paths, ecstore) = setup_test_env().await;
let (backend, _) = register_recovery_mock_tier(&ecstore).await;
let lease = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
.await
.expect("mock tier lease should be available");
let identity = lease.backend_identity();
backend
.set_put_remote_version(Some("provider-version-token".to_string()))
.await;
lease
.put(
"remote/object",
crate::client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")),
9,
)
.await
.expect("confirmed remote candidate should be seeded");
backend.set_remove_failure(true);
backend.set_reject_non_empty_remote_versions(true);
let je = Jentry {
obj_name: "remote/object".to_string(),
version_id: "provider-version-token".to_string(),
tier_name: "WARM".to_string(),
backend_identity: Some(identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
};
crate::set_disk::cleanup_rejected_transition_upload_durably(
&lease,
&je.obj_name,
&je.version_id,
true,
Some(ecstore.clone()),
)
.await
.expect("failed immediate cleanup should remain durable in the journal");
assert!(backend.contains(&je.obj_name).await);
backend.set_remove_failure(false);
crate::bucket::lifecycle::tier_delete_journal::process_tier_delete_journal_entry(ecstore, &je)
.await
.expect("identity-bound exact journal must retry confirmed candidate cleanup");
assert!(!backend.contains(&je.obj_name).await);
assert_eq!(backend.exact_remove_count(), 2);
assert_eq!(
backend.remove_versions().await,
vec![("remote/object".to_string(), "provider-version-token".to_string())]
);
}
async fn seed_recoverable_free_version( async fn seed_recoverable_free_version(
disk_paths: &[PathBuf], disk_paths: &[PathBuf],
bucket: &str, bucket: &str,
@@ -10100,6 +10288,7 @@ mod tests {
identity, identity,
); );
} }
let transition_version_id = Uuid::new_v4();
let mut metadata = FileMeta::new(); let mut metadata = FileMeta::new();
metadata metadata
.add_version(FileInfo { .add_version(FileInfo {
@@ -10108,7 +10297,9 @@ mod tests {
version_id: Some(object_version_id), version_id: Some(object_version_id),
transition_status: crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE.to_string(), transition_status: crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE.to_string(),
transitioned_objname: format!("remote/{bucket}/{object}"), transitioned_objname: format!("remote/{bucket}/{object}"),
transition_version_id: Some(Uuid::new_v4()), transition_version_id: Some(transition_version_id),
transition_version: Some(transition_version_id.to_string()),
transition_version_state: rustfs_filemeta::TransitionVersionState::Exact,
transition_tier: "WARM".to_string(), transition_tier: "WARM".to_string(),
mod_time: Some(OffsetDateTime::now_utc()), mod_time: Some(OffsetDateTime::now_utc()),
metadata: transitioned_metadata, metadata: transitioned_metadata,
@@ -11127,23 +11318,25 @@ mod tests {
// Distinct payloads with distinct sizes: a mixed-generation reassembly // Distinct payloads with distinct sizes: a mixed-generation reassembly
// would produce bytes matching none of them (or fail the read outright). // would produce bytes matching none of them (or fail the read outright).
let candidates: Vec<Vec<u8>> = (0..3) let candidates: Vec<Vec<u8>> = (0..2)
.map(|g| { .map(|g| {
let len = 4096 + g * 512; let len = 4096 + g * 512;
vec![b'a' + g as u8; len] vec![b'a' + g as u8; len]
}) })
.collect(); .collect();
let commit_barrier = MultipartCommitBarrier::install(&bucket, object, MultipartCommitPause::PutPartBeforeLockLost); let commit_barrier = MultipartCommitBarrier::install_for_arrivals(
let start = Arc::new(tokio::sync::Barrier::new(candidates.len() + 1)); &bucket,
object,
MultipartCommitPause::PutPartBeforeLockAcquire,
candidates.len(),
);
let mut tasks = tokio::task::JoinSet::new(); let mut tasks = tokio::task::JoinSet::new();
for payload in candidates.iter().cloned() { for payload in candidates.iter().cloned() {
let store = ecstore.clone(); let store = ecstore.clone();
let bucket = bucket.clone(); let bucket = bucket.clone();
let upload_id = upload.upload_id.clone(); let upload_id = upload.upload_id.clone();
let start = Arc::clone(&start);
tasks.spawn(async move { tasks.spawn(async move {
start.wait().await;
let mut data = PutObjReader::from_vec(payload.clone()); let mut data = PutObjReader::from_vec(payload.clone());
store store
.put_object_part(&bucket, object, &upload_id, 1, &mut data, &ObjectOptions::default()) .put_object_part(&bucket, object, &upload_id, 1, &mut data, &ObjectOptions::default())
@@ -11151,11 +11344,10 @@ mod tests {
.map(|info| (info, payload)) .map(|info| (info, payload))
}); });
} }
start.wait().await;
// The first writer holds the uploadId commit lock while the other // Both writers finish streaming before racing for the uploadId commit
// resends reach the same critical section. Releasing it proves the // lock. Two generations are sufficient to exercise the mixed-shard
// handoff without depending on saturated CI disk latency. // hazard, while each waiter sits behind at most one cross-disk rename.
commit_barrier.wait_until_paused().await; commit_barrier.wait_until_paused().await;
commit_barrier.release(); commit_barrier.release();
@@ -1646,40 +1646,14 @@ pub async fn record_manual_transition_worker_result_with_reason(
job_id: Uuid, job_id: Uuid,
task_key: &str, task_key: &str,
result: ManualTransitionWorkerResult, result: ManualTransitionWorkerResult,
queue_snapshot: ManualTransitionQueueSnapshot, _queue_snapshot: ManualTransitionQueueSnapshot,
failure_reason: Option<ManualTransitionWorkerFailureReason>, failure_reason: Option<ManualTransitionWorkerFailureReason>,
) -> EcstoreResult<ManualTransitionJobRecord> { ) -> EcstoreResult<ManualTransitionJobRecord> {
let result_record = ManualTransitionWorkerResultRecord::new_with_reason(job_id, task_key, result, failure_reason); let result_record = ManualTransitionWorkerResultRecord::new_with_reason(job_id, task_key, result, failure_reason);
if !save_manual_transition_worker_result_if_absent(api.clone(), &result_record).await? { if !save_manual_transition_worker_result_if_absent(api.clone(), &result_record).await? {
return load_manual_transition_job_record(api, job_id).await; return load_manual_transition_job_record(api, job_id).await;
} }
load_manual_transition_job_record(api, job_id).await
for _ in 0..4 {
let (mut record, etag) = load_manual_transition_job_record_with_etag(api.clone(), job_id).await?;
if record.is_terminal() {
return Ok(record);
}
record.record_worker_result_with_reason(result, queue_snapshot, failure_reason);
match save_manual_transition_job_record_if_current(api.clone(), &record, &etag).await {
Ok(()) => {
if record.is_terminal() {
delete_manual_transition_scope_admission_if_current(
api.clone(),
&record.scope_key,
record.job_id,
record.lease_id,
)
.await?;
} else {
renew_manual_transition_scope_admission_from_job(api, &record).await?;
}
return Ok(record);
}
Err(Error::PreconditionFailed) => continue,
Err(err) => return Err(err),
}
}
Err(Error::PreconditionFailed)
} }
pub async fn renew_manual_transition_job_lease( pub async fn renew_manual_transition_job_lease(
@@ -20,7 +20,10 @@ use tokio_util::sync::CancellationToken;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::bucket::lifecycle::config_boundary; use crate::bucket::lifecycle::config_boundary;
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent_with_manager_and_identity}; use crate::bucket::lifecycle::tier_sweeper::{
Jentry, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
};
use crate::disk::RUSTFS_META_BUCKET; use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader}; use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
@@ -42,6 +45,7 @@ const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300); const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
const TIER_DELETE_JOURNAL_VERSION: u8 = 2; const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3; const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/"; pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -55,24 +59,35 @@ struct PersistedTierDeleteJournalEntry {
backend_identity: Option<[u8; 32]>, backend_identity: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
version_id_exact: Option<bool>, version_id_exact: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
version_state: Option<rustfs_filemeta::TransitionVersionState>,
} }
impl PersistedTierDeleteJournalEntry { impl PersistedTierDeleteJournalEntry {
fn from_jentry(je: &Jentry) -> Self { fn from_jentry(je: &Jentry) -> Result<Self> {
Self { validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?;
version: if je.version_id_exact { let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown;
TIER_DELETE_JOURNAL_EXACT_VERSION let version = if legacy_unknown {
} else if je.backend_identity.is_some() { if je.backend_identity.is_some() {
TIER_DELETE_JOURNAL_VERSION TIER_DELETE_JOURNAL_VERSION
} else { } else {
1 1
}, }
} else {
if je.backend_identity.is_none() {
return Err(Error::other("new tier delete journal entry is missing its backend identity"));
}
TIER_DELETE_JOURNAL_STATE_VERSION
};
Ok(Self {
version,
obj_name: je.obj_name.clone(), obj_name: je.obj_name.clone(),
version_id: je.version_id.clone(), version_id: je.version_id.clone(),
tier_name: je.tier_name.clone(), tier_name: je.tier_name.clone(),
backend_identity: je.backend_identity, backend_identity: je.backend_identity,
version_id_exact: je.version_id_exact.then_some(true), version_id_exact: je.version_id_exact.then_some(true),
} version_state: (!legacy_unknown).then_some(je.version_state),
})
} }
fn into_jentry(self) -> Result<Jentry> { fn into_jentry(self) -> Result<Jentry> {
@@ -84,19 +99,23 @@ impl PersistedTierDeleteJournalEntry {
if self.obj_name.is_empty() || self.tier_name.is_empty() { if self.obj_name.is_empty() || self.tier_name.is_empty() {
return Err(Error::other("tier delete journal entry is incomplete")); return Err(Error::other("tier delete journal entry is incomplete"));
} }
if self.version != TIER_DELETE_JOURNAL_EXACT_VERSION && self.version_id_exact.unwrap_or(false) { if self.version != TIER_DELETE_JOURNAL_EXACT_VERSION
&& self.version != TIER_DELETE_JOURNAL_STATE_VERSION
&& self.version_id_exact.unwrap_or(false)
{
return Err(Error::other( return Err(Error::other(
"legacy tier delete journal entry has an unsupported exact version constraint", "legacy tier delete journal entry has an unsupported exact version constraint",
)); ));
} }
let (backend_identity, version_id_exact) = match self.version { let (backend_identity, version_id_exact, version_state) = match self.version {
1 => (None, false), 1 => (None, false, rustfs_filemeta::TransitionVersionState::Unknown),
TIER_DELETE_JOURNAL_VERSION => ( TIER_DELETE_JOURNAL_VERSION => (
Some( Some(
self.backend_identity self.backend_identity
.ok_or_else(|| Error::other("tier delete journal v2 entry is missing its backend identity"))?, .ok_or_else(|| Error::other("tier delete journal v2 entry is missing its backend identity"))?,
), ),
false, false,
rustfs_filemeta::TransitionVersionState::Unknown,
), ),
TIER_DELETE_JOURNAL_EXACT_VERSION => { TIER_DELETE_JOURNAL_EXACT_VERSION => {
if self.version_id.is_empty() || self.version_id_exact != Some(true) { if self.version_id.is_empty() || self.version_id_exact != Some(true) {
@@ -108,6 +127,22 @@ impl PersistedTierDeleteJournalEntry {
.ok_or_else(|| Error::other("tier delete journal v3 entry is missing its backend identity"))?, .ok_or_else(|| Error::other("tier delete journal v3 entry is missing its backend identity"))?,
), ),
true, true,
rustfs_filemeta::TransitionVersionState::Exact,
)
}
TIER_DELETE_JOURNAL_STATE_VERSION => {
let state = self
.version_state
.ok_or_else(|| Error::other("tier delete journal v4 entry is missing its version state"))?;
let exact = self.version_id_exact.unwrap_or(false);
validate_version_state(state, &self.version_id, exact)?;
(
Some(
self.backend_identity
.ok_or_else(|| Error::other("tier delete journal v4 entry is missing its backend identity"))?,
),
exact,
state,
) )
} }
version => return Err(Error::other(format!("unsupported tier delete journal version {version}"))), version => return Err(Error::other(format!("unsupported tier delete journal version {version}"))),
@@ -118,10 +153,30 @@ impl PersistedTierDeleteJournalEntry {
tier_name: self.tier_name, tier_name: self.tier_name,
backend_identity, backend_identity,
version_id_exact, version_id_exact,
version_state,
}) })
} }
} }
fn validate_version_state(
state: rustfs_filemeta::TransitionVersionState,
version_id: &str,
version_id_exact: bool,
) -> Result<()> {
use rustfs_filemeta::TransitionVersionState::{Exact, KnownDisabled, SuspendedNull, Unknown};
let valid = match state {
Unknown => !version_id_exact,
KnownDisabled => version_id.is_empty() && !version_id_exact,
SuspendedNull => version_id == "null" && version_id_exact,
Exact => !version_id.is_empty() && version_id != "null" && version_id_exact,
};
if !valid {
return Err(Error::other("tier delete journal version state conflicts with its version id"));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TierDeleteJournalRecoveryStats { pub struct TierDeleteJournalRecoveryStats {
pub scanned: usize, pub scanned: usize,
@@ -159,7 +214,7 @@ pub(crate) fn decode_tier_delete_journal_entry(data: &[u8]) -> Result<Jentry> {
} }
pub(crate) fn encode_tier_delete_journal_entry(je: &Jentry) -> Result<Vec<u8>> { pub(crate) fn encode_tier_delete_journal_entry(je: &Jentry) -> Result<Vec<u8>> {
serde_json::to_vec(&PersistedTierDeleteJournalEntry::from_jentry(je)) serde_json::to_vec(&PersistedTierDeleteJournalEntry::from_jentry(je)?)
.map_err(|err| Error::other(format!("encode tier delete journal failed: {err}"))) .map_err(|err| Error::other(format!("encode tier delete journal failed: {err}")))
} }
@@ -209,18 +264,35 @@ where
} }
pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> { pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
if je.version_state == rustfs_filemeta::TransitionVersionState::Unknown {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"tier delete journal remote version state is unknown",
));
}
let backend_identity = je let backend_identity = je
.backend_identity .backend_identity
.ok_or_else(|| std::io::Error::other("legacy tier delete journal has no durable backend identity"))?; .ok_or_else(|| std::io::Error::other("legacy tier delete journal has no durable backend identity"))?;
delete_object_from_remote_tier_idempotent_with_manager_and_identity( if je.version_id_exact {
&je.obj_name, delete_confirmed_transition_candidate_exact_with_manager_and_identity(
&je.version_id, &je.obj_name,
&je.tier_name, &je.version_id,
backend_identity, &je.tier_name,
&api.tier_config_mgr(), backend_identity,
je.version_id_exact, &api.tier_config_mgr(),
) )
.await?; .await?;
} else {
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
&je.obj_name,
&je.version_id,
&je.tier_name,
backend_identity,
&api.tier_config_mgr(),
false,
)
.await?;
}
remove_tier_delete_journal_entry(api, je).await remove_tier_delete_journal_entry(api, je).await
} }
@@ -406,8 +478,9 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
TIER_DELETE_JOURNAL_EXACT_VERSION, await_tier_delete_journal_recovery, decode_tier_delete_journal_entry, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, await_tier_delete_journal_recovery,
encode_tier_delete_journal_entry, record_tier_delete_journal_backend_identity, tier_delete_journal_object_name, decode_tier_delete_journal_entry, encode_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
tier_delete_journal_object_name,
}; };
use crate::bucket::lifecycle::tier_sweeper::Jentry; use crate::bucket::lifecycle::tier_sweeper::Jentry;
use crate::error::Result; use crate::error::Result;
@@ -420,7 +493,8 @@ mod tests {
version_id: "remote-version".to_string(), version_id: "remote-version".to_string(),
tier_name: "WARM".to_string(), tier_name: "WARM".to_string(),
backend_identity: Some([7; 32]), backend_identity: Some([7; 32]),
version_id_exact: false, version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
} }
} }
@@ -436,6 +510,7 @@ mod tests {
assert_eq!(decoded.tier_name, je.tier_name); assert_eq!(decoded.tier_name, je.tier_name);
assert_eq!(decoded.backend_identity, je.backend_identity); assert_eq!(decoded.backend_identity, je.backend_identity);
assert_eq!(decoded.version_id_exact, je.version_id_exact); assert_eq!(decoded.version_id_exact, je.version_id_exact);
assert_eq!(decoded.version_state, je.version_state);
} }
#[test] #[test]
@@ -450,7 +525,7 @@ mod tests {
let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("exact journal JSON should decode"); let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("exact journal JSON should decode");
let decoded = decode_tier_delete_journal_entry(&encoded).expect("exact journal entry should decode"); let decoded = decode_tier_delete_journal_entry(&encoded).expect("exact journal entry should decode");
assert_eq!(persisted["version"], TIER_DELETE_JOURNAL_EXACT_VERSION); assert_eq!(persisted["version"], TIER_DELETE_JOURNAL_STATE_VERSION);
assert_eq!(persisted["version_id_exact"], true); assert_eq!(persisted["version_id_exact"], true);
assert!(decoded.version_id_exact); assert!(decoded.version_id_exact);
assert_ne!(tier_delete_journal_object_name(&exact), tier_delete_journal_object_name(&normalized)); assert_ne!(tier_delete_journal_object_name(&exact), tier_delete_journal_object_name(&normalized));
@@ -513,6 +588,46 @@ mod tests {
} }
} }
#[test]
fn tier_delete_journal_rejects_conflicting_v4_version_states() {
let identity = vec![7_u8; 32];
let invalid = [
("known-disabled", "unexpected", false),
("suspended-null", "", true),
("suspended-null", "null", false),
("exact", "", true),
("exact", "null", true),
("exact", "version", false),
("unknown", "version", true),
];
for (state, version_id, exact) in invalid {
let persisted = serde_json::json!({
"version": TIER_DELETE_JOURNAL_STATE_VERSION,
"obj_name": "remote/object",
"version_id": version_id,
"tier_name": "WARM",
"backend_identity": identity,
"version_id_exact": exact.then_some(true),
"version_state": state,
});
let encoded = serde_json::to_vec(&persisted).expect("invalid journal fixture should encode");
decode_tier_delete_journal_entry(&encoded).expect_err("conflicting v4 version state must fail closed");
}
}
#[test]
fn legacy_journals_decode_with_unknown_version_state() {
let v1 = br#"{"version":1,"obj_name":"remote/object","version_id":"opaque","tier_name":"WARM"}"#;
let v2 = br#"{"version":2,"obj_name":"remote/object","version_id":"opaque","tier_name":"WARM","backend_identity":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7]}"#;
for payload in [v1.as_slice(), v2.as_slice()] {
let decoded = decode_tier_delete_journal_entry(payload).expect("legacy journal should decode");
assert_eq!(decoded.version_state, rustfs_filemeta::TransitionVersionState::Unknown);
assert!(!decoded.version_id_exact);
}
}
#[test] #[test]
fn tier_delete_journal_path_is_stable_and_sanitized() { fn tier_delete_journal_path_is_stable_and_sanitized() {
let je = journal_entry(); let je = journal_entry();
@@ -530,6 +645,8 @@ mod tests {
fn tier_delete_journal_paths_separate_legacy_and_backend_identities() { fn tier_delete_journal_paths_separate_legacy_and_backend_identities() {
let mut legacy = journal_entry(); let mut legacy = journal_entry();
legacy.backend_identity = None; legacy.backend_identity = None;
legacy.version_id_exact = false;
legacy.version_state = rustfs_filemeta::TransitionVersionState::Unknown;
let mut backend_a = journal_entry(); let mut backend_a = journal_entry();
backend_a.backend_identity = Some([1; 32]); backend_a.backend_identity = Some([1; 32]);
let mut backend_b = journal_entry(); let mut backend_b = journal_entry();
@@ -575,6 +692,8 @@ mod tests {
fn tier_delete_journal_without_transition_identity_stays_legacy() { fn tier_delete_journal_without_transition_identity_stays_legacy() {
let mut je = journal_entry(); let mut je = journal_entry();
je.backend_identity = None; je.backend_identity = None;
je.version_id_exact = false;
je.version_state = rustfs_filemeta::TransitionVersionState::Unknown;
let encoded = encode_tier_delete_journal_entry(&je).expect("legacy journal should remain encodable"); let encoded = encode_tier_delete_journal_entry(&je).expect("legacy journal should remain encodable");
let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("journal JSON should decode"); let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("journal JSON should decode");
@@ -185,6 +185,7 @@ struct ObjSweeper {
transition_status: String, transition_status: String,
transition_tier: String, transition_tier: String,
transition_version_id: String, transition_version_id: String,
transition_version_state: rustfs_filemeta::TransitionVersionState,
remote_object: String, remote_object: String,
} }
@@ -231,7 +232,9 @@ impl ObjSweeper {
} }
pub fn should_remove_remote_object(&self) -> Option<Jentry> { pub fn should_remove_remote_object(&self) -> Option<Jentry> {
if self.transition_status != lifecycle::TRANSITION_COMPLETE { if self.transition_status != lifecycle::TRANSITION_COMPLETE
|| self.transition_version_state == rustfs_filemeta::TransitionVersionState::Unknown
{
return None; return None;
} }
@@ -249,7 +252,11 @@ impl ObjSweeper {
version_id: self.transition_version_id.clone(), version_id: self.transition_version_id.clone(),
tier_name: self.transition_tier.clone(), tier_name: self.transition_tier.clone(),
backend_identity: None, backend_identity: None,
version_id_exact: false, version_id_exact: matches!(
self.transition_version_state,
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
),
version_state: self.transition_version_state,
}); });
} }
None None
@@ -286,6 +293,7 @@ pub struct Jentry {
pub(crate) tier_name: String, pub(crate) tier_name: String,
pub(crate) backend_identity: Option<TierDestinationId>, pub(crate) backend_identity: Option<TierDestinationId>,
pub(crate) version_id_exact: bool, pub(crate) version_id_exact: bool,
pub(crate) version_state: rustfs_filemeta::TransitionVersionState,
} }
impl ExpiryOp for Jentry { impl ExpiryOp for Jentry {
@@ -330,7 +338,7 @@ async fn delete_object_from_remote_tier_raw_with_manager(
let lease = TierConfigMgr::acquire_operation_lease(&tier_config_mgr, tier_name) let lease = TierConfigMgr::acquire_operation_lease(&tier_config_mgr, tier_name)
.await .await
.map_err(std::io::Error::other)?; .map_err(std::io::Error::other)?;
delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false).await delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false, true).await
} }
async fn delete_object_from_remote_tier_raw_with_lease( async fn delete_object_from_remote_tier_raw_with_lease(
@@ -338,8 +346,11 @@ async fn delete_object_from_remote_tier_raw_with_lease(
rv_id: &str, rv_id: &str,
lease: &TierOperationLease, lease: &TierOperationLease,
version_id_exact: bool, version_id_exact: bool,
validate_remote_version_id: bool,
) -> Result<(), std::io::Error> { ) -> Result<(), std::io::Error> {
lease.validate_remote_version_id(rv_id)?; if validate_remote_version_id {
lease.validate_remote_version_id(rv_id)?;
}
if remote_delete_breaker_is_open(Instant::now()).await { if remote_delete_breaker_is_open(Instant::now()).await {
metrics::counter!(METRIC_DELETE_REMOTE_BREAKER_TOTAL).increment(1); metrics::counter!(METRIC_DELETE_REMOTE_BREAKER_TOTAL).increment(1);
@@ -435,7 +446,53 @@ pub(crate) async fn delete_object_from_remote_tier_with_lease_idempotent(
lease: &TierOperationLease, lease: &TierOperationLease,
version_id_exact: bool, version_id_exact: bool,
) -> Result<RemoteTierDeleteOutcome, std::io::Error> { ) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
match delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, lease, version_id_exact).await { delete_object_from_remote_tier_with_lease_idempotent_inner(obj_name, rv_id, lease, version_id_exact, true).await
}
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idempotent(
obj_name: &str,
rv_id: &str,
lease: &TierOperationLease,
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
if rv_id.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"confirmed versioned transition candidate requires a non-empty remote version",
));
}
#[cfg(test)]
if obj_name == "remote/empty-guard-probe" {
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
delete_object_from_remote_tier_with_lease_idempotent_inner(obj_name, rv_id, lease, true, false).await
}
#[cfg(test)]
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
obj_name: &str,
rv_id: &str,
tier_name: &str,
backend_identity: TierDestinationId,
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(tier_config_mgr, tier_name, backend_identity)
.await
.map_err(std::io::Error::other)?;
delete_confirmed_transition_candidate_exact_with_lease_idempotent(obj_name, rv_id, &lease).await
}
async fn delete_object_from_remote_tier_with_lease_idempotent_inner(
obj_name: &str,
rv_id: &str,
lease: &TierOperationLease,
version_id_exact: bool,
validate_remote_version_id: bool,
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
match delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, lease, version_id_exact, validate_remote_version_id)
.await
{
Ok(()) => Ok(RemoteTierDeleteOutcome::Deleted), Ok(()) => Ok(RemoteTierDeleteOutcome::Deleted),
Err(err) if is_remote_tier_not_found_error(&err) => Ok(RemoteTierDeleteOutcome::AlreadyRemoved), Err(err) if is_remote_tier_not_found_error(&err) => Ok(RemoteTierDeleteOutcome::AlreadyRemoved),
Err(err) => { Err(err) => {
@@ -460,6 +517,7 @@ pub fn transitioned_delete_journal_entry(
versioned: bool, versioned: bool,
suspended: bool, suspended: bool,
transitioned: &TransitionedObject, transitioned: &TransitionedObject,
transition_version_state: rustfs_filemeta::TransitionVersionState,
) -> Option<Jentry> { ) -> Option<Jentry> {
let sweeper = ObjSweeper { let sweeper = ObjSweeper {
version_id, version_id,
@@ -468,6 +526,7 @@ pub fn transitioned_delete_journal_entry(
transition_status: transitioned.status.clone(), transition_status: transitioned.status.clone(),
transition_tier: transitioned.tier.clone(), transition_tier: transitioned.tier.clone(),
transition_version_id: transitioned.version_id.clone(), transition_version_id: transitioned.version_id.clone(),
transition_version_state,
remote_object: transitioned.name.clone(), remote_object: transitioned.name.clone(),
..Default::default() ..Default::default()
}; };
@@ -475,8 +534,13 @@ pub fn transitioned_delete_journal_entry(
sweeper.should_remove_remote_object() sweeper.should_remove_remote_object()
} }
pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject) -> Option<Jentry> { pub fn transitioned_force_delete_journal_entry(
if transitioned.status != lifecycle::TRANSITION_COMPLETE { transitioned: &TransitionedObject,
transition_version_state: rustfs_filemeta::TransitionVersionState,
) -> Option<Jentry> {
if transitioned.status != lifecycle::TRANSITION_COMPLETE
|| transition_version_state == rustfs_filemeta::TransitionVersionState::Unknown
{
return None; return None;
} }
@@ -485,7 +549,11 @@ pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject
version_id: transitioned.version_id.clone(), version_id: transitioned.version_id.clone(),
tier_name: transitioned.tier.clone(), tier_name: transitioned.tier.clone(),
backend_identity: None, backend_identity: None,
version_id_exact: false, version_id_exact: matches!(
transition_version_state,
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
),
version_state: transition_version_state,
}) })
} }
@@ -494,11 +562,14 @@ mod test {
use crate::client::signer_error::invalid_utf8_header_error; use crate::client::signer_error::invalid_utf8_header_error;
use super::{ use super::{
ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, RemoteDeleteBreaker, RemoteTierDeleteOutcome, CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
RemoteDeleteBreaker, RemoteTierDeleteOutcome, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity, delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
is_remote_tier_not_found_error, is_signer_header_error, set_remote_tier_delete_test_hook, is_remote_tier_not_found_error, is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook,
should_record_remote_delete_failure, should_record_remote_delete_failure, transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
}; };
use crate::storage_api_contracts::lifecycle::TransitionedObject;
use rustfs_filemeta::TransitionVersionState;
use std::io::{Error, ErrorKind}; use std::io::{Error, ErrorKind};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -542,6 +613,43 @@ mod test {
assert!(should_record_remote_delete_failure(&Error::other("NoSuchVersion"))); assert!(should_record_remote_delete_failure(&Error::other("NoSuchVersion")));
} }
#[test]
fn transitioned_delete_journal_preserves_remote_version_state() {
let cases = [
(TransitionVersionState::Unknown, "legacy-version", None),
(TransitionVersionState::KnownDisabled, "", Some(false)),
(TransitionVersionState::SuspendedNull, "null", Some(true)),
(TransitionVersionState::Exact, "opaque-version", Some(true)),
];
for (state, version_id, expected_exact) in cases {
let transitioned = TransitionedObject {
name: "remote/object".to_string(),
version_id: version_id.to_string(),
tier: "WARM".to_string(),
status: lifecycle::TRANSITION_COMPLETE.to_string(),
..Default::default()
};
let regular = transitioned_delete_journal_entry(None, false, false, &transitioned, state);
let forced = transitioned_force_delete_journal_entry(&transitioned, state);
match expected_exact {
Some(expected_exact) => {
let regular = regular.expect("known version state should produce a regular delete journal entry");
assert_eq!(regular.version_state, state);
assert_eq!(regular.version_id_exact, expected_exact);
let forced = forced.expect("known version state should produce a forced delete journal entry");
assert_eq!(forced.version_state, state);
assert_eq!(forced.version_id_exact, expected_exact);
}
None => {
assert!(regular.is_none());
assert!(forced.is_none());
}
}
}
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn idempotent_remote_delete_treats_hooked_nosuchversion_as_already_removed() { async fn idempotent_remote_delete_treats_hooked_nosuchversion_as_already_removed() {
@@ -664,6 +772,55 @@ mod test {
assert_eq!(backend.remove_versions().await, vec![("remote/object".to_string(), String::new())]); assert_eq!(backend.remove_versions().await, vec![("remote/object".to_string(), String::new())]);
} }
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial]
async fn confirmed_transition_cleanup_deletes_exact_provider_token() {
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.store(0, std::sync::atomic::Ordering::Relaxed);
let manager = crate::services::tier::tier::TierConfigMgr::new();
let backend = crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
.await
.expect("test tier lease should be available");
let identity = lease.backend_identity();
drop(lease);
backend.set_reject_non_empty_remote_versions(true);
let outcome = delete_confirmed_transition_candidate_exact_with_manager_and_identity(
"remote/object",
"provider-version-token",
"WARM",
identity,
&manager,
)
.await
.expect("confirmed upload compensation should delete the exact provider token");
assert_eq!(outcome, RemoteTierDeleteOutcome::Deleted);
assert_eq!(backend.exact_remove_count(), 1);
assert_eq!(
backend.remove_versions().await,
vec![("remote/object".to_string(), "provider-version-token".to_string())]
);
let err = delete_confirmed_transition_candidate_exact_with_manager_and_identity(
"remote/empty-guard-probe",
"",
"WARM",
identity,
&manager,
)
.await
.expect_err("confirmed versioned cleanup must reject an empty token");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(backend.remove_count().await, 1);
assert_eq!(
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.load(std::sync::atomic::Ordering::Relaxed),
0,
"empty remote versions must be rejected before exact cleanup dispatch"
);
}
#[test] #[test]
fn breaker_opens_at_threshold_and_recovers_after_window() { fn breaker_opens_at_threshold_and_recovers_after_window() {
let mut breaker = RemoteDeleteBreaker::new(3, Duration::from_secs(30)); let mut breaker = RemoteDeleteBreaker::new(3, Duration::from_secs(30));
@@ -22,7 +22,10 @@ use uuid::Uuid;
use crate::bucket::lifecycle::config_boundary; use crate::bucket::lifecycle::config_boundary;
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE; use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
use crate::bucket::lifecycle::tier_sweeper::delete_object_from_remote_tier_idempotent_with_manager_and_identity; use crate::bucket::lifecycle::tier_sweeper::{
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
};
use crate::disk::RUSTFS_META_BUCKET; use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result as EcstoreResult}; use crate::error::{Error, Result as EcstoreResult};
use crate::object_api::ObjectOptions; use crate::object_api::ObjectOptions;
@@ -708,6 +711,21 @@ async fn recover_unknown_upload_outcome(
TransitionCandidateProbe::UnversionedPresent => { TransitionCandidateProbe::UnversionedPresent => {
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::unversioned()).await cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::unversioned()).await
} }
TransitionCandidateProbe::VersionedPresent(version_id)
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
{
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
&transaction.remote_object,
&version_id,
&transaction.tier_name,
transaction.backend_fingerprint,
&api.tier_config_mgr(),
)
.await
.map_err(Error::other)?;
delete_transition_transaction_record(api, transaction.transaction_id).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
TransitionCandidateProbe::VersionedPresent(version_id) => { TransitionCandidateProbe::VersionedPresent(version_id) => {
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
} }
+622 -47
View File
@@ -23,7 +23,7 @@ use crate::error::{Error, Result, is_err_bucket_not_found};
use crate::runtime::sources as runtime_sources; use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::heal::HealOperations as _; use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _; use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use crate::store::ECStore; use crate::store::{ECStore, await_bucket_namespace_operation};
use futures::future::join_all; use futures::future::join_all;
use rustfs_common::heal_channel::HealOpts; use rustfs_common::heal_channel::HealOpts;
use rustfs_policy::policy::BucketPolicy; use rustfs_policy::policy::BucketPolicy;
@@ -35,15 +35,24 @@ use s3s::dto::{
}; };
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
use std::{collections::HashMap, sync::Arc}; use std::{
collections::HashMap,
sync::{Arc, Mutex as StdMutex, Weak},
};
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio::sync::RwLock; use tokio::sync::{Mutex, RwLock};
use tokio::time::sleep; use tokio::time::sleep;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{error, warn}; use tracing::{error, warn};
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
#[derive(Clone, Copy)]
enum MetadataLoadMode {
Initial,
Refresh,
}
pub async fn init_bucket_metadata_sys(api: Arc<ECStore>, buckets: Vec<String>) { pub async fn init_bucket_metadata_sys(api: Arc<ECStore>, buckets: Vec<String>) {
// The metadata system is inherently per-store (it holds the store handle // The metadata system is inherently per-store (it holds the store handle
// and that store's bucket cache), so it lives on the store's own instance // and that store's bucket cache), so it lives on the store's own instance
@@ -85,6 +94,19 @@ pub async fn set_bucket_metadata(bucket: String, bm: BucketMetadata) -> Result<(
Ok(()) Ok(())
} }
pub async fn reload_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<()> {
if is_meta_bucketname(bucket) {
return Err(Error::other("errInvalidArgument"));
}
let namespace_lock = api.new_ns_lock(bucket, bucket).await?;
let namespace_guard = namespace_lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await?;
let sys = bucket_metadata_sys_of(&api.ctx)?;
let lock = sys.read().await;
lock.reload_from_store_under_namespace(bucket, &namespace_guard).await
}
/// Drop a bucket's cached metadata from the in-memory map. /// Drop a bucket's cached metadata from the in-memory map.
/// ///
/// This is the counterpart to [`set_bucket_metadata`] and is invoked when a /// This is the counterpart to [`set_bucket_metadata`] and is invoked when a
@@ -139,8 +161,7 @@ async fn refresh_buckets_metadata_once(sys: Arc<RwLock<BucketMetadataSys>>) {
let mut failed_buckets = HashSet::new(); let mut failed_buckets = HashSet::new();
for chunk in buckets.chunks(count) { for chunk in buckets.chunks(count) {
let sys = sys.read().await; BucketMetadataSys::concurrent_refresh_load(Arc::clone(&sys), chunk, &mut failed_buckets).await;
sys.concurrent_load(chunk, &mut failed_buckets).await;
} }
if !failed_buckets.is_empty() { if !failed_buckets.is_empty() {
@@ -457,10 +478,44 @@ pub async fn list_bucket_targets(bucket: &str) -> Result<BucketTargets> {
/// notification was lost; the capacity bounds memory under bogus-name floods. /// notification was lost; the capacity bounds memory under bogus-name floods.
const ABSENT_BUCKET_METADATA_TTL: Duration = Duration::from_secs(30); const ABSENT_BUCKET_METADATA_TTL: Duration = Duration::from_secs(30);
const ABSENT_BUCKET_METADATA_MAX_ENTRIES: u64 = 10_000; const ABSENT_BUCKET_METADATA_MAX_ENTRIES: u64 = 10_000;
const PEER_METADATA_NOT_PERSISTED: &str = "no persisted bucket metadata readable; peer cache left unchanged";
#[derive(Debug)]
struct MetadataPublishLockRegistry {
locks: StdMutex<HashMap<String, Weak<Mutex<MetadataPublishLockState>>>>,
}
#[derive(Debug)]
struct MetadataPublishLockState {
bucket: String,
registry: Weak<MetadataPublishLockRegistry>,
lock: Weak<Mutex<MetadataPublishLockState>>,
}
impl Drop for MetadataPublishLockState {
fn drop(&mut self) {
let Some(registry) = self.registry.upgrade() else {
return;
};
let mut locks = registry.locks.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if locks.get(&self.bucket).is_some_and(|current| current.ptr_eq(&self.lock)) {
locks.remove(&self.bucket);
}
}
}
#[derive(Debug)]
struct MetadataPublishGuard {
_guard: tokio::sync::OwnedMutexGuard<MetadataPublishLockState>,
}
#[derive(Debug)] #[derive(Debug)]
pub struct BucketMetadataSys { pub struct BucketMetadataSys {
metadata_map: RwLock<HashMap<String, Arc<BucketMetadata>>>, metadata_map: RwLock<HashMap<String, Arc<BucketMetadata>>>,
/// Serializes metadata-map commits and their derived cache updates for one
/// bucket. Namespace locks, when present, are acquired before this lock.
metadata_publish_locks: Arc<MetadataPublishLockRegistry>,
#[cfg(test)]
lazy_load_lock_probe: std::sync::atomic::AtomicBool,
/// Buckets recently observed to have no persisted metadata. Serving the /// Buckets recently observed to have no persisted metadata. Serving the
/// fabricated default from here (instead of re-reading disk) keeps the /// fabricated default from here (instead of re-reading disk) keeps the
/// per-request cost of repeated lookups for such names bounded — without /// per-request cost of repeated lookups for such names bounded — without
@@ -476,6 +531,11 @@ impl BucketMetadataSys {
pub fn new(api: Arc<ECStore>) -> Self { pub fn new(api: Arc<ECStore>) -> Self {
Self { Self {
metadata_map: RwLock::new(HashMap::new()), metadata_map: RwLock::new(HashMap::new()),
metadata_publish_locks: Arc::new(MetadataPublishLockRegistry {
locks: StdMutex::new(HashMap::new()),
}),
#[cfg(test)]
lazy_load_lock_probe: std::sync::atomic::AtomicBool::new(false),
absent_metadata: moka::future::Cache::builder() absent_metadata: moka::future::Cache::builder()
.max_capacity(ABSENT_BUCKET_METADATA_MAX_ENTRIES) .max_capacity(ABSENT_BUCKET_METADATA_MAX_ENTRIES)
.time_to_live(ABSENT_BUCKET_METADATA_TTL) .time_to_live(ABSENT_BUCKET_METADATA_TTL)
@@ -489,6 +549,65 @@ impl BucketMetadataSys {
self.api.clone() self.api.clone()
} }
fn metadata_publish_lock(&self, bucket: &str) -> Arc<Mutex<MetadataPublishLockState>> {
let mut locks = self
.metadata_publish_locks
.locks
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
locks.get(bucket).and_then(Weak::upgrade).unwrap_or_else(|| {
let lock = Arc::new_cyclic(|lock| {
Mutex::new(MetadataPublishLockState {
bucket: bucket.to_string(),
registry: Arc::downgrade(&self.metadata_publish_locks),
lock: lock.clone(),
})
});
locks.insert(bucket.to_string(), Arc::downgrade(&lock));
lock
})
}
async fn lock_metadata_publish(
&self,
bucket: &str,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
operation: &'static str,
) -> Result<MetadataPublishGuard> {
let lock = self.metadata_publish_lock(bucket);
let guard = await_bucket_namespace_operation(Some(namespace_guard), bucket, operation, async {
Ok(MetadataPublishGuard {
_guard: lock.lock_owned().await,
})
})
.await?;
if namespace_guard.is_lock_lost() {
return Err(Error::other(format!("bucket namespace lock was lost before {operation}: {bucket}")));
}
Ok(guard)
}
async fn bucket_exists(
&self,
bucket: &str,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
operation: &'static str,
) -> Result<bool> {
await_bucket_namespace_operation(Some(namespace_guard), bucket, operation, async {
match self
.api
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
{
Ok(_) => Ok(true),
Err(crate::disk::error::Error::VolumeNotFound) => Ok(false),
Err(err) => Err(err.into()),
}
})
.await
}
pub async fn init(&mut self, buckets: Vec<String>) { pub async fn init(&mut self, buckets: Vec<String>) {
let _ = self.init_internal(buckets).await; let _ = self.init_internal(buckets).await;
} }
@@ -502,11 +621,13 @@ impl BucketMetadataSys {
loop { loop {
if buckets.len() < count { if buckets.len() < count {
self.concurrent_load(buckets, &mut failed_buckets).await; self.concurrent_load(buckets, &mut failed_buckets, MetadataLoadMode::Initial)
.await;
break; break;
} }
self.concurrent_load(&buckets[..count], &mut failed_buckets).await; self.concurrent_load(&buckets[..count], &mut failed_buckets, MetadataLoadMode::Initial)
.await;
buckets = &buckets[count..] buckets = &buckets[count..]
} }
@@ -517,7 +638,7 @@ impl BucketMetadataSys {
Ok(()) Ok(())
} }
async fn concurrent_load(&self, buckets: &[String], failed_buckets: &mut HashSet<String>) { async fn concurrent_load(&self, buckets: &[String], failed_buckets: &mut HashSet<String>, mode: MetadataLoadMode) {
let mut futures = Vec::new(); let mut futures = Vec::new();
for bucket in buckets.iter() { for bucket in buckets.iter() {
@@ -525,16 +646,16 @@ impl BucketMetadataSys {
let bucket = bucket.clone(); let bucket = bucket.clone();
futures.push(async move { futures.push(async move {
sleep(Duration::from_millis(30)).await; sleep(Duration::from_millis(30)).await;
let _ = api let expected = match mode {
.heal_bucket( MetadataLoadMode::Initial => None,
&bucket, MetadataLoadMode::Refresh => self.metadata_map.read().await.get(&bucket).cloned(),
&HealOpts { };
recreate: true, let namespace_lock = api.new_ns_lock(&bucket, &bucket).await?;
..Default::default() let namespace_guard = namespace_lock
}, .get_read_lock(crate::set_disk::get_lock_acquire_timeout())
) .await?;
.await; self.load_bucket_under_namespace(&bucket, mode, expected.as_ref(), &namespace_guard)
load_bucket_metadata_parse_with_presence(self.api.clone(), bucket.as_str(), true).await .await
}); });
} }
@@ -542,26 +663,7 @@ impl BucketMetadataSys {
for (idx, res) in results.into_iter().enumerate() { for (idx, res) in results.into_iter().enumerate() {
match res { match res {
Ok((bm, persisted)) => { Ok(()) => {}
if let Some(bucket) = buckets.get(idx) {
if persisted {
self.set(bucket.clone(), Arc::new(bm)).await;
} else {
// A fabricated default (no persisted metadata
// readable right now) must never REPLACE an
// existing entry: the periodic refresh would
// otherwise downgrade a lock-enabled bucket to an
// authoritative "no lock" default on a transient
// ConfigNotFound, disabling the object-lock
// delete gate and wiping its target/durability
// sync state. Insert-if-vacant keeps the startup
// behavior for legacy buckets without a metadata
// file, atomically under the map write lock.
let mut map = self.metadata_map.write().await;
map.entry(bucket.clone()).or_insert_with(|| Arc::new(bm));
}
}
}
Err(e) => { Err(e) => {
error!("Unable to load bucket metadata, will be retried: {:?}", e); error!("Unable to load bucket metadata, will be retried: {:?}", e);
if let Some(bucket) = buckets.get(idx) { if let Some(bucket) = buckets.get(idx) {
@@ -572,6 +674,136 @@ impl BucketMetadataSys {
} }
} }
async fn concurrent_refresh_load(sys: Arc<RwLock<Self>>, buckets: &[String], failed_buckets: &mut HashSet<String>) {
let mut futures = Vec::with_capacity(buckets.len());
for bucket in buckets {
let sys = Arc::clone(&sys);
let bucket = bucket.clone();
futures.push(async move {
sleep(Duration::from_millis(30)).await;
let api = sys.read().await.api.clone();
let namespace_lock = api.new_ns_lock(&bucket, &bucket).await?;
let namespace_guard = namespace_lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await?;
let metadata_sys = sys.read().await;
let expected = metadata_sys.metadata_map.read().await.get(&bucket).cloned();
metadata_sys
.load_bucket_under_namespace(&bucket, MetadataLoadMode::Refresh, expected.as_ref(), &namespace_guard)
.await
});
}
let results = join_all(futures).await;
for (idx, result) in results.into_iter().enumerate() {
if let Err(err) = result {
error!("Unable to load bucket metadata, will be retried: {:?}", err);
if let Some(bucket) = buckets.get(idx) {
failed_buckets.insert(bucket.clone());
}
}
}
}
async fn load_bucket_under_namespace(
&self,
bucket: &str,
mode: MetadataLoadMode,
expected: Option<&Arc<BucketMetadata>>,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
"bucket metadata heal",
self.api.heal_bucket(bucket, &HealOpts::default()),
)
.await?;
if !self
.bucket_exists(bucket, namespace_guard, "bucket metadata existence check")
.await?
{
if matches!(mode, MetadataLoadMode::Refresh) {
let _publish_guard = self
.lock_metadata_publish(bucket, namespace_guard, "stale bucket metadata removal")
.await?;
let removed = self.metadata_map.write().await.remove(bucket).is_some();
if removed {
BucketTargetSys::get().delete(bucket).await;
clear_bucket_durability(bucket);
}
}
return Ok(());
}
let (bm, persisted) = await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
"bucket metadata load",
load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true),
)
.await?;
match mode {
MetadataLoadMode::Initial if persisted => {
let bm = Arc::new(bm);
let _publish_guard = self
.lock_metadata_publish(bucket, namespace_guard, "initial bucket metadata publish")
.await?;
self.metadata_map.write().await.insert(bucket.to_string(), Arc::clone(&bm));
self.absent_metadata.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &bm).await;
sync_bucket_durability(bucket, &bm);
}
MetadataLoadMode::Initial => {
let _publish_guard = self
.lock_metadata_publish(bucket, namespace_guard, "initial bucket metadata publish")
.await?;
self.metadata_map
.write()
.await
.entry(bucket.to_string())
.or_insert_with(|| Arc::new(bm));
}
MetadataLoadMode::Refresh => {
self.publish_if_unchanged(bucket, expected, bm, persisted, namespace_guard)
.await?;
}
}
Ok(())
}
async fn publish_if_unchanged(
&self,
bucket: &str,
expected: Option<&Arc<BucketMetadata>>,
metadata: BucketMetadata,
persisted: bool,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
if !persisted {
return Ok(());
}
let _publish_guard = self
.lock_metadata_publish(bucket, namespace_guard, "refreshed bucket metadata publish")
.await?;
let metadata = Arc::new(metadata);
let mut map = self.metadata_map.write().await;
let unchanged = match (expected, map.get(bucket)) {
(None, None) => true,
(Some(expected), Some(current)) => Arc::ptr_eq(expected, current),
_ => false,
};
if !unchanged {
return Ok(());
}
map.insert(bucket.to_string(), Arc::clone(&metadata));
drop(map);
self.absent_metadata.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &metadata).await;
sync_bucket_durability(bucket, &metadata);
Ok(())
}
pub async fn get(&self, bucket: &str) -> Result<Arc<BucketMetadata>> { pub async fn get(&self, bucket: &str) -> Result<Arc<BucketMetadata>> {
if is_meta_bucketname(bucket) { if is_meta_bucketname(bucket) {
return Err(Error::ConfigNotFound); return Err(Error::ConfigNotFound);
@@ -587,6 +819,8 @@ impl BucketMetadataSys {
pub async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) { pub async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
if !is_meta_bucketname(&bucket) { if !is_meta_bucketname(&bucket) {
let publish_lock = self.metadata_publish_lock(&bucket);
let _publish_guard = publish_lock.lock().await;
let mut map = self.metadata_map.write().await; let mut map = self.metadata_map.write().await;
map.insert(bucket.clone(), bm.clone()); map.insert(bucket.clone(), bm.clone());
drop(map); drop(map);
@@ -604,6 +838,8 @@ impl BucketMetadataSys {
if is_meta_bucketname(bucket) { if is_meta_bucketname(bucket) {
return false; return false;
} }
let publish_lock = self.metadata_publish_lock(bucket);
let _publish_guard = publish_lock.lock().await;
let mut map = self.metadata_map.write().await; let mut map = self.metadata_map.write().await;
let removed = map.remove(bucket).is_some(); let removed = map.remove(bucket).is_some();
drop(map); drop(map);
@@ -717,6 +953,49 @@ impl BucketMetadataSys {
load_bucket_metadata(self.api.clone(), bucket).await load_bucket_metadata(self.api.clone(), bucket).await
} }
/// Reload persisted metadata under the bucket namespace generation fence.
///
/// A miss is never published as an authoritative default, and a snapshot
/// read before delete plus same-name recreation cannot replace the new
/// generation.
pub(crate) async fn reload_from_store(&self, bucket: &str) -> Result<()> {
if is_meta_bucketname(bucket) {
return Err(Error::other("errInvalidArgument"));
}
let namespace_lock = self.api.new_ns_lock(bucket, bucket).await?;
let namespace_guard = namespace_lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await?;
self.reload_from_store_under_namespace(bucket, &namespace_guard).await
}
async fn reload_from_store_under_namespace(
&self,
bucket: &str,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
let expected = self.metadata_map.read().await.get(bucket).cloned();
if !self
.bucket_exists(bucket, namespace_guard, "peer bucket metadata existence check")
.await?
{
return Err(Error::other(PEER_METADATA_NOT_PERSISTED));
}
let (metadata, persisted) = await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
"peer bucket metadata load",
load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true),
)
.await?;
if !persisted {
return Err(Error::other(PEER_METADATA_NOT_PERSISTED));
}
self.publish_if_unchanged(bucket, expected.as_ref(), metadata, true, namespace_guard)
.await
}
pub async fn get_config(&self, bucket: &str) -> Result<(Arc<BucketMetadata>, bool)> { pub async fn get_config(&self, bucket: &str) -> Result<(Arc<BucketMetadata>, bool)> {
let has_bm = { let has_bm = {
let map = self.metadata_map.read().await; let map = self.metadata_map.read().await;
@@ -735,7 +1014,24 @@ impl BucketMetadataSys {
return Ok((Arc::new(bm), true)); return Ok((Arc::new(bm), true));
} }
let (bm, persisted) = match load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true).await { let lock = self.api.new_ns_lock(bucket, bucket).await?;
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
#[cfg(test)]
if self.lazy_load_lock_probe.load(std::sync::atomic::Ordering::Relaxed) {
let competing = self.api.new_ns_lock(bucket, bucket).await?;
assert!(
competing.get_write_lock(Duration::from_millis(20)).await.is_err(),
"lazy metadata IO must start while the bucket namespace read lock is held"
);
}
let (bm, persisted) = match await_bucket_namespace_operation(
Some(&guard),
bucket,
"lazy bucket metadata load",
Box::pin(load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true)),
)
.await
{
Ok(res) => res, Ok(res) => res,
Err(err) => { Err(err) => {
return if *self.initialized.read().await { return if *self.initialized.read().await {
@@ -759,9 +1055,35 @@ impl BucketMetadataSys {
// defaults for buckets listed on disk — legacy buckets without a // defaults for buckets listed on disk — legacy buckets without a
// metadata file — but never lets one replace an existing entry.) // metadata file — but never lets one replace an existing entry.)
if persisted { if persisted {
await_bucket_namespace_operation(
Some(&guard),
bucket,
"lazy bucket metadata existence check",
Box::pin(async {
self.api
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.map(|_| ())
.map_err(Into::into)
}),
)
.await?;
if guard.is_lock_lost() {
return Err(Error::other(format!(
"bucket namespace lock was lost before lazy bucket metadata publish: {bucket}"
)));
}
let _publish_guard = self
.lock_metadata_publish(bucket, &guard, "lazy bucket metadata publish")
.await?;
let mut map = self.metadata_map.write().await; let mut map = self.metadata_map.write().await;
if let Some(current) = map.get(bucket) {
return Ok((Arc::clone(current), true));
}
map.insert(bucket.to_string(), bm.clone()); map.insert(bucket.to_string(), bm.clone());
drop(map); drop(map);
self.absent_metadata.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &bm).await; sync_bucket_target_sys(bucket, &bm).await;
sync_bucket_durability(bucket, &bm); sync_bucket_durability(bucket, &bm);
} else { } else {
@@ -1047,12 +1369,13 @@ mod tests {
/// Pins the fail-closed caching contract of the lazy `get_config` path /// Pins the fail-closed caching contract of the lazy `get_config` path
/// and the refresh no-replace rule: fabricated defaults are returned but /// and the refresh no-replace rule: fabricated defaults are returned but
/// never served by the map-only `get()`, persisted metadata is cached on /// never served by the map-only `get()`, persisted metadata is cached on
/// lazy load (superseding a recorded absence), and a refresh-load miss /// lazy load (superseding a recorded absence), a refresh-load miss never
/// never replaces an existing entry. /// replaces an existing entry or heals a deleted bucket, and initial load
/// still heals buckets discovered from storage.
#[tokio::test] #[tokio::test]
async fn get_config_never_caches_fabricated_defaults_as_authoritative() { async fn get_config_never_caches_fabricated_defaults_as_authoritative() {
let (_dirs, ecstore) = isolated_store_over_temp_disks().await; let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore); let sys = Arc::new(BucketMetadataSys::new(ecstore));
// (a) Miss: the fabricated default is returned but not cached. // (a) Miss: the fabricated default is returned but not cached.
let (bm, _) = sys let (bm, _) = sys
@@ -1078,6 +1401,9 @@ mod tests {
let mut persisted = BucketMetadata::new("absent-bucket"); let mut persisted = BucketMetadata::new("absent-bucket");
persisted.policy_config_json = b"persisted-marker".to_vec(); persisted.policy_config_json = b"persisted-marker".to_vec();
sys.persist_and_set(persisted).await.expect("metadata should persist"); sys.persist_and_set(persisted).await.expect("metadata should persist");
for dir in &dirs {
std::fs::create_dir_all(dir.path().join("absent-bucket")).expect("persisted bucket directory should be created");
}
sys.metadata_map.write().await.clear(); sys.metadata_map.write().await.clear();
let _ = sys let _ = sys
.get_config("absent-bucket") .get_config("absent-bucket")
@@ -1088,15 +1414,53 @@ mod tests {
.await .await
.expect("lazily loaded persisted metadata must be cached"); .expect("lazily loaded persisted metadata must be cached");
assert_eq!(cached.policy_config_json, b"persisted-marker".to_vec()); assert_eq!(cached.policy_config_json, b"persisted-marker".to_vec());
sys.metadata_map.write().await.clear();
sys.reload_from_store("absent-bucket")
.await
.expect("peer reload should publish persisted metadata into a cold cache");
assert_eq!(sys.get("absent-bucket").await.unwrap().policy_config_json, b"persisted-marker".to_vec());
// (c) A refresh-load miss (no persisted metadata readable) must not // (c) Persisted metadata left behind after physical deletion must not
// replace an existing entry. // be lazily republished as a live bucket generation.
let mut deleted_lazy = BucketMetadata::new("deleted-lazy-bucket");
deleted_lazy.policy_config_json = b"stale-generation".to_vec();
sys.persist_and_set(deleted_lazy)
.await
.expect("stale metadata should persist");
sys.metadata_map.write().await.remove("deleted-lazy-bucket");
assert!(
sys.get_config("deleted-lazy-bucket").await.is_err(),
"lazy load must fail when the physical bucket no longer exists"
);
assert!(sys.get("deleted-lazy-bucket").await.is_err());
// (d) The namespace generation fence must be acquired before lazy
// metadata IO, so a writer can replace the generation atomically.
let fenced_bucket = "fenced-lazy-bucket";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(fenced_bucket)).unwrap();
}
let mut old_fenced = BucketMetadata::new(fenced_bucket);
old_fenced.policy_config_json = b"old-fenced-generation".to_vec();
sys.persist_and_set(old_fenced).await.unwrap();
sys.metadata_map.write().await.remove(fenced_bucket);
sys.lazy_load_lock_probe.store(true, std::sync::atomic::Ordering::Relaxed);
let (loaded, _) = sys.get_config(fenced_bucket).await.unwrap();
sys.lazy_load_lock_probe.store(false, std::sync::atomic::Ordering::Relaxed);
assert_eq!(loaded.policy_config_json, b"old-fenced-generation".to_vec());
// (e) A refresh-load miss for a bucket that still exists must not
// replace an existing entry with a fabricated default.
let mut kept = BucketMetadata::new("kept-bucket"); let mut kept = BucketMetadata::new("kept-bucket");
kept.policy_config_json = b"kept-marker".to_vec(); kept.policy_config_json = b"kept-marker".to_vec();
sys.set("kept-bucket".to_string(), Arc::new(kept)).await; sys.set("kept-bucket".to_string(), Arc::new(kept)).await;
for dir in &dirs {
std::fs::create_dir_all(dir.path().join("kept-bucket")).expect("kept bucket directory should be created");
}
let mut failed = HashSet::new(); let mut failed = HashSet::new();
let refresh_targets = vec!["kept-bucket".to_string()]; let refresh_targets = vec!["kept-bucket".to_string()];
sys.concurrent_load(&refresh_targets, &mut failed).await; sys.concurrent_load(&refresh_targets, &mut failed, MetadataLoadMode::Refresh)
.await;
let kept = sys let kept = sys
.get("kept-bucket") .get("kept-bucket")
.await .await
@@ -1106,6 +1470,143 @@ mod tests {
b"kept-marker".to_vec(), b"kept-marker".to_vec(),
"a fabricated refresh default must not replace real metadata" "a fabricated refresh default must not replace real metadata"
); );
// (f) A stale cache entry for a physically deleted bucket must be
// removed without recreating the bucket during periodic refresh.
sys.set("deleted-bucket".to_string(), Arc::new(BucketMetadata::new("deleted-bucket")))
.await;
let deleted_targets = vec!["deleted-bucket".to_string()];
sys.concurrent_load(&deleted_targets, &mut failed, MetadataLoadMode::Refresh)
.await;
assert!(
dirs.iter().all(|dir| !dir.path().join("deleted-bucket").exists()),
"periodic refresh must not recreate a bucket from stale cached metadata"
);
assert!(
sys.get("deleted-bucket").await.is_err(),
"periodic refresh must remove stale cached metadata"
);
// (g) Persisted metadata left behind after physical deletion must not
// keep the deleted generation authoritative during refresh.
let mut deleted_persisted = BucketMetadata::new("deleted-persisted-bucket");
deleted_persisted.policy_config_json = b"stale-persisted-generation".to_vec();
sys.persist_and_set(deleted_persisted)
.await
.expect("stale metadata should persist");
sys.concurrent_load(&["deleted-persisted-bucket".to_string()], &mut failed, MetadataLoadMode::Refresh)
.await;
assert!(
sys.get("deleted-persisted-bucket").await.is_err(),
"refresh must remove persisted metadata for a physically absent bucket"
);
assert!(
sys.reload_from_store("deleted-persisted-bucket").await.is_err(),
"peer reload must not publish stale metadata for an absent bucket"
);
// (h) Metadata loaded for an old bucket generation must not replace
// metadata published by delete plus same-name recreation.
let old = Arc::new(BucketMetadata::new("recreated-bucket"));
sys.set("recreated-bucket".to_string(), Arc::clone(&old)).await;
let mut recreated = BucketMetadata::new("recreated-bucket");
recreated.policy_config_json = b"new-generation".to_vec();
sys.set("recreated-bucket".to_string(), Arc::new(recreated)).await;
let mut stale = BucketMetadata::new("recreated-bucket");
stale.policy_config_json = b"old-generation".to_vec();
let namespace_lock = sys
.api
.new_ns_lock("recreated-bucket", "recreated-bucket")
.await
.expect("namespace lock should be created");
let namespace_guard = namespace_lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await
.expect("namespace read lock should be acquired");
sys.publish_if_unchanged("recreated-bucket", Some(&old), stale, true, &namespace_guard)
.await
.expect("stale refresh publish should be fenced");
assert_eq!(
sys.get("recreated-bucket")
.await
.expect("recreated bucket metadata should remain cached")
.policy_config_json,
b"new-generation".to_vec()
);
// (i) Refresh retains periodic healing for a partially missing bucket.
sys.set("partial-bucket".to_string(), Arc::new(BucketMetadata::new("partial-bucket")))
.await;
for dir in dirs.iter().take(3) {
std::fs::create_dir_all(dir.path().join("partial-bucket")).unwrap();
}
sys.concurrent_load(&["partial-bucket".to_string()], &mut failed, MetadataLoadMode::Refresh)
.await;
assert!(dirs.iter().all(|dir| dir.path().join("partial-bucket").is_dir()));
// (j) A stale initial snapshot must not recreate a bucket that has
// disappeared from every disk.
let stale_initial_targets = vec!["deleted-initial-bucket".to_string()];
sys.concurrent_load(&stale_initial_targets, &mut failed, MetadataLoadMode::Initial)
.await;
assert!(
dirs.iter().all(|dir| !dir.path().join("deleted-initial-bucket").exists()),
"initial load must not recreate a bucket absent from every disk"
);
// (k) Initial discovery still heals a bucket present on part of the
// storage topology.
for dir in dirs.iter().take(3) {
std::fs::create_dir_all(dir.path().join("initial-bucket"))
.expect("partial initial bucket directory should be created");
}
let initial_targets = vec!["initial-bucket".to_string()];
sys.concurrent_load(&initial_targets, &mut failed, MetadataLoadMode::Initial)
.await;
assert!(
dirs.iter().all(|dir| dir.path().join("initial-bucket").is_dir()),
"initial load must heal buckets discovered from storage"
);
}
#[tokio::test]
async fn metadata_publish_locks_are_isolated_per_bucket() {
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = Arc::new(BucketMetadataSys::new(ecstore));
let first_lock = sys.metadata_publish_lock("blocked-bucket");
let same_lock = sys.metadata_publish_lock("blocked-bucket");
assert!(Arc::ptr_eq(&first_lock, &same_lock));
let first_guard = first_lock.lock().await;
let cancelled_waiter_lock = sys.metadata_publish_lock("blocked-bucket");
let cancelled_waiter = tokio::spawn(async move {
let _guard = cancelled_waiter_lock.lock_owned().await;
});
tokio::task::yield_now().await;
cancelled_waiter.abort();
assert!(cancelled_waiter.await.unwrap_err().is_cancelled());
let other_bucket = "other-bucket".to_string();
let other_lock = sys.metadata_publish_lock(&other_bucket);
assert!(!Arc::ptr_eq(&first_lock, &other_lock));
timeout(
Duration::from_secs(1),
sys.set(other_bucket.clone(), Arc::new(BucketMetadata::new(&other_bucket))),
)
.await
.expect("one bucket publish lock must not block another bucket");
assert!(sys.get(&other_bucket).await.is_ok());
drop(first_guard);
drop(first_lock);
drop(same_lock);
drop(other_lock);
assert!(
sys.metadata_publish_locks
.locks
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.is_empty()
);
} }
#[tokio::test] #[tokio::test]
@@ -1130,12 +1631,18 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn update_config_with_persists_tagging_rewrite_across_disk_reload() { async fn update_config_with_persists_tagging_rewrite_across_disk_reload() {
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG; use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
use crate::storage_api_contracts::bucket::MakeBucketOptions;
use s3s::dto::Tag; use s3s::dto::Tag;
let (_dirs, ecstore) = isolated_store_over_temp_disks().await; let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let mut sys = BucketMetadataSys::new(ecstore);
let bucket = "swift-tagging-bucket"; let bucket = "swift-tagging-bucket";
ecstore
.peer_sys
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket volume should be created");
let mut sys = BucketMetadataSys::new(ecstore);
sys.persist_and_set(BucketMetadata::new(bucket)) sys.persist_and_set(BucketMetadata::new(bucket))
.await .await
.expect("initial metadata should persist"); .expect("initial metadata should persist");
@@ -1240,6 +1747,74 @@ mod tests {
assert_eq!(tags.tag_set.len(), WRITERS, "every concurrent rewrite must survive: {tags:?}"); assert_eq!(tags.tag_set.len(), WRITERS, "every concurrent rewrite must survive: {tags:?}");
} }
/// Pins the peer reload-notification contract (`reload_from_store`, the
/// LoadBucketMetadata RPC path): only metadata actually read from
/// persisted storage enters the cache. A load miss errors out and leaves
/// the cache untouched — it must neither install a fabricated default
/// for an unknown bucket nor replace an existing entry, since a
/// transient ConfigNotFound during the notification would otherwise
/// downgrade a lock-enabled bucket to an authoritative "no Object Lock"
/// default and disable the batch-delete retention gate on this peer.
#[tokio::test]
async fn peer_reload_never_caches_fabricated_defaults_as_authoritative() {
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore.clone());
// (a) Miss with no cached entry: the reload fails and installs nothing.
let err = sys
.reload_from_store("reload-bucket")
.await
.expect_err("a reload miss must be reported to the notifying peer");
assert!(
err.to_string().contains("no persisted bucket metadata readable"),
"the miss must surface through the dedicated non-persisted branch, got: {err}"
);
assert!(
sys.get("reload-bucket").await.is_err(),
"a reload miss must not install a fabricated default"
);
// (b) Miss with an existing entry: the reload fails and the entry
// (standing in for a lock-enabled bucket's metadata) survives intact.
let mut kept = BucketMetadata::new("reload-bucket");
kept.object_lock_config_xml = b"<ObjectLockConfiguration/>".to_vec();
sys.set("reload-bucket".to_string(), Arc::new(kept)).await;
assert!(sys.reload_from_store("reload-bucket").await.is_err());
let cached = sys
.get("reload-bucket")
.await
.expect("existing entry must survive a reload miss");
assert_eq!(
cached.object_lock_config_xml,
b"<ObjectLockConfiguration/>".to_vec(),
"a reload miss must not replace the cached entry with a fabricated default"
);
// (c) Persisted metadata reloads over a stale cached entry: the
// reload converges the cache to disk truth.
let mut persisted = BucketMetadata::new("reload-bucket");
persisted.policy_config_json = b"persisted-marker".to_vec();
sys.persist_and_set(persisted).await.expect("metadata should persist");
for dir in &dirs {
std::fs::create_dir_all(dir.path().join("reload-bucket")).expect("physical bucket should exist before reload");
}
let mut stale = BucketMetadata::new("reload-bucket");
stale.policy_config_json = b"stale-cache-marker".to_vec();
sys.set("reload-bucket".to_string(), Arc::new(stale)).await;
sys.reload_from_store("reload-bucket")
.await
.expect("persisted metadata should reload");
let cached = sys
.get("reload-bucket")
.await
.expect("reloaded persisted metadata must be cached");
assert_eq!(
cached.policy_config_json,
b"persisted-marker".to_vec(),
"a reload must converge the cache to the persisted disk state"
);
}
fn target(bucket: &str, id: &str) -> BucketTarget { fn target(bucket: &str, id: &str) -> BucketTarget {
BucketTarget { BucketTarget {
source_bucket: bucket.to_string(), source_bucket: bucket.to_string(),
+135 -19
View File
@@ -14,7 +14,7 @@
use crate::cluster::rpc::http_auth::RPC_CONTENT_SHA256_HEADER; use crate::cluster::rpc::http_auth::RPC_CONTENT_SHA256_HEADER;
use crate::cluster::rpc::{gen_tonic_signature_headers, normalize_tonic_rpc_audience}; use crate::cluster::rpc::{gen_tonic_signature_headers, normalize_tonic_rpc_audience};
use crate::disk::error::{DiskError, Error as DiskErrorType}; use crate::disk::error::{DiskError, Error as DiskErrorType, RpcStatusError};
use crate::runtime::sources as runtime_sources; use crate::runtime::sources as runtime_sources;
use http::Uri; use http::Uri;
use rustfs_protos::{ use rustfs_protos::{
@@ -107,10 +107,79 @@ pub async fn node_service_time_out_client_no_auth(
node_service_time_out_client(addr, TonicInterceptor::NoOp(NoOpInterceptor)).await node_service_time_out_client(addr, TonicInterceptor::NoOp(NoOpInterceptor)).await
} }
/// The typed `tonic::Status` an internode RPC failure was converted from, if
/// this error carries one.
pub(crate) fn embedded_tonic_status(io_err: &std::io::Error) -> Option<&tonic::Status> {
io_err.get_ref()?.downcast_ref::<RpcStatusError>().map(RpcStatusError::status)
}
/// Decide whether a gRPC status reports a peer we cannot currently reach,
/// rather than an application outcome from a live peer.
///
/// `Unavailable` is the one code that means "no service behind this channel":
/// the client transport raises it when the connection is broken, and the
/// server's own not-ready gates use it deliberately.
///
/// `Unknown` is the client transport's escape hatch for a cause it could not
/// map to a code — tower's "Service was not ready: <cause>", an h2 error with
/// no gRPC mapping. Our handlers never return it, so there its message is the
/// only evidence available and the anchored needles decide.
///
/// Every other code is an answer from a live peer and is never a transport
/// failure, whatever its message says. That distinction is the point of
/// classifying by code: a peer relaying its own downstream trouble as
/// `Internal("connection refused ...")`, or a handler interpolating a local
/// `io::Error` into `Status::internal`, answered us perfectly well. Marking it
/// offline over that text is the bug this classification replaces. Likewise a
/// `Cancelled` "Timeout expired" from the per-RPC channel deadline means the
/// peer is slow, not gone; gating it would turn load into a partition.
pub(crate) fn is_network_like_status(status: &tonic::Status) -> bool {
match status.code() {
tonic::Code::Unavailable => true,
tonic::Code::Unknown => message_has_network_needle(&status.to_string()),
_ => false,
}
}
/// Substring fallback for failures that only exist as text: dial errors
/// wrapped by `get_client`, remote `error_info` payloads, and statuses
/// flattened through `format!`. Needles must stay anchored to transport
/// context — a bare word like "unavailable" also matches application text
/// (e.g. a bucket named "unavailable-logs") and would take a healthy peer
/// offline.
pub(crate) fn message_has_network_needle(message: &str) -> bool {
let message = message.to_ascii_lowercase();
[
"temporarily offline",
"transport error",
// tonic >= 0.14 renders Code::Unavailable as
// `code: 'The service is currently unavailable'`.
"code: 'the service is currently unavailable'",
// RUSTFS_COMPAT_TODO(tonic-013-status-render): releases up to 1.0.0-alpha.38 shipped tonic 0.13, which rendered the same status as `status: Unavailable`, and peers relay that text in error_info. Remove after the minimum supported RustFS peer version ships tonic >= 0.14.
"status: unavailable",
"error trying to connect",
"connection refused",
"connection reset",
"broken pipe",
"not connected",
"unexpected eof",
"timed out",
"deadline has elapsed",
"connection closed",
"connection aborted",
"tcp connect error",
]
.iter()
.any(|needle| message.contains(needle))
}
pub(crate) fn is_network_like_disk_error(err: &DiskErrorType) -> bool { pub(crate) fn is_network_like_disk_error(err: &DiskErrorType) -> bool {
match err { match err {
DiskError::Timeout => true, DiskError::Timeout => true,
DiskError::Io(io_err) => { DiskError::Io(io_err) => {
if let Some(status) = embedded_tonic_status(io_err) {
return is_network_like_status(status);
}
if matches!( if matches!(
io_err.kind(), io_err.kind(),
ErrorKind::TimedOut ErrorKind::TimedOut
@@ -124,24 +193,7 @@ pub(crate) fn is_network_like_disk_error(err: &DiskErrorType) -> bool {
return true; return true;
} }
let message = io_err.to_string().to_ascii_lowercase(); message_has_network_needle(&io_err.to_string())
[
"transport error",
"unavailable",
"error trying to connect",
"connection refused",
"connection reset",
"broken pipe",
"not connected",
"unexpected eof",
"timed out",
"deadline has elapsed",
"connection closed",
"connection aborted",
"tcp connect error",
]
.iter()
.any(|needle| message.contains(needle))
} }
_ => false, _ => false,
} }
@@ -269,6 +321,70 @@ mod tests {
let _ = provider.shutdown(); let _ = provider.shutdown();
} }
#[test]
fn network_like_disk_error_uses_typed_status_code() {
// Transport-level Unavailable statuses justify retry/eviction.
assert!(is_network_like_disk_error(&DiskError::from(tonic::Status::unavailable(
"storage layer is not initialized"
))));
// Application statuses from a live peer must not look network-like,
// even when their message contains transport-sounding words.
assert!(!is_network_like_disk_error(&DiskError::from(tonic::Status::internal(
"failed to heal bucket \"unavailable-logs\""
))));
assert!(!is_network_like_disk_error(&DiskError::from(tonic::Status::unauthenticated(
"No valid auth token"
))));
// A slow peer that blew the per-RPC deadline is still answering.
assert!(!is_network_like_disk_error(&DiskError::from(tonic::Status::cancelled("Timeout expired"))));
}
#[test]
fn embedded_tonic_status_is_recovered_across_error_conversions() {
// DiskError and StorageError share one wrapper, so a status keeps its
// typed classification whichever error it was converted into first.
let from_storage: DiskErrorType = crate::error::Error::from(tonic::Status::unavailable("peer gone")).into();
let DiskError::Io(io_err) = &from_storage else {
panic!("status-derived disk error should stay an Io error");
};
assert_eq!(embedded_tonic_status(io_err).map(|status| status.code()), Some(tonic::Code::Unavailable));
let from_disk = crate::error::Error::from(DiskError::from(tonic::Status::unavailable("peer gone")));
let crate::error::Error::Io(io_err) = &from_disk else {
panic!("status-derived storage error should stay an Io error");
};
assert_eq!(embedded_tonic_status(io_err).map(|status| status.code()), Some(tonic::Code::Unavailable));
}
#[test]
fn network_like_disk_error_ignores_transport_words_in_application_statuses() {
// Same contract as the peer client: a status the peer answered with
// is not a transport failure, so it must not drive a reconnect even
// when its message describes one.
assert!(!is_network_like_disk_error(&DiskError::from(tonic::Status::internal(
"connection refused while dialing downstream backend"
))));
assert!(!is_network_like_disk_error(&DiskError::from(tonic::Status::unauthenticated(
"connection reset while validating token"
))));
}
#[test]
fn network_like_disk_error_requires_anchored_unavailable_needle() {
// Regression: a bare "unavailable" needle used to match application
// text such as a bucket name.
assert!(!is_network_like_disk_error(&DiskError::other("bucket \"unavailable-logs\" not found")));
// Anchored renderings of a flattened Unavailable status still match.
assert!(is_network_like_disk_error(&DiskError::other(
"code: 'The service is currently unavailable', message: \"peer gone\""
)));
assert!(is_network_like_disk_error(&DiskError::other(
"status: Unavailable, message: \"peer gone\""
)));
assert!(is_network_like_disk_error(&DiskError::other("connection refused")));
assert!(!is_network_like_disk_error(&DiskError::FileNotFound));
}
#[test] #[test]
fn test_signature_interceptor_keeps_auth_headers() { fn test_signature_interceptor_keeps_auth_headers() {
ensure_test_rpc_secret(); ensure_test_rpc_secret();
+95 -2
View File
@@ -443,6 +443,16 @@ pub fn set_tonic_canonical_body_digest<T>(request: &mut tonic::Request<T>, canon
Ok(()) Ok(())
} }
pub fn set_tonic_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(
request: &mut tonic::Request<T>,
) -> std::io::Result<()> {
let canonical_body = request
.get_ref()
.canonical_body()
.map_err(|_| std::io::Error::other("RPC mutation body length cannot be represented"))?;
set_tonic_canonical_body_digest(request, &canonical_body)
}
pub fn verify_tonic_canonical_body_digest<T>(request: &tonic::Request<T>, canonical_body: &[u8]) -> std::io::Result<()> { pub fn verify_tonic_canonical_body_digest<T>(request: &tonic::Request<T>, canonical_body: &[u8]) -> std::io::Result<()> {
let version = request let version = request
.metadata() .metadata()
@@ -466,7 +476,7 @@ pub fn verify_tonic_canonical_body_digest<T>(request: &tonic::Request<T>, canoni
Ok(()) Ok(())
} }
/// Verify a mutating disk RPC's canonical body digest with a rolling-upgrade fallback. /// Verify a mutating RPC's canonical body digest with a rolling-upgrade fallback.
/// ///
/// When the request carries a real (non-`UNSIGNED-PAYLOAD`) content SHA-256 it is verified exactly /// When the request carries a real (non-`UNSIGNED-PAYLOAD`) content SHA-256 it is verified exactly
/// like [`verify_tonic_canonical_body_digest`]. The digest value is a member of the signed v2 /// like [`verify_tonic_canonical_body_digest`]. The digest value is a member of the signed v2
@@ -497,7 +507,7 @@ fn verify_tonic_mutation_body_digest_with_strictness<T>(
Some(digest) if digest != UNSIGNED_PAYLOAD => verify_tonic_canonical_body_digest(request, canonical_body), Some(digest) if digest != UNSIGNED_PAYLOAD => verify_tonic_canonical_body_digest(request, canonical_body),
_ => { _ => {
// RUSTFS_COMPAT_TODO(disk-mutation-body-digest): accept digestless peers during rolling upgrades. Remove after the // RUSTFS_COMPAT_TODO(disk-mutation-body-digest): accept digestless peers during rolling upgrades. Remove after the
// minimum supported RustFS peer version body-binds every mutating disk RPC. // minimum supported RustFS peer version body-binds every mutating RPC.
if strict { if strict {
return Err(std::io::Error::other("RPC mutation requires a body-bound v2 signature")); return Err(std::io::Error::other("RPC mutation requires a body-bound v2 signature"));
} }
@@ -677,11 +687,28 @@ mod tests {
use crate::cluster::rpc::context_propagation::REQUEST_ID_HEADER; use crate::cluster::rpc::context_propagation::REQUEST_ID_HEADER;
use crate::runtime::sources as runtime_sources; use crate::runtime::sources as runtime_sources;
use http::{HeaderMap, Method}; use http::{HeaderMap, Method};
use rustfs_protos::{
CanonicalMutationBody as _, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
proto_gen::node_service::{Mss, SignalServiceRequest},
};
use std::collections::HashMap;
use std::io::{self, Write}; use std::io::{self, Write};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::fmt::MakeWriter;
fn signal_service_request(signal: &str, sub_system: &str, dry_run: &str) -> SignalServiceRequest {
SignalServiceRequest {
vars: Some(Mss {
value: HashMap::from([
(PEER_RESTSIGNAL.to_string(), signal.to_string()),
(PEER_RESTSUB_SYS.to_string(), sub_system.to_string()),
(PEER_RESTDRY_RUN.to_string(), dry_run.to_string()),
]),
}),
}
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct CapturedLogs { struct CapturedLogs {
buffer: Arc<Mutex<Vec<u8>>>, buffer: Arc<Mutex<Vec<u8>>>,
@@ -1596,6 +1623,72 @@ mod tests {
assert_eq!(stripped.to_string(), "RPC content SHA-256 mismatch"); assert_eq!(stripped.to_string(), "RPC content SHA-256 mismatch");
} }
#[test]
fn signal_service_mutation_contract_rejects_tampering_and_replay() {
ensure_test_rpc_secret();
let body = signal_service_request("2", "scanner", "false")
.canonical_body()
.expect("small signal request should encode");
let mut request = tonic::Request::new(());
set_tonic_canonical_body_digest(&mut request, &body).expect("canonical body digest should be attached");
let content_sha256 = request
.metadata()
.get(RPC_CONTENT_SHA256_HEADER)
.and_then(|value| value.to_str().ok());
let headers = gen_tonic_signature_headers("node-a:9000", "node_service.NodeService", "SignalService", content_sha256)
.expect("body-bound auth headers should build");
request.metadata_mut().as_mut().extend(headers.clone());
assert!(
verify_tonic_rpc_signature("node-a:9000", "/node_service.NodeService/SignalService", &headers).is_ok(),
"the first body-bound signal request must authenticate"
);
assert!(verify_tonic_mutation_body_digest(&request, &body).is_ok());
let tampered = signal_service_request("1", "scanner", "false")
.canonical_body()
.expect("small signal request should encode");
let error = verify_tonic_mutation_body_digest(&request, &tampered)
.expect_err("changing the signal must invalidate the signed digest");
assert_eq!(error.to_string(), "RPC content SHA-256 mismatch");
let replay = verify_tonic_rpc_signature("node-a:9000", "/node_service.NodeService/SignalService", &headers)
.expect_err("reusing the signal nonce must fail");
assert_eq!(replay.to_string(), "RPC request replay detected");
}
#[test]
#[serial_test::serial(rpc_body_digest_fallback_counter)]
fn signal_service_mutation_contract_preserves_rollout_fallback_and_strictness() {
let body = signal_service_request("2", "scanner", "false")
.canonical_body()
.expect("small signal request should encode");
let before = global_internode_metrics().snapshot().body_digest_fallback_total;
let digestless = tonic::Request::new(());
assert!(
verify_tonic_mutation_body_digest_with_strictness(&digestless, &body, false).is_ok(),
"old peers must remain compatible while the rollout gate is open"
);
assert_eq!(
global_internode_metrics().snapshot().body_digest_fallback_total,
before + 1,
"accepted digestless signal requests must be visible in the fallback metric"
);
let error = verify_tonic_mutation_body_digest_with_strictness(&digestless, &body, true)
.expect_err("strict mode must reject a digestless signal request");
assert_eq!(error.to_string(), "RPC mutation requires a body-bound v2 signature");
let mut bound = tonic::Request::new(());
set_tonic_canonical_body_digest(&mut bound, &body).expect("canonical body digest should be attached");
bound
.metadata_mut()
.as_mut()
.insert(RPC_AUTH_VERSION_HEADER, HeaderValue::from_static(RPC_AUTH_VERSION_V2));
assert!(verify_tonic_mutation_body_digest_with_strictness(&bound, &body, true).is_ok());
}
#[test] #[test]
fn nonce_cache_rejects_replay_after_wall_clock_regression() { fn nonce_cache_rejects_replay_after_wall_clock_regression() {
let now = Instant::now(); let now = Instant::now();
+3 -3
View File
@@ -30,9 +30,9 @@ pub use client::{
}; };
pub use http_auth::{ pub use http_auth::{
TONIC_RPC_PREFIX, build_auth_headers, gen_signature_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience, TONIC_RPC_PREFIX, build_auth_headers, gen_signature_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience,
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof, verify_ns_scanner_capability, set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof,
verify_rpc_signature, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_ns_scanner_capability, verify_rpc_signature, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest,
verify_tonic_rpc_signature, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
}; };
#[cfg(test)] #[cfg(test)]
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport; pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
@@ -13,10 +13,10 @@
// limitations under the License. // limitations under the License.
use crate::cluster::rpc::client::{ use crate::cluster::rpc::client::{
TonicInterceptor, gen_tonic_signature_interceptor, heal_control_time_out_client, node_service_time_out_client, TonicInterceptor, embedded_tonic_status, gen_tonic_signature_interceptor, heal_control_time_out_client,
tier_mutation_control_time_out_client, is_network_like_status, message_has_network_needle, node_service_time_out_client, tier_mutation_control_time_out_client,
}; };
use crate::cluster::rpc::{set_tonic_canonical_body_digest, verify_tonic_rpc_response_proof}; use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, verify_tonic_rpc_response_proof};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::storage_api_contracts::internode::{ use crate::storage_api_contracts::internode::{
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION, SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION,
@@ -50,6 +50,7 @@ use rustfs_protos::proto_gen::node_service::{
TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient, tier_mutation_control_service_client::TierMutationControlServiceClient,
}; };
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection}; use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection};
use rustfs_utils::XHost; use rustfs_utils::XHost;
use serde::{Deserialize, Serialize as _}; use serde::{Deserialize, Serialize as _};
@@ -69,9 +70,6 @@ use tonic::transport::Channel;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use uuid::Uuid; use uuid::Uuid;
pub const PEER_RESTSIGNAL: &str = "signal";
pub const PEER_RESTSUB_SYS: &str = "sub-sys";
pub const PEER_RESTDRY_RUN: &str = "dry-run";
pub const SERVICE_SIGNAL_REFRESH_CONFIG: u64 = 1; pub const SERVICE_SIGNAL_REFRESH_CONFIG: u64 = 1;
pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2; pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2;
const BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024; const BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
@@ -471,26 +469,21 @@ impl PeerRestClient {
self.offline.store(false, Ordering::Release); self.offline.store(false, Ordering::Release);
} }
/// Whether this failure means the peer is unreachable, so it should be
/// gated offline and its connection evicted.
///
/// RPC failures are classified by their typed gRPC code first
/// (`is_network_like_status`); an application error from a live peer must
/// never take it offline no matter what its message says. The substring
/// fallback only covers failures that exist purely as text, such as the
/// dial errors `get_client` wraps.
fn is_network_like_error(err: &Error) -> bool { fn is_network_like_error(err: &Error) -> bool {
let message = err.to_string().to_ascii_lowercase(); if let Error::Io(io_err) = err
[ && let Some(status) = embedded_tonic_status(io_err)
"temporarily offline", {
"transport error", return is_network_like_status(status);
"unavailable", }
"error trying to connect", message_has_network_needle(&err.to_string())
"connection refused",
"connection reset",
"broken pipe",
"not connected",
"unexpected eof",
"timed out",
"deadline has elapsed",
"connection closed",
"connection aborted",
"tcp connect error",
]
.iter()
.any(|needle| message.contains(needle))
} }
fn mark_offline_and_spawn_recovery(&self) { fn mark_offline_and_spawn_recovery(&self) {
@@ -1165,10 +1158,11 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(LoadBucketMetadataRequest { let mut request = Request::new(LoadBucketMetadataRequest {
bucket: bucket.to_string(), bucket: bucket.to_string(),
scanner_maintenance_change, scanner_maintenance_change,
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.load_bucket_metadata(request).await?.into_inner(); let response = client.load_bucket_metadata(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1188,9 +1182,10 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(DeleteBucketMetadataRequest { let mut request = Request::new(DeleteBucketMetadataRequest {
bucket: bucket.to_string(), bucket: bucket.to_string(),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.delete_bucket_metadata(request).await?.into_inner(); let response = client.delete_bucket_metadata(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1210,9 +1205,10 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(DeletePolicyRequest { let mut request = Request::new(DeletePolicyRequest {
policy_name: policy.to_string(), policy_name: policy.to_string(),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.delete_policy(request).await?.into_inner(); let response = client.delete_policy(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1232,9 +1228,10 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(LoadPolicyRequest { let mut request = Request::new(LoadPolicyRequest {
policy_name: policy.to_string(), policy_name: policy.to_string(),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.load_policy(request).await?.into_inner(); let response = client.load_policy(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1254,11 +1251,12 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(LoadPolicyMappingRequest { let mut request = Request::new(LoadPolicyMappingRequest {
user_or_group: user_or_group.to_string(), user_or_group: user_or_group.to_string(),
user_type, user_type,
is_group, is_group,
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.load_policy_mapping(request).await?.into_inner(); let response = client.load_policy_mapping(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1278,9 +1276,10 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(DeleteUserRequest { let mut request = Request::new(DeleteUserRequest {
access_key: access_key.to_string(), access_key: access_key.to_string(),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.delete_user(request).await?.into_inner(); let response = client.delete_user(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1300,9 +1299,10 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(DeleteServiceAccountRequest { let mut request = Request::new(DeleteServiceAccountRequest {
access_key: access_key.to_string(), access_key: access_key.to_string(),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.delete_service_account(request).await?.into_inner(); let response = client.delete_service_account(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1322,10 +1322,11 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(LoadUserRequest { let mut request = Request::new(LoadUserRequest {
access_key: access_key.to_string(), access_key: access_key.to_string(),
temp, temp,
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.load_user(request).await?.into_inner(); let response = client.load_user(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1345,9 +1346,10 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(LoadServiceAccountRequest { let mut request = Request::new(LoadServiceAccountRequest {
access_key: access_key.to_string(), access_key: access_key.to_string(),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.load_service_account(request).await?.into_inner(); let response = client.load_service_account(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1367,9 +1369,10 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(LoadGroupRequest { let mut request = Request::new(LoadGroupRequest {
group: group.to_string(), group: group.to_string(),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.load_group(request).await?.into_inner(); let response = client.load_group(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1389,7 +1392,8 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(ReloadSiteReplicationConfigRequest {}); let mut request = Request::new(ReloadSiteReplicationConfigRequest {});
set_tonic_mutation_body_digest(&mut request)?;
let response = client.reload_site_replication_config(request).await?.into_inner(); let response = client.reload_site_replication_config(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1413,9 +1417,10 @@ impl PeerRestClient {
vars.insert(PEER_RESTSIGNAL.to_string(), sig.to_string()); vars.insert(PEER_RESTSIGNAL.to_string(), sig.to_string());
vars.insert(PEER_RESTSUB_SYS.to_string(), sub_sys.to_string()); vars.insert(PEER_RESTSUB_SYS.to_string(), sub_sys.to_string());
vars.insert(PEER_RESTDRY_RUN.to_string(), dry_run.to_string()); vars.insert(PEER_RESTDRY_RUN.to_string(), dry_run.to_string());
let request = Request::new(SignalServiceRequest { let mut request = Request::new(SignalServiceRequest {
vars: Some(Mss { value: vars }), vars: Some(Mss { value: vars }),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.signal_service(request).await?.into_inner(); let response = client.signal_service(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1484,7 +1489,8 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(ReloadPoolMetaRequest {}); let mut request = Request::new(ReloadPoolMetaRequest {});
set_tonic_mutation_body_digest(&mut request)?;
let response = client.reload_pool_meta(request).await?.into_inner(); let response = client.reload_pool_meta(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1505,9 +1511,10 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(StopRebalanceRequest { let mut request = Request::new(StopRebalanceRequest {
expected_rebalance_id: expected_rebalance_id.unwrap_or_default().to_string(), expected_rebalance_id: expected_rebalance_id.unwrap_or_default().to_string(),
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.stop_rebalance(request).await?.into_inner(); let response = client.stop_rebalance(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1528,7 +1535,8 @@ impl PeerRestClient {
self.finalize_result( self.finalize_result(
async { async {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(LoadRebalanceMetaRequest { start_rebalance }); let mut request = Request::new(LoadRebalanceMetaRequest { start_rebalance });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.load_rebalance_meta(request).await?.into_inner(); let response = client.load_rebalance_meta(request).await?.into_inner();
@@ -1567,7 +1575,8 @@ impl PeerRestClient {
}) })
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(StartDecommissionRequest { pool_indices }); let mut request = Request::new(StartDecommissionRequest { pool_indices });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.start_decommission(request).await?.into_inner(); let response = client.start_decommission(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1590,7 +1599,8 @@ impl PeerRestClient {
let pool_index = u32::try_from(pool_index) let pool_index = u32::try_from(pool_index)
.map_err(|_| Error::other(format!("decommission pool index {pool_index} exceeds RPC range")))?; .map_err(|_| Error::other(format!("decommission pool index {pool_index} exceeds RPC range")))?;
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(CancelDecommissionRequest { pool_index }); let mut request = Request::new(CancelDecommissionRequest { pool_index });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.cancel_decommission(request).await?.into_inner(); let response = client.cancel_decommission(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1613,7 +1623,8 @@ impl PeerRestClient {
let pool_index = u32::try_from(pool_index) let pool_index = u32::try_from(pool_index)
.map_err(|_| Error::other(format!("decommission pool index {pool_index} exceeds RPC range")))?; .map_err(|_| Error::other(format!("decommission pool index {pool_index} exceeds RPC range")))?;
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(ClearDecommissionRequest { pool_index }); let mut request = Request::new(ClearDecommissionRequest { pool_index });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.clear_decommission(request).await?.into_inner(); let response = client.clear_decommission(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1633,10 +1644,14 @@ impl PeerRestClient {
pub async fn load_transition_tier_config(&self) -> Result<()> { pub async fn load_transition_tier_config(&self) -> Result<()> {
match self.load_transition_tier_config_outcome().await { match self.load_transition_tier_config_outcome().await {
TierConfigReloadOutcome::Success => Ok(()), TierConfigReloadOutcome::Success => Ok(()),
TierConfigReloadOutcome::TransientReconnect(err) | TierConfigReloadOutcome::TransientRetrySameChannel(err) => { // Only a reconnect-class failure says anything about the channel.
self.finalize_result(Err(err)).await // `finalize_result` marks the peer offline and evicts its connection
} // whenever the message looks network-like, and a peer that answered
TierConfigReloadOutcome::Terminal(err) => Err(err), // and rejected the apply can easily report one ("release RPC failed:
// transport error"). Routing those through here would gate a healthy,
// responding peer out of every unrelated RPC.
TierConfigReloadOutcome::TransientReconnect(err) => self.finalize_result(Err(err)).await,
TierConfigReloadOutcome::TransientRetrySameChannel(err) | TierConfigReloadOutcome::Terminal(err) => Err(err),
} }
} }
@@ -1662,6 +1677,9 @@ impl PeerRestClient {
Err(err) => return tier_config_reload_connection_outcome(err), Err(err) => return tier_config_reload_connection_outcome(err),
}; };
let mut request = Request::new(LoadTransitionTierConfigRequest {}); let mut request = Request::new(LoadTransitionTierConfigRequest {});
if let Err(err) = set_tonic_mutation_body_digest(&mut request) {
return TierConfigReloadOutcome::Terminal(Error::other(err));
}
request.set_timeout(rustfs_protos::heal_control_execution_timeout()); request.set_timeout(rustfs_protos::heal_control_execution_timeout());
let response = match client.load_transition_tier_config(request).await { let response = match client.load_transition_tier_config(request).await {
@@ -1702,39 +1720,37 @@ fn tier_config_reload_connection_outcome(err: Error) -> TierConfigReloadOutcome
} }
fn is_tier_config_reload_connection_failure(err: &Error) -> bool { fn is_tier_config_reload_connection_failure(err: &Error) -> bool {
let message = err.to_string().to_ascii_lowercase(); let message = err.to_string();
// A bare "unavailable" is only trusted inside the local dial-failure
// wrapper from `get_client`, never in application text.
if message if message
.to_ascii_lowercase()
.split_once("can not get client, err:") .split_once("can not get client, err:")
.is_some_and(|(_, local_error)| local_error.contains("unavailable")) .is_some_and(|(_, local_error)| local_error.contains("unavailable"))
{ {
return true; return true;
} }
[ message_has_network_needle(&message)
"temporarily offline",
"transport error",
"error trying to connect",
"connection refused",
"connection reset",
"connection closed",
"connection aborted",
"broken pipe",
"not connected",
"unexpected eof",
"timed out",
"deadline has elapsed",
"tcp connect error",
]
.iter()
.any(|needle| message.contains(needle))
} }
/// Classifies a reload the peer answered but refused to apply.
///
/// The peer replied, so the channel is healthy and only the remote apply
/// failed. Those failures are transient by nature: the reload reads the tier
/// mutation intents and takes the distributed tier-config lock, both of which
/// fail while any other node is restarting or while the lock quorum is briefly
/// disturbed. Retiring the worker on the first such rejection leaves that peer
/// pinned to the old configuration with nothing left to heal it, so it answers
/// `TierNotFound` for a tier the rest of the cluster already committed until a
/// second admin mutation happens to spawn a fresh worker.
///
/// Convergence is the whole point of this path, so a rejection is retried on
/// the same channel. The worker's exponential backoff caps the cost at one
/// reload every `TIER_CONFIG_RELOAD_RETRY_CAP`, and `Terminal` stays reachable
/// for transport and gRPC status failures, which is where a genuinely
/// unrecoverable peer surfaces.
fn tier_config_reload_remote_failure(error_info: Option<String>) -> TierConfigReloadOutcome { fn tier_config_reload_remote_failure(error_info: Option<String>) -> TierConfigReloadOutcome {
let error_info = error_info.unwrap_or_default(); TierConfigReloadOutcome::TransientRetrySameChannel(Error::other(error_info.unwrap_or_default()))
if matches!(error_info.as_str(), "errServerNotInitialized" | "ServerNotInitialized") {
TierConfigReloadOutcome::TransientRetrySameChannel(Error::other(error_info))
} else {
TierConfigReloadOutcome::Terminal(Error::other(error_info))
}
} }
fn tier_config_reload_status_outcome(status: tonic::Status) -> TierConfigReloadOutcome { fn tier_config_reload_status_outcome(status: tonic::Status) -> TierConfigReloadOutcome {
@@ -1744,6 +1760,14 @@ fn tier_config_reload_status_outcome(status: tonic::Status) -> TierConfigReloadO
TierConfigReloadOutcome::TransientReconnect(status.into()) TierConfigReloadOutcome::TransientReconnect(status.into())
} else if status.code() == Code::Unknown && status.message().starts_with("Service was not ready:") { } else if status.code() == Code::Unknown && status.message().starts_with("Service was not ready:") {
TierConfigReloadOutcome::TransientRetrySameChannel(status.into()) TierConfigReloadOutcome::TransientRetrySameChannel(status.into())
} else if status.code() == Code::Unknown
&& is_tier_config_reload_connection_failure(&Error::other(status.message().to_string()))
{
// tonic reports a connection dropped mid-call as `Unknown` carrying the
// transport error text rather than as `Unavailable`, which is what a peer
// restarting under an active mutation produces. Reconnect and retry, so
// the restart does not permanently retire this peer's reload worker.
TierConfigReloadOutcome::TransientReconnect(status.into())
} else { } else {
TierConfigReloadOutcome::Terminal(status.into()) TierConfigReloadOutcome::Terminal(status.into())
} }
@@ -2151,6 +2175,126 @@ mod tests {
assert!(!PeerRestClient::is_network_like_error(&Error::NotImplemented)); assert!(!PeerRestClient::is_network_like_error(&Error::NotImplemented));
} }
#[test]
fn peer_rest_client_network_classifier_uses_typed_status_code() {
// The one code that means "nothing is answering on this channel".
assert!(PeerRestClient::is_network_like_error(&Error::from(tonic::Status::unavailable(
"storage layer is not initialized"
))));
// Application statuses from a live peer must not mark it offline,
// even when their message contains transport-sounding words.
assert!(!PeerRestClient::is_network_like_error(&Error::from(tonic::Status::internal(
"failed to reload metadata for bucket \"unavailable-logs\""
))));
assert!(!PeerRestClient::is_network_like_error(&Error::from(tonic::Status::unauthenticated(
"No valid auth token"
))));
// A request-budget expiry answered by a live peer is an application
// outcome, not a transport failure.
assert!(!PeerRestClient::is_network_like_error(&Error::from(tonic::Status::deadline_exceeded(
"heal control request expired"
))));
// Unknown is the transport's escape hatch for a cause it could not
// map, and our handlers never return it, so there the text decides.
assert!(PeerRestClient::is_network_like_error(&Error::from(tonic::Status::unknown(
"Service was not ready: transport error"
))));
assert!(!PeerRestClient::is_network_like_error(&Error::from(tonic::Status::unknown(
"peer response unknown"
))));
}
#[test]
fn peer_rest_client_network_classifier_ignores_transport_words_in_application_statuses() {
// The reason classification reads the code rather than the text: a
// peer that answers is reachable, even when what it says describes a
// connection failure of its own. A handler interpolating a local
// io::Error into Status::internal, or relaying trouble with its own
// downstream, must not cost us the channel to a healthy peer.
for status in [
tonic::Status::internal("connection refused while dialing downstream backend"),
tonic::Status::internal("write failed: broken pipe"),
tonic::Status::unauthenticated("connection reset while validating token"),
tonic::Status::failed_precondition("scanner lease timed out"),
tonic::Status::deadline_exceeded("heal control request timed out"),
] {
let rendered = status.to_string();
assert!(
!PeerRestClient::is_network_like_error(&Error::from(status)),
"an answered application status must not mark the peer offline: {rendered}"
);
}
}
#[test]
fn peer_rest_client_network_classifier_keeps_slow_peers_online() {
// The per-RPC channel deadline (RUSTFS_INTERNODE_RPC_TIMEOUT, 30s)
// surfaces as Cancelled "Timeout expired" carrying the transport
// cause as its source. A peer that is merely slow must stay online:
// gating it would spend a full recovery cycle fast-failing every RPC
// to a host that is still answering, turning load into a partition.
let timeout_status = tonic::Status::cancelled("Timeout expired");
assert!(!PeerRestClient::is_network_like_error(&Error::from(timeout_status)));
let sourced = tonic::Status::from_error(Box::new(std::io::Error::other("Timeout expired")));
assert!(
std::error::Error::source(&sourced).is_some(),
"the transport builds this status through Status::from_error, which attaches the cause"
);
assert!(!PeerRestClient::is_network_like_error(&Error::from(sourced)));
}
#[test]
fn rpc_status_errors_keep_their_rendering_and_hide_peer_metadata() {
let err = Error::from(tonic::Status::unavailable("peer gone"));
assert_eq!(
err.to_string(),
"Io error: code: 'The service is currently unavailable', message: \"peer gone\""
);
// tonic's own Debug prints the MetadataMap, i.e. every response header
// the peer sent; those must not reach a log through this error.
let mut status = tonic::Status::unavailable("peer gone");
status
.metadata_mut()
.insert("authorization", "Bearer secret".parse().expect("valid header value"));
let rendered = format!("{:?}", Error::from(status));
assert!(!rendered.contains("Bearer secret"), "{rendered}");
assert!(!rendered.contains("MetadataMap"), "{rendered}");
}
#[test]
fn peer_rest_client_network_classifier_ignores_application_text_containing_unavailable() {
// Regression: a bare "unavailable" needle used to match application
// strings like these and take a healthy peer offline.
assert!(!PeerRestClient::is_network_like_error(&Error::other(
"peer replication statistics provider is unavailable"
)));
assert!(!PeerRestClient::is_network_like_error(&Error::other(
"bucket \"unavailable-logs\" not found"
)));
// Anchored renderings of a flattened Unavailable status still match:
// tonic >= 0.14 form ...
assert!(PeerRestClient::is_network_like_error(&Error::other(
"peer tier mutation commit RPC failed: code: 'The service is currently unavailable', message: \"peer gone\""
)));
// ... which is only anchored as long as tonic renders Unavailable this
// way. A tonic bump that reworded it leaves the typed path correct but
// this needle stale, so pin the coupling rather than discover it in a
// partition.
assert!(
tonic::Status::unavailable("peer gone")
.to_string()
.to_ascii_lowercase()
.contains("code: 'the service is currently unavailable'"),
"tonic reworded Code::Unavailable; update the anchored needle"
);
// ... and the tonic <= 0.13 form peers may relay in error_info.
assert!(PeerRestClient::is_network_like_error(&Error::other(
"peer tier mutation commit RPC failed: status: Unavailable, message: \"peer gone\""
)));
}
#[test] #[test]
fn tier_config_reload_outcome_keeps_tonic_and_remote_errors_typed() { fn tier_config_reload_outcome_keeps_tonic_and_remote_errors_typed() {
assert!(matches!( assert!(matches!(
@@ -2177,9 +2321,12 @@ mod tests {
tier_config_reload_status_outcome(tonic::Status::cancelled("request cancelled")), tier_config_reload_status_outcome(tonic::Status::cancelled("request cancelled")),
TierConfigReloadOutcome::Terminal(_) TierConfigReloadOutcome::Terminal(_)
)); ));
// A peer that answered and then refused the apply is retried rather than
// retired: the channel is healthy, so the rejection reflects remote state
// that the next attempt can find healed.
assert!(matches!( assert!(matches!(
tier_config_reload_remote_failure(Some("backend unavailable".to_string())), tier_config_reload_remote_failure(Some("backend unavailable".to_string())),
TierConfigReloadOutcome::Terminal(_) TierConfigReloadOutcome::TransientRetrySameChannel(_)
)); ));
assert!(matches!( assert!(matches!(
tier_config_reload_remote_failure(Some("errServerNotInitialized".to_string())), tier_config_reload_remote_failure(Some("errServerNotInitialized".to_string())),
@@ -2193,6 +2340,58 @@ mod tests {
tier_config_reload_connection_outcome(Error::other("can not get client, err: connection unavailable")), tier_config_reload_connection_outcome(Error::other("can not get client, err: connection unavailable")),
TierConfigReloadOutcome::TransientReconnect(_) TierConfigReloadOutcome::TransientReconnect(_)
)); ));
// The bare word is trusted only to the right of the dial-failure
// prefix, not anywhere in the message.
assert!(matches!(
tier_config_reload_connection_outcome(Error::other(
"bucket unavailable-logs rejected it, then: can not get client, err: some other reason"
)),
TierConfigReloadOutcome::Terminal(_)
));
}
/// A tier mutation issued while another node restarts must still converge on
/// the nodes that stayed up. Those peers answer the reload RPC and reject the
/// apply, because reloading reads the tier mutation intents and takes the
/// distributed tier-config lock while the lock quorum is still disturbed.
/// Classifying those rejections as terminal retired the reload worker on its
/// first attempt and pinned the peer to the previous configuration, so it
/// served `TierNotFound` for an already-committed tier until an unrelated
/// second admin mutation spawned a new worker.
#[test]
fn tier_config_reload_retries_peers_that_reject_the_apply_mid_restart() {
for error_info in [
"Lock acquisition timeout for resource '.rustfs.sys/config/tier-config.bin.lock' after 5s",
"Resource '.rustfs.sys/config/tier-config.bin.lock' is already locked by node-3",
"Internal error: release RPC failed: transport error",
"save_config_with_opts: err: PreconditionFailed",
"erasure read quorum",
] {
assert!(
matches!(
tier_config_reload_remote_failure(Some(error_info.to_string())),
TierConfigReloadOutcome::TransientRetrySameChannel(_)
),
"a peer that rejected the apply must stay retryable so it converges: {error_info}"
);
}
// An absent error message is still a rejection, not a reason to stop.
assert!(matches!(
tier_config_reload_remote_failure(None),
TierConfigReloadOutcome::TransientRetrySameChannel(_)
));
// tonic surfaces a connection dropped mid-call as `Unknown`, not `Unavailable`.
assert!(matches!(
tier_config_reload_status_outcome(tonic::Status::unknown("transport error")),
TierConfigReloadOutcome::TransientReconnect(_)
));
// An `Unknown` that is not transport-shaped stays terminal.
assert!(matches!(
tier_config_reload_status_outcome(tonic::Status::unknown("peer response unknown")),
TierConfigReloadOutcome::Terminal(_)
));
} }
#[tokio::test] #[tokio::test]
@@ -2495,6 +2694,39 @@ mod tests {
assert!(!client.offline.load(Ordering::Acquire)); assert!(!client.offline.load(Ordering::Acquire));
} }
#[tokio::test]
async fn peer_rest_client_finalize_result_keeps_online_for_app_errors_mentioning_unavailable() {
// Regression: application error text containing "unavailable" (a
// remote error_info payload, or a bucket named "unavailable-logs" in
// a typed application status) must not take a healthy peer offline.
let client = test_peer_client();
let err = client
.finalize_result::<()>(Err(Error::other("peer replication statistics provider is unavailable")))
.await
.expect_err("application error should still be returned");
assert!(err.to_string().contains("provider is unavailable"));
assert!(!client.offline.load(Ordering::Acquire));
let err = client
.finalize_result::<()>(Err(Error::from(tonic::Status::internal(
"failed to reload metadata for bucket \"unavailable-logs\"",
))))
.await
.expect_err("application status should still be returned");
assert!(err.to_string().contains("unavailable-logs"));
assert!(!client.offline.load(Ordering::Acquire));
}
#[tokio::test]
async fn peer_rest_client_finalize_result_marks_offline_for_typed_unavailable_status() {
let client = test_peer_client();
client
.finalize_result::<()>(Err(Error::from(tonic::Status::unavailable("storage layer is not initialized"))))
.await
.expect_err("network error should still be returned");
assert!(client.offline.load(Ordering::Acquire));
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn peer_rest_recovery_probe_logs_keep_request_id_span_context() { async fn peer_rest_recovery_probe_logs_keep_request_id_span_context() {
let logs = CapturedLogs::default(); let logs = CapturedLogs::default();
@@ -16,6 +16,7 @@ use crate::bucket::metadata_sys;
use crate::cluster::rpc::client::{ use crate::cluster::rpc::client::{
TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error, node_service_time_out_client, TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error, node_service_time_out_client,
}; };
use crate::cluster::rpc::set_tonic_mutation_body_digest;
use crate::disk::error::DiskError; use crate::disk::error::DiskError;
use crate::disk::error::{Error, Result}; use crate::disk::error::{Error, Result};
use crate::disk::error_reduce::{BUCKET_OP_IGNORED_ERRS, is_all_buckets_not_found, reduce_write_quorum_errs}; use crate::disk::error_reduce::{BUCKET_OP_IGNORED_ERRS, is_all_buckets_not_found, reduce_write_quorum_errs};
@@ -89,6 +90,22 @@ fn reduce_pool_write_quorum_errs(per_pool_errs: &[Option<Error>]) -> Option<Erro
reduce_write_quorum_errs(per_pool_errs, BUCKET_OP_IGNORED_ERRS, pool_write_quorum(per_pool_errs.len())) reduce_write_quorum_errs(per_pool_errs, BUCKET_OP_IGNORED_ERRS, pool_write_quorum(per_pool_errs.len()))
} }
fn resolve_heal_bucket_mode(opts: &mut HealOpts, pool_errs: &[Option<Error>]) -> Result<()> {
if opts.recreate {
return Ok(());
}
if let Some(err) = pool_errs
.iter()
.flatten()
.find(|err| **err != Error::DiskNotFound && **err != Error::VolumeNotFound)
{
return Err(err.clone());
}
opts.remove = is_all_buckets_not_found(pool_errs);
opts.recreate = !opts.remove;
Ok(())
}
#[async_trait] #[async_trait]
pub trait PeerS3Client: Debug + Sync + Send + 'static { pub trait PeerS3Client: Debug + Sync + Send + 'static {
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem>; async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem>;
@@ -159,10 +176,7 @@ impl S3PeerSys {
pool_errs.push(reduce_pool_write_quorum_errs(&per_pool_errs)); pool_errs.push(reduce_pool_write_quorum_errs(&per_pool_errs));
} }
if !opts.recreate { resolve_heal_bucket_mode(&mut opts, &pool_errs)?;
opts.remove = is_all_buckets_not_found(&pool_errs);
opts.recreate = !opts.remove;
}
let mut futures = Vec::new(); let mut futures = Vec::new();
let heal_bucket_results = Arc::new(RwLock::new(vec![HealResultItem::default(); self.clients.len()])); let heal_bucket_results = Arc::new(RwLock::new(vec![HealResultItem::default(); self.clients.len()]));
@@ -917,10 +931,11 @@ impl PeerS3Client for RemotePeerS3Client {
|| async { || async {
let options: String = serde_json::to_string(opts)?; let options: String = serde_json::to_string(opts)?;
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(HealBucketRequest { let mut request = Request::new(HealBucketRequest {
bucket: bucket.to_string(), bucket: bucket.to_string(),
options, options,
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.heal_bucket(request).await?.into_inner(); let response = client.heal_bucket(request).await?.into_inner();
if !response.success { if !response.success {
return if let Some(err) = response.error { return if let Some(err) = response.error {
@@ -973,10 +988,11 @@ impl PeerS3Client for RemotePeerS3Client {
|| async { || async {
let options = serde_json::to_string(opts)?; let options = serde_json::to_string(opts)?;
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(MakeBucketRequest { let mut request = Request::new(MakeBucketRequest {
name: bucket.to_string(), name: bucket.to_string(),
options, options,
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.make_bucket(request).await?.into_inner(); let response = client.make_bucket(request).await?.into_inner();
if !response.success { if !response.success {
@@ -1027,10 +1043,11 @@ impl PeerS3Client for RemotePeerS3Client {
let options = serde_json::to_string(opts)?; let options = serde_json::to_string(opts)?;
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let request = Request::new(DeleteBucketRequest { let mut request = Request::new(DeleteBucketRequest {
bucket: bucket.to_string(), bucket: bucket.to_string(),
options, options,
}); });
set_tonic_mutation_body_digest(&mut request)?;
let response = client.delete_bucket(request).await?.into_inner(); let response = client.delete_bucket(request).await?.into_inner();
if !response.success { if !response.success {
return if let Some(err) = response.error { return if let Some(err) = response.error {
@@ -1618,6 +1635,30 @@ mod tests {
assert_eq!(err, Error::VolumeExists); assert_eq!(err, Error::VolumeExists);
} }
#[test]
fn heal_bucket_mode_fails_closed_on_incomplete_topology() {
let mut opts = HealOpts::default();
assert_eq!(
resolve_heal_bucket_mode(&mut opts, &[Some(Error::ErasureWriteQuorum)]),
Err(Error::ErasureWriteQuorum)
);
assert!(!opts.recreate);
assert!(!opts.remove);
}
#[test]
fn heal_bucket_mode_distinguishes_deleted_and_partial_buckets() {
let mut deleted = HealOpts::default();
resolve_heal_bucket_mode(&mut deleted, &[Some(Error::VolumeNotFound)]).unwrap();
assert!(deleted.remove);
assert!(!deleted.recreate);
let mut partial = HealOpts::default();
resolve_heal_bucket_mode(&mut partial, &[None, Some(Error::VolumeNotFound)]).unwrap();
assert!(!partial.remove);
assert!(partial.recreate);
}
#[tokio::test] #[tokio::test]
async fn test_make_bucket_reduces_quorum_by_pool_participants() { async fn test_make_bucket_reduces_quorum_by_pool_participants() {
let peer_sys = S3PeerSys { let peer_sys = S3PeerSys {
+119 -3
View File
@@ -25,7 +25,7 @@ use crate::disk::error::{Error, Result};
use crate::disk::{ use crate::disk::{
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, DeleteOptions, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, DeleteOptions, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation,
DiskOption, FileInfoVersions, FileReader, FileWriter, PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions, DiskOption, FileInfoVersions, FileReader, FileWriter, PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions,
RenameDataResp, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one, RenameDataResp, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
disk_store::{ disk_store::{
DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, SKIP_IF_SUCCESS_BEFORE, DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, SKIP_IF_SUCCESS_BEFORE,
get_drive_active_check_interval, get_drive_active_check_timeout, get_drive_disk_info_timeout, get_drive_list_dir_timeout, get_drive_active_check_interval, get_drive_active_check_timeout, get_drive_disk_info_timeout, get_drive_list_dir_timeout,
@@ -50,8 +50,9 @@ use rustfs_protos::proto_gen::node_service::{
DeleteVersionRequest, DeleteVersionsRequest, DeleteVolumeRequest, DiskInfoRequest, ListDirRequest, ListVolumesRequest, DeleteVersionRequest, DeleteVersionsRequest, DeleteVolumeRequest, DiskInfoRequest, ListDirRequest, ListVolumesRequest,
MakeVolumeRequest, MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest, ReadMetadataRequest, MakeVolumeRequest, MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest, ReadMetadataRequest,
ReadMultipleRequest, ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RenameDataRequest, ReadMultipleRequest, ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RenameDataRequest,
RenameFileRequest, SettlePartTransactionRequest, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, RenameFileRequest, SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest,
WriteAllRequest, WriteMetadataRequest, node_service_client::NodeServiceClient, SnapshotLeaseRequest, SnapshotLeaseResponse, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest,
WriteMetadataRequest, node_service_client::NodeServiceClient,
}; };
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use std::{ use std::{
@@ -100,6 +101,18 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_REMOTE_DISK: &str = "remote_disk"; const LOG_SUBSYSTEM_REMOTE_DISK: &str = "remote_disk";
const EVENT_REMOTE_DISK_HEALTH: &str = "remote_disk_health"; const EVENT_REMOTE_DISK_HEALTH: &str = "remote_disk_health";
const EVENT_REMOTE_DISK_RPC: &str = "remote_disk_rpc"; const EVENT_REMOTE_DISK_RPC: &str = "remote_disk_rpc";
const SNAPSHOT_LEASE_PROTOCOL_VERSION: u32 = 1;
pub const REMOTE_SNAPSHOT_LEASE_TTL: Duration = Duration::from_secs(60);
fn snapshot_lease_token_from_response(response: SnapshotLeaseResponse) -> Result<SnapshotLeaseToken> {
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
if response.protocol_version != SNAPSHOT_LEASE_PROTOCOL_VERSION {
return Err(Error::other("remote snapshot lease protocol is incompatible"));
}
SnapshotLeaseToken::from_slice(&response.token)
}
/// Bind a mutating disk RPC to its canonical body: the digest lands in the request metadata, and /// Bind a mutating disk RPC to its canonical body: the digest lands in the request metadata, and
/// the signing interceptor folds it (plus a replay-protected nonce) into the v2 signature scope /// the signing interceptor folds it (plus a replay-protected nonce) into the v2 signature scope
@@ -1784,6 +1797,81 @@ impl DiskAPI for RemoteDisk {
.await .await
} }
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
self.execute_with_timeout(
|| async {
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let mut request = Request::new(SnapshotLeaseRequest {
disk: self.endpoint.to_string(),
volume: volume.to_string(),
path: path.to_string(),
ttl_ms: u64::try_from(REMOTE_SNAPSHOT_LEASE_TTL.as_millis())
.map_err(|_| Error::other("snapshot lease TTL cannot be represented"))?,
});
let canonical_body = rustfs_protos::canonical_snapshot_lease_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "acquire_snapshot_lease")?;
let response = client.acquire_snapshot_lease(request).await?.into_inner();
snapshot_lease_token_from_response(response)
},
get_max_timeout_duration(),
)
.await
}
async fn renew_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> Result<SnapshotLeaseToken> {
self.execute_with_timeout(
|| async {
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let mut request = Request::new(SnapshotLeaseRenewRequest {
disk: self.endpoint.to_string(),
volume: volume.to_string(),
path: path.to_string(),
token: token.as_bytes().to_vec().into(),
ttl_ms: u64::try_from(REMOTE_SNAPSHOT_LEASE_TTL.as_millis())
.map_err(|_| Error::other("snapshot lease TTL cannot be represented"))?,
});
let canonical_body = rustfs_protos::canonical_snapshot_lease_renew_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "renew_snapshot_lease")?;
let response = client.renew_snapshot_lease(request).await?.into_inner();
snapshot_lease_token_from_response(response)
},
get_max_timeout_duration(),
)
.await
}
async fn release_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> Result<()> {
self.execute_with_timeout(
|| async {
let mut client = self
.get_client()
.await
.map_err(|err| Error::other(format!("can not get client, err: {err}")))?;
let mut request = Request::new(SnapshotLeaseReleaseRequest {
disk: self.endpoint.to_string(),
volume: volume.to_string(),
path: path.to_string(),
token: token.as_bytes().to_vec().into(),
});
let canonical_body = rustfs_protos::canonical_snapshot_lease_release_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "release_snapshot_lease")?;
let response = client.release_snapshot_lease(request).await?.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
Ok(())
},
get_max_timeout_duration(),
)
.await
}
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> { async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> {
trace!( trace!(
@@ -2930,6 +3018,34 @@ mod tests {
static INIT: Once = Once::new(); static INIT: Once = Once::new();
#[test]
fn snapshot_lease_response_requires_current_protocol_and_valid_token() {
let token = SnapshotLeaseToken::new();
let response = SnapshotLeaseResponse {
success: true,
token: token.as_bytes().to_vec().into(),
protocol_version: SNAPSHOT_LEASE_PROTOCOL_VERSION,
error: None,
};
assert_eq!(snapshot_lease_token_from_response(response).unwrap(), token);
let incompatible = SnapshotLeaseResponse {
success: true,
token: token.as_bytes().to_vec().into(),
protocol_version: SNAPSHOT_LEASE_PROTOCOL_VERSION + 1,
error: None,
};
assert!(snapshot_lease_token_from_response(incompatible).is_err());
let malformed = SnapshotLeaseResponse {
success: true,
token: Bytes::from_static(b"not-a-uuid"),
protocol_version: SNAPSHOT_LEASE_PROTOCOL_VERSION,
error: None,
};
assert!(snapshot_lease_token_from_response(malformed).is_err());
}
#[test] #[test]
fn list_volumes_decode_rejects_a_malformed_entry() { fn list_volumes_decode_rejects_a_malformed_entry() {
let valid = serde_json::to_string(&VolumeInfo { let valid = serde_json::to_string(&VolumeInfo {
@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
use crate::cluster::rpc::client::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client}; use crate::cluster::rpc::client::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
use crate::cluster::rpc::set_tonic_mutation_body_digest;
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use rustfs_lock::{ use rustfs_lock::{
@@ -313,10 +314,11 @@ impl LockClient for RemoteClient {
info!("remote acquire_exclusive for {}", request.resource); info!("remote acquire_exclusive for {}", request.resource);
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let resource_summary = request.resource.to_string(); let resource_summary = request.resource.to_string();
let req = Request::new(GenerallyLockRequest { let mut req = Request::new(GenerallyLockRequest {
args: serde_json::to_string(&request) args: serde_json::to_string(&request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?, .map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
}); });
set_tonic_mutation_body_digest(&mut req)?;
let resp = match self.execute_rpc("lock", &resource_summary, client.lock(req)).await { let resp = match self.execute_rpc("lock", &resource_summary, client.lock(req)).await {
Ok(resp) => resp.into_inner(), Ok(resp) => resp.into_inner(),
@@ -347,7 +349,7 @@ impl LockClient for RemoteClient {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let resource_summary = Self::summarize_resources(requests); let resource_summary = Self::summarize_resources(requests);
let req = Request::new(BatchGenerallyLockRequest { let mut req = Request::new(BatchGenerallyLockRequest {
args: requests args: requests
.iter() .iter()
.map(|request| { .map(|request| {
@@ -355,6 +357,7 @@ impl LockClient for RemoteClient {
}) })
.collect::<Result<Vec<_>>>()?, .collect::<Result<Vec<_>>>()?,
}); });
set_tonic_mutation_body_digest(&mut req)?;
let resp = match self let resp = match self
.execute_rpc("lock_batch", &resource_summary, client.lock_batch(req)) .execute_rpc("lock_batch", &resource_summary, client.lock_batch(req))
@@ -395,7 +398,8 @@ impl LockClient for RemoteClient {
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?; .map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let resource_summary = unlock_request.resource.to_string(); let resource_summary = unlock_request.resource.to_string();
let req = Request::new(GenerallyLockRequest { args: request_string }); let mut req = Request::new(GenerallyLockRequest { args: request_string });
set_tonic_mutation_body_digest(&mut req)?;
let resp = self let resp = self
.execute_rpc("release", &resource_summary, client.un_lock(req)) .execute_rpc("release", &resource_summary, client.un_lock(req))
.await? .await?
@@ -414,7 +418,7 @@ impl LockClient for RemoteClient {
let unlock_requests = lock_ids.iter().map(Self::create_unlock_request).collect::<Vec<_>>(); let unlock_requests = lock_ids.iter().map(Self::create_unlock_request).collect::<Vec<_>>();
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let resource_summary = Self::summarize_resources(&unlock_requests); let resource_summary = Self::summarize_resources(&unlock_requests);
let req = Request::new(BatchGenerallyLockRequest { let mut req = Request::new(BatchGenerallyLockRequest {
args: unlock_requests args: unlock_requests
.iter() .iter()
.map(|request| { .map(|request| {
@@ -422,6 +426,7 @@ impl LockClient for RemoteClient {
}) })
.collect::<Result<Vec<_>>>()?, .collect::<Result<Vec<_>>>()?,
}); });
set_tonic_mutation_body_digest(&mut req)?;
let resp = self let resp = self
.execute_rpc("release_batch", &resource_summary, client.un_lock_batch(req)) .execute_rpc("release_batch", &resource_summary, client.un_lock_batch(req))
@@ -440,10 +445,11 @@ impl LockClient for RemoteClient {
let refresh_request = Self::create_unlock_request(lock_id); let refresh_request = Self::create_unlock_request(lock_id);
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let resource_summary = refresh_request.resource.to_string(); let resource_summary = refresh_request.resource.to_string();
let req = Request::new(GenerallyLockRequest { let mut req = Request::new(GenerallyLockRequest {
args: serde_json::to_string(&refresh_request) args: serde_json::to_string(&refresh_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?, .map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
}); });
set_tonic_mutation_body_digest(&mut req)?;
let resp = self let resp = self
.execute_rpc("refresh", &resource_summary, client.refresh(req)) .execute_rpc("refresh", &resource_summary, client.refresh(req))
.await? .await?
@@ -459,10 +465,11 @@ impl LockClient for RemoteClient {
let force_request = Self::create_unlock_request(lock_id); let force_request = Self::create_unlock_request(lock_id);
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
let resource_summary = force_request.resource.to_string(); let resource_summary = force_request.resource.to_string();
let req = Request::new(GenerallyLockRequest { let mut req = Request::new(GenerallyLockRequest {
args: serde_json::to_string(&force_request) args: serde_json::to_string(&force_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?, .map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
}); });
set_tonic_mutation_body_digest(&mut req)?;
let resp = self let resp = self
.execute_rpc("force_release", &resource_summary, client.force_un_lock(req)) .execute_rpc("force_release", &resource_summary, client.force_un_lock(req))
.await? .await?
@@ -483,10 +490,11 @@ impl LockClient for RemoteClient {
let mut client = self.get_client().await?; let mut client = self.get_client().await?;
// Try to acquire a very short-lived lock to test availability // Try to acquire a very short-lived lock to test availability
let req = Request::new(GenerallyLockRequest { let mut req = Request::new(GenerallyLockRequest {
args: serde_json::to_string(&status_request) args: serde_json::to_string(&status_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?, .map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
}); });
set_tonic_mutation_body_digest(&mut req)?;
// Try exclusive lock first with very short timeout // Try exclusive lock first with very short timeout
let resp = match self.execute_rpc("check_status", &resource_summary, client.lock(req)).await { let resp = match self.execute_rpc("check_status", &resource_summary, client.lock(req)).await {
@@ -497,10 +505,11 @@ impl LockClient for RemoteClient {
if resp.success { if resp.success {
// If we successfully acquired the lock, the resource was free. // If we successfully acquired the lock, the resource was free.
// Immediately release it on a best-effort basis. // Immediately release it on a best-effort basis.
let release_req = Request::new(GenerallyLockRequest { let mut release_req = Request::new(GenerallyLockRequest {
args: serde_json::to_string(&status_request) args: serde_json::to_string(&status_request)
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?, .map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
}); });
set_tonic_mutation_body_digest(&mut release_req)?;
let _ = self let _ = self
.execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req)) .execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req))
.await; .await;
+1
View File
@@ -2543,6 +2543,7 @@ mod tests {
data_dir: None, data_dir: None,
delete_marker: false, delete_marker: false,
transitioned_object: Default::default(), transitioned_object: Default::default(),
transition_version_state: Default::default(),
restore_ongoing: false, restore_ongoing: false,
restore_expires: None, restore_expires: None,
user_tags: Arc::new(String::new()), user_tags: Arc::new(String::new()),
+35 -3
View File
@@ -13,9 +13,9 @@
// limitations under the License. // limitations under the License.
use crate::disk::{ use crate::disk::{
CheckPartsResp, DeleteOptions, DiskAPI, DiskError, DiskInfo, DiskInfoOptions, DiskLocation, Endpoint, Error, CheckPartsResp, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskError, DiskInfo, DiskInfoOptions, DiskLocation, Endpoint,
FileInfoVersions, MmapCopyStageMetrics, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, Result, Error, FileInfoVersions, MmapCopyStageMetrics, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, Result,
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
health_state::{ health_state::{
RuntimeDriveHealthState, classify_drive_recovery, get_drive_returning_probe_interval, RuntimeDriveHealthState, classify_drive_recovery, get_drive_returning_probe_interval,
get_drive_returning_success_threshold, get_drive_suspect_failure_threshold, record_drive_offline_duration, get_drive_returning_success_threshold, get_drive_suspect_failure_threshold, record_drive_offline_duration,
@@ -1349,6 +1349,38 @@ impl DiskAPI for LocalDiskWrapper {
.await .await
} }
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
self.track_disk_health(
|| async { self.disk.acquire_snapshot_lease(volume, path).await },
get_max_timeout_duration(),
)
.await
}
async fn release_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> Result<()> {
self.track_disk_health(
|| async { self.disk.release_snapshot_lease(volume, path, token).await },
get_max_timeout_duration(),
)
.await
}
async fn renew_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> Result<SnapshotLeaseToken> {
self.track_disk_health(
|| async { self.disk.renew_snapshot_lease(volume, path, token).await },
get_max_timeout_duration(),
)
.await
}
async fn delete_data_dir(&self, volume: &str, path: &str, opts: DeleteOptions) -> Result<DataDirDeleteStatus> {
self.track_disk_health(
|| async { self.disk.delete_data_dir(volume, path, opts).await },
get_max_timeout_duration(),
)
.await
}
async fn write_metadata(&self, org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> { async fn write_metadata(&self, org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> {
self.track_disk_health( self.track_disk_health(
|| async { self.disk.write_metadata(org_volume, volume, path, fi).await }, || async { self.disk.write_metadata(org_volume, volume, path, fi).await },
+46 -1
View File
@@ -337,9 +337,54 @@ impl From<DiskError> for std::io::Error {
} }
} }
/// The single in-band representation of a failed internode RPC: it carries the
/// typed `tonic::Status` so failure classifiers can read the gRPC code instead
/// of substring-matching the rendered message (see `is_network_like_status`).
/// Both `DiskError` and `StorageError` wrap statuses in this type, so one
/// downcast recovers the status regardless of which error the status was
/// converted into first.
pub(crate) struct RpcStatusError(tonic::Status);
impl RpcStatusError {
pub(crate) fn status(&self) -> &tonic::Status {
&self.0
}
}
impl From<tonic::Status> for RpcStatusError {
fn from(status: tonic::Status) -> Self {
Self(status)
}
}
impl std::fmt::Display for RpcStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
/// `tonic::Status`'s own `Debug` prints its `MetadataMap`, i.e. every response
/// header and trailer the peer sent. Those are remote-controlled and can carry
/// credentials injected by a proxy in front of the peer, so keep them out of
/// anything that reaches a log.
impl std::fmt::Debug for RpcStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RpcStatusError")
.field("code", &self.0.code())
.field("message", &self.0.message())
.finish()
}
}
impl StdError for RpcStatusError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(&self.0)
}
}
impl From<tonic::Status> for DiskError { impl From<tonic::Status> for DiskError {
fn from(e: tonic::Status) -> Self { fn from(e: tonic::Status) -> Self {
DiskError::other(e.message().to_string()) DiskError::Io(io::Error::other(RpcStatusError(e)))
} }
} }
File diff suppressed because it is too large Load Diff
+74
View File
@@ -72,6 +72,39 @@ pub type DiskStore = Arc<Disk>;
pub type FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>; pub type FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>;
pub type FileWriter = Box<dyn AsyncWrite + Send + Sync + Unpin>; pub type FileWriter = Box<dyn AsyncWrite + Send + Sync + Unpin>;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub struct SnapshotLeaseToken(Uuid);
impl SnapshotLeaseToken {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn from_slice(bytes: &[u8]) -> Result<Self> {
let uuid = Uuid::from_slice(bytes).map_err(|_| Error::other("invalid snapshot lease token"))?;
if uuid.is_nil() {
return Err(Error::other("invalid snapshot lease token"));
}
Ok(Self(uuid))
}
pub fn as_bytes(&self) -> &[u8; 16] {
self.0.as_bytes()
}
}
impl Default for SnapshotLeaseToken {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DataDirDeleteStatus {
Deleted,
Deferred,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PartTransactionAction { pub enum PartTransactionAction {
Commit, Commit,
@@ -249,6 +282,34 @@ impl DiskAPI for Disk {
} }
} }
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
match self {
Disk::Local(local_disk) => local_disk.acquire_snapshot_lease(volume, path).await,
Disk::Remote(remote_disk) => remote_disk.acquire_snapshot_lease(volume, path).await,
}
}
async fn release_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> Result<()> {
match self {
Disk::Local(local_disk) => local_disk.release_snapshot_lease(volume, path, token).await,
Disk::Remote(remote_disk) => remote_disk.release_snapshot_lease(volume, path, token).await,
}
}
async fn renew_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> Result<SnapshotLeaseToken> {
match self {
Disk::Local(local_disk) => local_disk.renew_snapshot_lease(volume, path, token).await,
Disk::Remote(remote_disk) => remote_disk.renew_snapshot_lease(volume, path, token).await,
}
}
async fn delete_data_dir(&self, volume: &str, path: &str, opts: DeleteOptions) -> Result<DataDirDeleteStatus> {
match self {
Disk::Local(local_disk) => local_disk.delete_data_dir(volume, path, opts).await,
Disk::Remote(remote_disk) => remote_disk.delete_data_dir(volume, path, opts).await,
}
}
#[tracing::instrument(level = "trace", skip_all)] #[tracing::instrument(level = "trace", skip_all)]
async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> { async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> {
match self { match self {
@@ -646,6 +707,19 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
) -> Result<()>; ) -> Result<()>;
async fn delete_versions(&self, volume: &str, versions: Vec<FileInfoVersions>, opts: DeleteOptions) -> Vec<Option<Error>>; async fn delete_versions(&self, volume: &str, versions: Vec<FileInfoVersions>, opts: DeleteOptions) -> Vec<Option<Error>>;
async fn delete_paths(&self, volume: &str, paths: &[String]) -> Result<()>; async fn delete_paths(&self, volume: &str, paths: &[String]) -> Result<()>;
async fn acquire_snapshot_lease(&self, _volume: &str, _path: &str) -> Result<SnapshotLeaseToken> {
Err(Error::other("snapshot leases are not supported by this disk"))
}
async fn release_snapshot_lease(&self, _volume: &str, _path: &str, _token: SnapshotLeaseToken) -> Result<()> {
Err(Error::other("snapshot leases are not supported by this disk"))
}
async fn renew_snapshot_lease(&self, _volume: &str, _path: &str, _token: SnapshotLeaseToken) -> Result<SnapshotLeaseToken> {
Err(Error::other("snapshot leases are not supported by this disk"))
}
async fn delete_data_dir(&self, volume: &str, path: &str, opts: DeleteOptions) -> Result<DataDirDeleteStatus> {
self.delete(volume, path, opts).await?;
Ok(DataDirDeleteStatus::Deleted)
}
async fn write_metadata(&self, org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()>; async fn write_metadata(&self, org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()>;
async fn update_metadata(&self, volume: &str, path: &str, fi: FileInfo, opts: &UpdateMetadataOpts) -> Result<()>; async fn update_metadata(&self, volume: &str, path: &str, fi: FileInfo, opts: &UpdateMetadataOpts) -> Result<()>;
async fn read_version( async fn read_version(
+5 -1
View File
@@ -818,7 +818,11 @@ impl From<s3s::xml::DeError> for Error {
impl From<tonic::Status> for Error { impl From<tonic::Status> for Error {
fn from(e: tonic::Status) -> Self { fn from(e: tonic::Status) -> Self {
Error::other(e.to_string()) // Keep the typed status as the io::Error payload instead of a
// flattened string so RPC failure classifiers can read the gRPC code
// via downcast (see `PeerRestClient::is_network_like_error`).
// `RpcStatusError` renders exactly as `e.to_string()` did.
Error::other(crate::disk::error::RpcStatusError::from(e))
} }
} }
+8 -5
View File
@@ -180,6 +180,7 @@ pub struct ObjectInfo {
pub data_dir: Option<Uuid>, pub data_dir: Option<Uuid>,
pub delete_marker: bool, pub delete_marker: bool,
pub transitioned_object: TransitionedObject, pub transitioned_object: TransitionedObject,
pub transition_version_state: rustfs_filemeta::TransitionVersionState,
pub restore_ongoing: bool, pub restore_ongoing: bool,
pub restore_expires: Option<OffsetDateTime>, pub restore_expires: Option<OffsetDateTime>,
pub user_tags: Arc<String>, pub user_tags: Arc<String>,
@@ -220,6 +221,7 @@ impl Clone for ObjectInfo {
data_dir: self.data_dir, data_dir: self.data_dir,
delete_marker: self.delete_marker, delete_marker: self.delete_marker,
transitioned_object: self.transitioned_object.clone(), transitioned_object: self.transitioned_object.clone(),
transition_version_state: self.transition_version_state,
restore_ongoing: self.restore_ongoing, restore_ongoing: self.restore_ongoing,
restore_expires: self.restore_expires, restore_expires: self.restore_expires,
user_tags: self.user_tags.clone(), user_tags: self.user_tags.clone(),
@@ -464,11 +466,11 @@ impl ObjectInfo {
let transitioned_object = TransitionedObject { let transitioned_object = TransitionedObject {
name: fi.transitioned_objname.clone(), name: fi.transitioned_objname.clone(),
version_id: if let Some(transition_version_id) = fi.transition_version_id { version_id: fi
transition_version_id.to_string() .transition_version
} else { .clone()
"".to_string() .or_else(|| fi.transition_version_id.map(|version_id| version_id.to_string()))
}, .unwrap_or_default(),
status: fi.transition_status.clone(), status: fi.transition_status.clone(),
free_version: fi.tier_free_version(), free_version: fi.tier_free_version(),
tier: fi.transition_tier.clone(), tier: fi.transition_tier.clone(),
@@ -537,6 +539,7 @@ impl ObjectInfo {
inlined, inlined,
user_defined: Arc::new(metadata), user_defined: Arc::new(metadata),
transitioned_object, transitioned_object,
transition_version_state: fi.transition_version_state,
checksum: fi.checksum.clone(), checksum: fi.checksum.clone(),
storage_class, storage_class,
restore_ongoing, restore_ongoing,
-4
View File
@@ -207,10 +207,6 @@ pub(crate) async fn ensure_boot_time() {
GLOBAL_BOOT_TIME.get_or_init(|| async { SystemTime::now() }).await; GLOBAL_BOOT_TIME.get_or_init(|| async { SystemTime::now() }).await;
} }
pub(crate) async fn scanner_init_time() -> Option<chrono::DateTime<chrono::Utc>> {
rustfs_common::get_global_init_time().await
}
pub(crate) async fn root_disk_threshold_for_erasure_disk() -> Option<u64> { pub(crate) async fn root_disk_threshold_for_erasure_disk() -> Option<u64> {
if is_erasure_sd().await { if is_erasure_sd().await {
None None
@@ -71,6 +71,7 @@ fn to_madmin_scanner_metrics(metrics: rustfs_common::metrics::ScannerMetricsRepo
MadminScannerMetrics { MadminScannerMetrics {
collected_at: metrics.collected_at, collected_at: metrics.collected_at,
current_cycle: metrics.current_cycle, current_cycle: metrics.current_cycle,
current_cycle_active: Some(metrics.current_cycle_active),
current_started: metrics.current_started, current_started: metrics.current_started,
cycles_completed_at: metrics.cycles_completed_at, cycles_completed_at: metrics.cycles_completed_at,
ongoing_buckets: metrics.ongoing_buckets, ongoing_buckets: metrics.ongoing_buckets,
@@ -398,10 +399,7 @@ pub async fn collect_local_metrics(types: MetricType, opts: &CollectMetricsOpts)
if types.contains(&MetricType::SCANNER) { if types.contains(&MetricType::SCANNER) {
debug!("start get scanner metrics"); debug!("start get scanner metrics");
let mut metrics = global_metrics().report().await; let metrics = global_metrics().report().await;
if let Some(init_time) = runtime_sources::scanner_init_time().await {
metrics.current_started = init_time;
}
real_time_metrics.aggregated.scanner = Some(to_madmin_scanner_metrics(metrics)); real_time_metrics.aggregated.scanner = Some(to_madmin_scanner_metrics(metrics));
} }
@@ -540,7 +538,9 @@ async fn collect_local_disks_metrics(disks: &HashSet<String>) -> HashMap<String,
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use rustfs_common::metrics::CurrentCycle;
use rustfs_io_metrics::internode_metrics::global_internode_metrics; use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use serial_test::serial;
use std::time::Duration; use std::time::Duration;
#[test] #[test]
@@ -588,7 +588,10 @@ mod test {
#[test] #[test]
fn scanner_metrics_mapping_preserves_partial_source_status() { fn scanner_metrics_mapping_preserves_partial_source_status() {
let current_started = Utc::now() - chrono::Duration::seconds(5);
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport { let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
current_cycle_active: true,
current_started,
last_cycle_partial_source: "usage".to_string(), last_cycle_partial_source: "usage".to_string(),
last_cycle_partial_source_code: 1, last_cycle_partial_source_code: 1,
partial_cycles_by_source: vec![rustfs_common::metrics::ScannerSourceCycleSnapshot { partial_cycles_by_source: vec![rustfs_common::metrics::ScannerSourceCycleSnapshot {
@@ -598,6 +601,8 @@ mod test {
..Default::default() ..Default::default()
}); });
assert_eq!(scanner.current_cycle_active, Some(true));
assert_eq!(scanner.current_started, current_started);
assert_eq!(scanner.last_cycle_partial_source, "usage"); assert_eq!(scanner.last_cycle_partial_source, "usage");
assert_eq!(scanner.last_cycle_partial_source_code, 1); assert_eq!(scanner.last_cycle_partial_source_code, 1);
let usage = scanner let usage = scanner
@@ -608,6 +613,39 @@ mod test {
assert_eq!(usage.cycles, 2); assert_eq!(usage.cycles, 2);
} }
#[tokio::test]
#[serial]
async fn collect_local_metrics_preserves_scanner_cycle_started_time() {
let previous_init_time = *rustfs_common::globals::GLOBAL_INIT_TIME.read().await;
let previous_cycle = global_metrics().get_cycle().await;
let init_time = Utc::now() - chrono::Duration::hours(1);
let cycle_started = Utc::now() - chrono::Duration::seconds(5);
*rustfs_common::globals::GLOBAL_INIT_TIME.write().await = Some(init_time);
let cycle = CurrentCycle {
current: 0,
next: 1,
started: cycle_started,
..Default::default()
};
let cycle_start = global_metrics().start_scan_cycle_work_with_cycle(cycle).await;
let realtime = collect_local_metrics(MetricType::SCANNER, &CollectMetricsOpts::default()).await;
global_metrics()
.finish_scan_cycle_work_with_cycle(cycle_start, previous_cycle.clone().unwrap_or_default())
.await;
global_metrics().set_cycle(previous_cycle).await;
*rustfs_common::globals::GLOBAL_INIT_TIME.write().await = previous_init_time;
let encoded = rmp_serde::to_vec_named(&realtime).expect("realtime metrics should encode");
let decoded: RealtimeMetrics = rmp_serde::from_slice(&encoded).expect("realtime metrics should decode");
let mut aggregated = RealtimeMetrics::default();
aggregated.merge(decoded);
let scanner = aggregated.aggregated.scanner.expect("scanner metrics");
assert_eq!(scanner.current_cycle_active, Some(true));
assert_eq!(scanner.current_started, cycle_started);
}
#[test] #[test]
fn scanner_metrics_mapping_preserves_pacing_pressure() { fn scanner_metrics_mapping_preserves_pacing_pressure() {
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport { let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
@@ -1344,8 +1344,11 @@ async fn run_tier_config_reload_worker<F, Fut>(
} }
TierConfigReloadFinish::Pending => retry_attempt = 0, TierConfigReloadFinish::Pending => retry_attempt = 0,
}, },
TierConfigReloadOutcome::Terminal(_) => match sys.finish_tier_config_reload_worker(&host) { TierConfigReloadOutcome::Terminal(err) => match sys.finish_tier_config_reload_worker(&host) {
TierConfigReloadFinish::Completed => { TierConfigReloadFinish::Completed => {
// This peer keeps the previous tier configuration for good, so record
// why. Dropping the error here hides the only evidence of a divergent
// node behind an outcome label that cannot be acted on.
warn!( warn!(
event = EVENT_NOTIFICATION_PEER_PROPAGATION, event = EVENT_NOTIFICATION_PEER_PROPAGATION,
component = LOG_COMPONENT_ECSTORE, component = LOG_COMPONENT_ECSTORE,
@@ -1353,6 +1356,7 @@ async fn run_tier_config_reload_worker<F, Fut>(
action = "reload_transition_tier_config", action = "reload_transition_tier_config",
host, host,
outcome = "terminal", outcome = "terminal",
error = ?err,
"tier configuration reload stopped after a terminal outcome" "tier configuration reload stopped after a terminal outcome"
); );
return; return;
@@ -931,7 +931,10 @@ pub async fn read_transition_meta(disk_path: &Path, bucket: &str, object: &str)
status: fi.transition_status.clone(), status: fi.transition_status.clone(),
tier: fi.transition_tier.clone(), tier: fi.transition_tier.clone(),
remote_object: fi.transitioned_objname.clone(), remote_object: fi.transitioned_objname.clone(),
remote_version_id: fi.transition_version_id.map(|id| id.to_string()), remote_version_id: fi
.transition_version
.clone()
.or_else(|| fi.transition_version_id.map(|id| id.to_string())),
free_version_count, free_version_count,
}) })
} }
+2 -1
View File
@@ -9274,7 +9274,8 @@ mod tests {
version_id: "v1".to_string(), version_id: "v1".to_string(),
tier_name: "COLD-A".to_string(), tier_name: "COLD-A".to_string(),
backend_identity: Some(current_identity), backend_identity: Some(current_identity),
version_id_exact: false, version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
}; };
journal_store journal_store
.insert_config_object( .insert_config_object(
@@ -19,6 +19,7 @@
#![allow(clippy::all)] #![allow(clippy::all)]
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::sync::Arc; use std::sync::Arc;
use bytes::Bytes; use bytes::Bytes;
@@ -45,6 +46,19 @@ const MAX_PARTS_COUNT: i64 = 10000;
const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5; const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5;
const MIN_PART_SIZE: i64 = 1024 * 1024 * 128; const MIN_PART_SIZE: i64 = 1024 * 1024 * 128;
fn parse_generation(remote_version: &str) -> Result<Option<i64>, Error> {
if remote_version.is_empty() {
return Ok(None);
}
let generation = remote_version
.parse::<i64>()
.map_err(|_| Error::new(ErrorKind::InvalidData, "GCS remote version is not a valid generation"))?;
if generation <= 0 {
return Err(Error::new(ErrorKind::InvalidData, "GCS remote version generation must be positive"));
}
Ok(Some(generation))
}
pub struct WarmBackendGCS { pub struct WarmBackendGCS {
pub client: Arc<Storage>, pub client: Arc<Storage>,
pub control: Arc<StorageControl>, pub control: Arc<StorageControl>,
@@ -105,6 +119,10 @@ impl WarmBackendGCS {
#[async_trait::async_trait] #[async_trait::async_trait]
impl WarmBackend for WarmBackendGCS { impl WarmBackend for WarmBackendGCS {
fn validate_remote_version_id(&self, remote_version_id: &str) -> Result<(), std::io::Error> {
parse_generation(remote_version_id).map(|_| ())
}
async fn put_with_meta( async fn put_with_meta(
&self, &self,
object: &str, object: &str,
@@ -135,6 +153,9 @@ impl WarmBackend for WarmBackendGCS {
async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> { async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
let mut req = self.client.read_object(&self.bucket, &self.get_dest(object)); let mut req = self.client.read_object(&self.bucket, &self.get_dest(object));
if let Some(generation) = parse_generation(rv)? {
req = req.set_generation(generation);
}
// Honor the requested byte range so Range GETs on tiered objects return the exact // Honor the requested byte range so Range GETs on tiered objects return the exact
// interval instead of the whole object (matches the s3/s3sdk/rustfs warm backends). // interval instead of the whole object (matches the s3/s3sdk/rustfs warm backends).
@@ -164,13 +185,15 @@ impl WarmBackend for WarmBackendGCS {
// gRPC v2 DeleteObject requires the bucket in resource-name form. Without this the // gRPC v2 DeleteObject requires the bucket in resource-name form. Without this the
// deleted tiered object was never removed from GCS (empty impl returned Ok), leaking // deleted tiered object was never removed from GCS (empty impl returned Ok), leaking
// remote data forever. // remote data forever.
self.control let mut req = self
.control
.delete_object() .delete_object()
.set_bucket(format!("projects/_/buckets/{}", self.bucket)) .set_bucket(format!("projects/_/buckets/{}", self.bucket))
.set_object(self.get_dest(object)) .set_object(self.get_dest(object));
.send() if let Some(generation) = parse_generation(rv)? {
.await req = req.set_generation(generation);
.map_err(|e| std::io::Error::other(e.to_string()))?; }
req.send().await.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -191,6 +214,30 @@ impl WarmBackend for WarmBackendGCS {
} }
} }
#[cfg(test)]
mod tests {
use super::parse_generation;
use std::io::ErrorKind;
#[test]
fn generation_parser_preserves_exact_numeric_versions() {
assert_eq!(parse_generation("").expect("empty generation means no version condition"), None);
assert_eq!(parse_generation("1").expect("minimum generation should parse"), Some(1));
assert_eq!(
parse_generation(&i64::MAX.to_string()).expect("maximum generation should parse"),
Some(i64::MAX)
);
}
#[test]
fn generation_parser_rejects_unknown_or_non_positive_versions() {
for value in ["unknown", "1.0", "-1", "0", "9223372036854775808"] {
let err = parse_generation(value).expect_err("unknown generation must fail closed");
assert_eq!(err.kind(), ErrorKind::InvalidData, "{value}");
}
}
}
/*fn gcs_to_object_error(err: Error, params: Vec<String>) -> Option<Error> { /*fn gcs_to_object_error(err: Error, params: Vec<String>) -> Option<Error> {
if err == nil { if err == nil {
return nil return nil
+113 -29
View File
@@ -46,9 +46,10 @@ use crate::diagnostics::get::{
GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure, GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure,
record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled, record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled,
}; };
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
use crate::disk::{ use crate::disk::{
OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction, DataDirDeleteStatus, OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK,
part_transaction_path, PartTransactionAction, part_transaction_path,
}; };
use crate::erasure::coding::BitrotReader; use crate::erasure::coding::BitrotReader;
use crate::io_support::bitrot::ShardReader; use crate::io_support::bitrot::ShardReader;
@@ -547,6 +548,8 @@ pub(in crate::set_disk) fn metadata_early_stop_candidate_matches(left: &FileInfo
&& left.transitioned_objname == right.transitioned_objname && left.transitioned_objname == right.transitioned_objname
&& left.transition_tier == right.transition_tier && left.transition_tier == right.transition_tier
&& left.transition_version_id == right.transition_version_id && left.transition_version_id == right.transition_version_id
&& left.transition_version == right.transition_version
&& left.transition_version_state == right.transition_version_state
&& left.expire_restored == right.expire_restored && left.expire_restored == right.expire_restored
&& left.size == right.size && left.size == right.size
&& left.mod_time == right.mod_time && left.mod_time == right.mod_time
@@ -2985,29 +2988,48 @@ impl SetDisks {
Self::rename_fanout_barrier(&object_for_fault, idx, rename_fanout_barrier_phase::CLEANUP).await; Self::rename_fanout_barrier(&object_for_fault, idx, rename_fanout_barrier_phase::CLEANUP).await;
if let Some(err) = Self::cleanup_injected_error(&object_for_fault, idx) { if let Some(err) = Self::cleanup_injected_error(&object_for_fault, idx) {
return Some(err); return (false, Some(err));
} }
if let Some(disk) = disk { if let Some(disk) = disk {
disk.delete( match disk
&bucket, .delete_data_dir(
&file_path, &bucket,
DeleteOptions { &file_path,
recursive: true, DeleteOptions {
..Default::default() recursive: true,
}, ..Default::default()
) },
.await )
.err() .await
{
Ok(DataDirDeleteStatus::Deleted) => (false, None),
Ok(DataDirDeleteStatus::Deferred) => (true, None),
Err(err) => (false, Some(err)),
}
} else { } else {
// `None` slot: ignored placeholder. It is not `attempted`, so // `None` slot: ignored placeholder. It is not `attempted`, so
// classification excludes it from residue regardless. // classification excludes it from residue regardless.
Some(DiskError::DiskNotFound) (false, Some(DiskError::DiskNotFound))
} }
}) })
}); });
let errs: Vec<Option<DiskError>> = join_all(futures).await.into_iter().map(map_cleanup_join_result).collect(); let mut deferred = 0usize;
let errs: Vec<Option<DiskError>> = join_all(futures)
.await
.into_iter()
.map(|result| match result {
Ok((was_deferred, err)) => {
deferred += usize::from(was_deferred);
err
}
Err(join_err) => Some(DiskError::other(format!("old data dir cleanup task failed: {join_err}"))),
})
.collect();
classify_old_data_dir_cleanup(&errs, &attempted, write_quorum) let mut cleanup = classify_old_data_dir_cleanup(&errs, &attempted, write_quorum);
cleanup.deferred = deferred;
cleanup.reclaimed = cleanup.reclaimed.saturating_sub(deferred);
cleanup
} }
/// Test-only fault-injection seam for the old-data-dir cleanup path /// Test-only fault-injection seam for the old-data-dir cleanup path
@@ -3098,6 +3120,20 @@ impl SetDisks {
rustfs_io_metrics::record_old_data_dir_cleanup(c.attempted, c.reclaimed, c.unreclaimed_disks.len(), c.below_quorum); rustfs_io_metrics::record_old_data_dir_cleanup(c.attempted, c.reclaimed, c.unreclaimed_disks.len(), c.below_quorum);
if c.deferred > 0 {
debug!(
event = EVENT_SET_DISK_WRITE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket = %bucket,
object = %object,
old_data_dir = %old_dir,
deferred = c.deferred,
state = "old_data_cleanup_deferred",
"Old data directory cleanup deferred for active snapshot leases"
);
}
if actions.warn { if actions.warn {
warn!( warn!(
component = LOG_COMPONENT_ECSTORE, component = LOG_COMPONENT_ECSTORE,
@@ -3917,9 +3953,9 @@ impl SetDisks {
/// * The set of referenced data dirs is the UNION of `get_data_dirs()` across /// * The set of referenced data dirs is the UNION of `get_data_dirs()` across
/// every online disk's `xl.meta`, so a dir named by *any* replica is kept. /// every online disk's `xl.meta`, so a dir named by *any* replica is kept.
/// * If a disk holds the object directory but its `xl.meta` is missing or /// * If a disk holds the object directory but its `xl.meta` is missing or
/// unparsable, the object is treated as degraded and NOTHING is removed — /// unparsable, the object is treated as degraded and unmarked data dirs are
/// the unreadable copy could be the only one naming a live data dir, and a /// never removed. A data dir carrying a committed delete-transaction marker
/// heal must run first. /// remains reclaimable after a downgrade/re-upgrade cleanup interruption.
/// * Only subdirectories whose names parse as a UUID are ever considered; /// * Only subdirectories whose names parse as a UUID are ever considered;
/// removal is non-recursive-safe via a recursive delete of the full stray /// removal is non-recursive-safe via a recursive delete of the full stray
/// data-dir path only. /// data-dir path only.
@@ -3934,7 +3970,7 @@ impl SetDisks {
// physical UUID subdirectories present on each disk. Abort on any degraded // physical UUID subdirectories present on each disk. Abort on any degraded
// copy so a healable object is never stripped of a referenced data dir. // copy so a healable object is never stripped of a referenced data dir.
let mut referenced: HashSet<Uuid> = HashSet::new(); let mut referenced: HashSet<Uuid> = HashSet::new();
let mut per_disk_dirs: Vec<(usize, Vec<Uuid>)> = Vec::new(); let mut per_disk_dirs: Vec<(usize, Vec<(Uuid, bool)>)> = Vec::new();
let mut healthy_metas = 0usize; let mut healthy_metas = 0usize;
for (i, disk) in disks.iter().enumerate() { for (i, disk) in disks.iter().enumerate() {
@@ -3970,6 +4006,22 @@ impl SetDisks {
// to the orphan-dir / dangling-object heal paths. // to the orphan-dir / dangling-object heal paths.
continue; continue;
} }
let mut committed = Vec::with_capacity(physical.len());
for dir in physical {
let data_dir = format!("{object}/{dir}");
let committed_delete = disk.list_dir("", bucket, &data_dir, 0).await.is_ok_and(|entries| {
entries.iter().any(|entry| {
entry
.strip_prefix(DELETE_DATA_DIR_MARKER_PREFIX)
.is_some_and(|transaction| Uuid::parse_str(transaction).is_ok())
})
});
committed.push((dir, committed_delete));
}
if committed.iter().all(|(_, committed_delete)| *committed_delete) {
per_disk_dirs.push((i, committed));
continue;
}
warn!( warn!(
target: "rustfs_ecstore::set_disk", target: "rustfs_ecstore::set_disk",
bucket, object, bucket, object,
@@ -4006,22 +4058,16 @@ impl SetDisks {
healthy_metas += 1; healthy_metas += 1;
if !physical.is_empty() { if !physical.is_empty() {
per_disk_dirs.push((i, physical)); per_disk_dirs.push((i, physical.into_iter().map(|dir| (dir, false)).collect()));
} }
} }
// No healthy metadata anywhere: this is not a live object, so surplus dirs
// (if any) belong to the dangling-object heal path, not here.
if healthy_metas == 0 {
return Ok(0);
}
// Phase 2: delete every physical data dir not referenced by the union. // Phase 2: delete every physical data dir not referenced by the union.
let mut removed = 0usize; let mut removed = 0usize;
for (i, physical) in per_disk_dirs { for (i, physical) in per_disk_dirs {
let Some(disk) = disks[i].as_ref() else { continue }; let Some(disk) = disks[i].as_ref() else { continue };
for dir in physical { for (dir, committed_delete) in physical {
if referenced.contains(&dir) { if referenced.contains(&dir) || (healthy_metas == 0 && !committed_delete) {
continue; continue;
} }
let stray = format!("{object}/{dir}"); let stray = format!("{object}/{dir}");
@@ -4129,6 +4175,9 @@ pub(in crate::set_disk) struct OldDataDirCleanup {
/// Number of attempted disks that returned `Ok` or a not-found variant /// Number of attempted disks that returned `Ok` or a not-found variant
/// (a missing dir == already reclaimed). /// (a missing dir == already reclaimed).
pub reclaimed: usize, pub reclaimed: usize,
/// Number of attempted disks that retained the directory for an active
/// snapshot lease and registered it for deletion after the final release.
pub deferred: usize,
/// Indices of attempted disks that failed with a non-ignored, non-not-found /// Indices of attempted disks that failed with a non-ignored, non-not-found
/// error (including task panic/cancel). This is the residue that actually /// error (including task panic/cancel). This is the residue that actually
/// leaks and drives the leak metric + heal enqueue. /// leaks and drives the leak metric + heal enqueue.
@@ -4191,6 +4240,7 @@ fn classify_old_data_dir_cleanup(errs: &[Option<DiskError>], attempted: &[bool],
OldDataDirCleanup { OldDataDirCleanup {
attempted: attempted_count, attempted: attempted_count,
reclaimed, reclaimed,
deferred: 0,
unreclaimed_disks, unreclaimed_disks,
below_quorum, below_quorum,
} }
@@ -5131,6 +5181,40 @@ mod tests {
drop((disk1, disk2)); drop((disk1, disk2));
} }
#[tokio::test]
async fn commit_cleanup_reports_and_releases_deferred_snapshot_data_dirs() {
let bucket = "cleanup-lease-bucket";
let object = "cleanup-lease-object";
let old_data_dir = "11111111-1111-1111-1111-111111111111";
let committed_data_dir = "22222222-2222-2222-2222-222222222222";
let data_dir_path = format!("{object}/{old_data_dir}");
let shard_path = format!("{data_dir_path}/part.1");
let (_dir1, disk1) = read_multiple_test_disk(bucket, &[(&shard_path, b"one".as_slice())]).await;
let set = io_primitives_test_set(vec![Some(disk1.clone())], 0).await;
let lease = disk1
.acquire_snapshot_lease(bucket, &data_dir_path)
.await
.expect("snapshot lease should be acquired before cleanup");
let cleanup = set
.commit_rename_data_dir(&[Some(disk1.clone())], bucket, object, old_data_dir, committed_data_dir, 1)
.await;
assert_eq!(cleanup.attempted, 1);
assert_eq!(cleanup.reclaimed, 0);
assert_eq!(cleanup.deferred, 1);
assert!(cleanup.unreclaimed_disks.is_empty());
disk1
.read_all(bucket, &shard_path)
.await
.expect("deferred cleanup must leave later shard opens available");
disk1
.release_snapshot_lease(bucket, &data_dir_path, lease)
.await
.expect("final lease release should reclaim the old data directory");
assert!(matches!(disk1.read_all(bucket, &shard_path).await, Err(DiskError::FileNotFound)));
}
/// Isolation guard: an armed barrier / observed object only affects its own /// Isolation guard: an armed barrier / observed object only affects its own
/// object. A fan-out for a different (unobserved, unarmed) object must not be /// object. A fan-out for a different (unobserved, unarmed) object must not be
/// paused and must not accrue any tracked task count — so concurrent tests /// paused and must not accrue any tracked task count — so concurrent tests
+8 -1
View File
@@ -555,7 +555,7 @@ impl SetDisks {
} }
} }
fn file_info_quorum_hash(meta: &FileInfo) -> [u8; 32] { pub(super) fn file_info_quorum_hash(meta: &FileInfo) -> [u8; 32] {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
Self::update_file_info_quorum_hash(&mut hasher, meta); Self::update_file_info_quorum_hash(&mut hasher, meta);
let digest = hasher.finalize(); let digest = hasher.finalize();
@@ -578,6 +578,13 @@ impl SetDisks {
Self::update_hash_str(hasher, &meta.transition_tier); Self::update_hash_str(hasher, &meta.transition_tier);
Self::update_hash_str(hasher, &meta.transitioned_objname); Self::update_hash_str(hasher, &meta.transitioned_objname);
Self::update_hash_optional_uuid(hasher, meta.transition_version_id); Self::update_hash_optional_uuid(hasher, meta.transition_version_id);
Self::update_hash_optional_str(hasher, meta.transition_version.as_deref());
hasher.update([match meta.transition_version_state {
rustfs_filemeta::TransitionVersionState::Unknown => 0,
rustfs_filemeta::TransitionVersionState::KnownDisabled => 1,
rustfs_filemeta::TransitionVersionState::SuspendedNull => 2,
rustfs_filemeta::TransitionVersionState::Exact => 3,
}]);
Self::update_hash_optional_u32(hasher, meta.mode); Self::update_hash_optional_u32(hasher, meta.mode);
Self::update_hash_optional_u64(hasher, meta.written_by_version); Self::update_hash_optional_u64(hasher, meta.written_by_version);
File diff suppressed because it is too large Load Diff
+674 -160
View File
@@ -92,6 +92,69 @@ struct PartFailureSummary {
bitrot_failure: bool, bitrot_failure: bool,
} }
#[derive(Clone)]
struct RecoverableMetaCandidate {
identity: [u8; 32],
file_info: FileInfo,
data_count: usize,
local_payload: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DanglingDeleteSafety {
UnsafeToDelete,
NoRecoverableCandidate,
}
#[cfg(test)]
struct DanglingCheckPartsFailure {
key: DanglingCheckPartsFailureKey,
}
#[cfg(test)]
type DanglingCheckPartsFailureKey = (String, String, usize);
#[cfg(test)]
type DanglingCheckPartsFailures = HashMap<DanglingCheckPartsFailureKey, DiskError>;
#[cfg(test)]
fn dangling_check_parts_failures() -> &'static std::sync::Mutex<DanglingCheckPartsFailures> {
static FAILURES: std::sync::OnceLock<std::sync::Mutex<DanglingCheckPartsFailures>> = std::sync::OnceLock::new();
FAILURES.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
#[cfg(test)]
impl DanglingCheckPartsFailure {
fn install(bucket: &str, object: &str, disk_index: usize, error: DiskError) -> Self {
let key = (bucket.to_string(), object.to_string(), disk_index);
let previous = dangling_check_parts_failures()
.lock()
.expect("dangling check-parts failure registry should not poison")
.insert(key.clone(), error);
assert!(previous.is_none(), "dangling check-parts failure already installed");
Self { key }
}
}
#[cfg(test)]
impl Drop for DanglingCheckPartsFailure {
fn drop(&mut self) {
dangling_check_parts_failures()
.lock()
.expect("dangling check-parts failure registry should not poison")
.remove(&self.key);
}
}
#[cfg(test)]
fn injected_dangling_check_parts_error(bucket: &str, object: &str, disk_index: usize) -> Option<DiskError> {
dangling_check_parts_failures()
.lock()
.expect("dangling check-parts failure registry should not poison")
.get(&(bucket.to_string(), object.to_string(), disk_index))
.cloned()
}
fn first_unhealthy_part_summary( fn first_unhealthy_part_summary(
data_errs_by_part: &HashMap<usize, Vec<usize>>, data_errs_by_part: &HashMap<usize, Vec<usize>>,
parts: &[ObjectPartInfo], parts: &[ObjectPartInfo],
@@ -125,39 +188,17 @@ impl SetDisks {
version_id: &str, version_id: &str,
opts: &HealOpts, opts: &HealOpts,
) -> disk::error::Result<(HealResultItem, Option<DiskError>)> { ) -> disk::error::Result<(HealResultItem, Option<DiskError>)> {
// `allow_meta_regen` is true on the first pass: a version whose data shards Box::pin(self.heal_object_with_explicit_version_regen(bucket, object, version_id, opts, true)).await
// physically survive (>= data_blocks) but whose xl.meta fell below
// read-quorum is RESCUED (missing xl.meta regenerated) rather than
// dangling-deleted. The re-drive after a rescue sets it false so the
// regeneration can happen at most once (no unbounded recursion).
Box::pin(self.heal_object_with_regen(bucket, object, version_id, opts, true)).await
}
/// Best-effort orphan-data-dir reclaim for an object that is healthy on this
/// set. Wraps [`Self::reclaim_orphan_data_dirs`] with the shared logging so
/// both `heal_object` exits — the already-healthy early return and the
/// post-heal tail — reclaim identically. Never fails the heal: delete errors
/// are logged and swallowed. Callers must gate this on `!opts.dry_run`.
async fn reclaim_orphan_data_dirs_best_effort(&self, bucket: &str, object: &str) {
match self.reclaim_orphan_data_dirs(bucket, object).await {
Ok(removed) if removed > 0 => {
info!(bucket, object, removed, "heal_object: reclaimed orphaned data directories");
}
Ok(_) => {}
Err(e) => {
warn!(bucket, object, error = %e, "heal_object: orphan data-dir reclaim failed");
}
}
} }
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
async fn heal_object_with_regen( async fn heal_object_with_explicit_version_regen(
&self, &self,
bucket: &str, bucket: &str,
object: &str, object: &str,
version_id: &str, version_id: &str,
opts: &HealOpts, opts: &HealOpts,
allow_meta_regen: bool, allow_explicit_version_regen: bool,
) -> disk::error::Result<(HealResultItem, Option<DiskError>)> { ) -> disk::error::Result<(HealResultItem, Option<DiskError>)> {
info!(?opts, "Starting heal_object"); info!(?opts, "Starting heal_object");
@@ -354,22 +395,6 @@ impl SetDisks {
} }
} }
// DATA-SAFETY GUARD (backlog#920, decision 1): before any
// dangling delete, if the version's DATA shards physically
// survive on >= data_blocks disks it is RECONSTRUCTABLE.
// Regenerate the missing xl.meta from a surviving valid
// FileInfo and re-drive the heal instead of destroying a
// recoverable version. Torn writes (< data_blocks data
// shards) fall through to the existing dangling behavior.
if cannot_heal
&& allow_meta_regen
&& self
.try_regenerate_recoverable_meta(bucket, object, &parts_metadata, &errs, &disks)
.await?
{
return Box::pin(self.heal_object_with_regen(bucket, object, version_id, opts, false)).await;
}
if cannot_heal { if cannot_heal {
let total_disks = parts_metadata.len(); let total_disks = parts_metadata.len();
let healthy_count = total_disks.saturating_sub(disks_to_heal_count); let healthy_count = total_disks.saturating_sub(disks_to_heal_count);
@@ -422,6 +447,20 @@ impl SetDisks {
); );
} }
// `disks_with_all_parts` normalizes conflicting entries
// in `parts_metadata` to defaults. Re-read only before
// destructive cleanup so the guard sees every original
// identity.
let (delete_guard_metadata, delete_guard_errs) =
Self::read_all_fileinfo(&disks, "", bucket, object, version_id, true, true, false).await?;
if self
.dangling_delete_safety(bucket, object, &delete_guard_metadata, &delete_guard_errs, &disks)
.await?
== DanglingDeleteSafety::UnsafeToDelete
{
return Ok((result, Some(cannot_heal_err)));
}
// Allow for dangling deletes, on versions that have DataDir missing etc. // Allow for dangling deletes, on versions that have DataDir missing etc.
// this would end up restoring the correct readable versions. // this would end up restoring the correct readable versions.
return match self return match self
@@ -837,17 +876,25 @@ impl SetDisks {
} }
} }
Err(err) => { Err(err) => {
// DATA-SAFETY GUARD (backlog#920, decision 1): meta quorum failed, if allow_explicit_version_regen
// but the version's DATA may still physically survive on enough && !version_id.is_empty()
// disks (xl.meta lost on > parity disks while part files remain).
// Rescue it by regenerating the missing xl.meta and re-driving heal
// instead of dangling-deleting a reconstructable version.
if allow_meta_regen
&& self && self
.try_regenerate_recoverable_meta(bucket, object, &parts_metadata, &errs, &disks) .try_regenerate_explicit_version_meta(bucket, object, version_id, &parts_metadata, &errs, &disks)
.await? .await?
{ {
return Box::pin(self.heal_object_with_regen(bucket, object, version_id, opts, false)).await; return Box::pin(self.heal_object_with_explicit_version_regen(bucket, object, version_id, opts, false)).await;
}
if self
.dangling_delete_safety(bucket, object, &parts_metadata, &errs, &disks)
.await?
== DanglingDeleteSafety::UnsafeToDelete
{
return Ok((
self.default_heal_result(FileInfo::default(), &errs, bucket, object, version_id)
.await,
Some(err),
));
} }
let data_errs_by_part = HashMap::new(); let data_errs_by_part = HashMap::new();
@@ -883,129 +930,226 @@ impl SetDisks {
} }
} }
/// backlog#920 (decision 1): rescue a version that meta-quorum logic would async fn try_regenerate_explicit_version_meta(
/// otherwise dangling-DELETE, when its DATA is still reconstructable. &self,
/// bucket: &str,
/// Returns `Ok(true)` if the version was rescued (missing xl.meta regenerated object: &str,
/// on at least one disk, so a re-driven heal can reconstruct it), `Ok(false)` version_id: &str,
/// to fall through to the existing dangling-delete behavior. parts_metadata: &[FileInfo],
/// errs: &[Option<DiskError>],
/// Recoverability is computed by physically probing part files across ALL disks: &[Option<DiskStore>],
/// disks in the set with `check_parts` — including disks whose xl.meta is ) -> disk::error::Result<bool> {
/// absent (a lost xl.meta does not lose the sibling `part.*` data). If at let Ok(version_id) = Uuid::parse_str(version_id) else {
/// least `data_blocks` disks hold every part of a surviving valid FileInfo, return Ok(false);
/// the object is EC-reconstructable, so we regenerate that FileInfo's xl.meta };
/// on every disk whose metadata is absent (via `write_metadata`, which merges let candidates = parts_metadata
/// into any existing xl.meta). Delete markers, remote/transitioned versions, .iter()
/// and genuine torn writes (< `data_blocks` surviving data shards) are NOT .zip(errs.iter())
/// rescued — they keep the current dangling-delete-after-grace behavior, so no .filter_map(|(file_info, err)| {
/// regression on those paths. (err.is_none()
async fn try_regenerate_recoverable_meta( && file_info_is_valid_for_metadata(file_info)
&& file_info.version_id == Some(version_id)
&& file_info.has_valid_erasure_geometry()
&& !file_info.deleted
&& !file_info.is_remote()
&& file_info.data_dir.is_some()
&& !file_info.parts.is_empty()
&& file_info.erasure.data_blocks > 0
&& file_info
.erasure
.data_blocks
.checked_add(file_info.erasure.parity_blocks)
.is_some_and(|shards| shards == disks.len()))
.then_some(file_info)
})
.collect::<Vec<_>>();
let Some(candidate) = candidates.first().copied() else {
return Ok(false);
};
let identity = Self::file_info_quorum_hash(candidate);
if candidates
.iter()
.any(|file_info| Self::file_info_quorum_hash(file_info) != identity)
{
return Ok(false);
}
let mut available = 0usize;
for disk in disks {
let Some(disk) = disk else {
return Ok(false);
};
match disk.check_parts(bucket, object, candidate).await {
Ok(response)
if !response.results.is_empty() && response.results.iter().all(|result| *result == CHECK_PART_SUCCESS) =>
{
available += 1;
}
Ok(_)
| Err(
DiskError::FileNotFound
| DiskError::FileVersionNotFound
| DiskError::PathNotFound
| DiskError::VolumeNotFound,
) => {}
Err(_) => return Ok(false),
}
}
if available < candidate.erasure.data_blocks {
return Ok(false);
}
let mut wrote = 0usize;
for (index, disk) in disks.iter().enumerate() {
let Some(disk) = disk else {
return Ok(false);
};
let metadata_absent = matches!(
errs.get(index).and_then(Option::as_ref),
Some(DiskError::FileNotFound | DiskError::FileVersionNotFound)
);
if !metadata_absent {
continue;
}
let Some(&shard_index) = candidate.erasure.distribution.get(index) else {
return Ok(false);
};
let mut regenerated = candidate.clone();
regenerated.fresh = false;
regenerated.erasure.index = shard_index;
match disk.write_metadata("", bucket, object, regenerated).await {
Ok(()) => wrote += 1,
Err(error) => {
warn!(
bucket,
object,
disk_index = index,
error = %error,
"failed to regenerate recoverable xl.meta"
);
}
}
}
Ok(wrote > 0)
}
/// Best-effort orphan-data-dir reclaim for an object that is healthy on this
/// set. Wraps [`Self::reclaim_orphan_data_dirs`] with the shared logging so
/// both `heal_object` exits — the already-healthy early return and the
/// post-heal tail — reclaim identically. Never fails the heal: delete errors
/// are logged and swallowed. Callers must gate this on `!opts.dry_run`.
async fn reclaim_orphan_data_dirs_best_effort(&self, bucket: &str, object: &str) {
match self.reclaim_orphan_data_dirs(bucket, object).await {
Ok(removed) if removed > 0 => {
info!(bucket, object, removed, "heal_object: reclaimed orphaned data directories");
}
Ok(_) => {}
Err(e) => {
warn!(bucket, object, error = %e, "heal_object: orphan data-dir reclaim failed");
}
}
}
/// Prevent dangling cleanup when surviving state cannot prove that deletion
/// is safe. Part presence proves only recoverability, never commit: the write
/// path can durably rename data before xl.meta is committed.
async fn dangling_delete_safety(
&self, &self,
bucket: &str, bucket: &str,
object: &str, object: &str,
parts_metadata: &[FileInfo], parts_metadata: &[FileInfo],
errs: &[Option<DiskError>], errs: &[Option<DiskError>],
disks: &[Option<DiskStore>], disks: &[Option<DiskStore>],
) -> disk::error::Result<bool> { ) -> disk::error::Result<DanglingDeleteSafety> {
// A surviving valid, non-deleted, non-remote data FileInfo to rebuild from. if disks.iter().any(Option::is_none)
let Some(surviving) = parts_metadata || errs.iter().flatten().any(|err| {
!matches!(
err,
DiskError::FileNotFound
| DiskError::FileVersionNotFound
| DiskError::PathNotFound
| DiskError::VolumeNotFound
)
})
{
return Ok(DanglingDeleteSafety::UnsafeToDelete);
}
let mut candidates = Vec::<RecoverableMetaCandidate>::with_capacity(parts_metadata.len());
for (fi, err) in parts_metadata.iter().zip(errs.iter()) {
if err.is_some() || !file_info_is_valid_for_metadata(fi) {
continue;
}
let identity = Self::file_info_quorum_hash(fi);
if !candidates.iter().any(|candidate| candidate.identity == identity) {
let local_payload = fi.has_valid_erasure_geometry()
&& !fi.deleted
&& !fi.is_remote()
&& fi.data_dir.is_some()
&& !fi.parts.is_empty()
&& fi.erasure.data_blocks > 0
&& fi
.erasure
.data_blocks
.checked_add(fi.erasure.parity_blocks)
.is_some_and(|shards| shards == disks.len());
candidates.push(RecoverableMetaCandidate {
identity,
file_info: fi.clone(),
data_count: 0,
local_payload,
});
}
}
if candidates
.iter() .iter()
.find(|fi| fi.has_valid_erasure_geometry() && !fi.deleted && !fi.is_remote()) .any(|candidate| candidate.file_info.deleted || candidate.file_info.is_remote())
.cloned() || candidates.len() > 1
else { {
return Ok(false); return Ok(DanglingDeleteSafety::UnsafeToDelete);
};
// Without a data_dir + parts there is no data to prove recoverable.
if surviving.data_dir.is_none() || surviving.parts.is_empty() {
return Ok(false);
}
let data_blocks = surviving.erasure.data_blocks;
if data_blocks == 0 {
return Ok(false);
} }
// Physically probe part presence on EVERY online disk using the surviving for candidate in candidates.iter_mut().filter(|candidate| candidate.local_payload) {
// FileInfo's data_dir/parts. `check_parts` stats `object/<data_dir>/part.N` for (disk_index, disk) in disks.iter().enumerate() {
// directly, so it counts disks that still hold the data even if their let Some(disk) = disk else {
// xl.meta was deleted. return Ok(DanglingDeleteSafety::UnsafeToDelete);
let mut available = 0usize; };
for disk in disks.iter().flatten() { #[cfg(test)]
if let Ok(resp) = disk.check_parts(bucket, object, &surviving).await let check_result = match injected_dangling_check_parts_error(bucket, object, disk_index) {
&& !resp.results.is_empty() Some(error) => Err(error),
&& resp.results.iter().all(|r| *r == CHECK_PART_SUCCESS) None => disk.check_parts(bucket, object, &candidate.file_info).await,
{ };
available += 1; #[cfg(not(test))]
} let check_result = disk.check_parts(bucket, object, &candidate.file_info).await;
}
// Torn write: fewer than data_blocks surviving data shards is genuinely match check_result {
// unrecoverable — preserve the current dangling behavior (no resurrection). Ok(resp) if !resp.results.is_empty() && resp.results.iter().all(|result| *result == CHECK_PART_SUCCESS) => {
if available < data_blocks { candidate.data_count += 1;
debug!( }
bucket, Ok(_) => {}
object, Err(
available, DiskError::FileNotFound
data_blocks, | DiskError::FileVersionNotFound
"heal_object: version not reconstructable (torn write), keeping dangling behavior" | DiskError::PathNotFound
); | DiskError::VolumeNotFound,
return Ok(false); ) => {}
} Err(_) => return Ok(DanglingDeleteSafety::UnsafeToDelete),
// Reconstructable: regenerate the surviving xl.meta on every disk whose
// metadata is absent so the version regains read-quorum. Each disk gets its
// OWN shard index: the disk at physical position `index` holds shard
// `distribution[index]` (mirrors `shuffle_disks` + the write path's
// `erasure.index = shuffled_pos + 1`). Copying the surviving disk's index
// verbatim would write an inconsistent xl.meta that the re-heal then treats
// as corrupt.
let distribution = &surviving.erasure.distribution;
let mut wrote = 0usize;
for (index, disk) in disks.iter().enumerate() {
let Some(disk) = disk else { continue };
let meta_absent = matches!(
errs.get(index).and_then(Option::as_ref),
Some(DiskError::FileNotFound | DiskError::FileVersionNotFound)
) || !parts_metadata.get(index).map(FileInfo::is_valid).unwrap_or(false);
if !meta_absent {
continue;
}
// Without a known shard index for this position we cannot write a
// consistent xl.meta; leave it for the normal heal to reconstruct.
let Some(&shard_index) = distribution.get(index) else {
continue;
};
let mut regen = surviving.clone();
regen.fresh = false; // merge into any existing xl.meta on the disk
regen.erasure.index = shard_index;
match disk.write_metadata("", bucket, object, regen).await {
Ok(()) => wrote += 1,
Err(e) => {
warn!(
bucket,
object,
disk_index = index,
error = %e,
"heal_object: failed to regenerate recoverable xl.meta on disk"
);
} }
} }
} }
if wrote == 0 { Ok(
return Ok(false); if candidates
} .iter()
.any(|candidate| candidate.local_payload && candidate.data_count >= candidate.file_info.erasure.data_blocks)
info!( {
bucket, DanglingDeleteSafety::UnsafeToDelete
object, } else {
available, DanglingDeleteSafety::NoRecoverableCandidate
data_blocks, },
regenerated_meta_disks = wrote, )
"heal_object: rescued reconstructable sub-quorum version by regenerating xl.meta"
);
Ok(true)
} }
pub(in crate::set_disk) async fn heal_object_dir_locked( pub(in crate::set_disk) async fn heal_object_dir_locked(
@@ -1393,7 +1537,7 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
#[cfg(test)] #[cfg(test)]
mod heal_result_report_tests { mod heal_result_report_tests {
use super::SetDisks; use super::{DanglingCheckPartsFailure, DanglingDeleteSafety, SetDisks};
use super::{HEAL_RENAME_INCOMPLETE, HealRenameFailureScope}; use super::{HEAL_RENAME_INCOMPLETE, HealRenameFailureScope};
use crate::disk::endpoint::Endpoint; use crate::disk::endpoint::Endpoint;
use crate::disk::error::DiskError; use crate::disk::error::DiskError;
@@ -1405,10 +1549,12 @@ mod heal_result_report_tests {
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
use crate::{config::storageclass, store::init_format::save_format_file}; use crate::{config::storageclass, store::init_format::save_format_file};
use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode}; use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode};
use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo}; use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo, ObjectPartInfo, TRANSITION_COMPLETE};
use std::sync::Arc; use std::sync::Arc;
use tempfile::TempDir; use tempfile::TempDir;
use time::OffsetDateTime;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use uuid::Uuid;
async fn real_disk() -> (TempDir, Endpoint, DiskStore) { async fn real_disk() -> (TempDir, Endpoint, DiskStore) {
let dir = tempfile::tempdir().expect("tempdir should be created"); let dir = tempfile::tempdir().expect("tempdir should be created");
@@ -1446,6 +1592,68 @@ mod heal_result_report_tests {
.await .await
} }
fn meta_regen_test_fileinfo(object: &str, data_dir: Uuid, mod_time: i64, disk_index: usize) -> FileInfo {
let mut fi = FileInfo::new(object, 2, 2);
fi.data_dir = Some(data_dir);
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(mod_time).expect("test timestamp should parse"));
fi.size = 1;
fi.parts = vec![ObjectPartInfo {
number: 1,
size: 1,
actual_size: 1,
..Default::default()
}];
fi.erasure.index = fi.erasure.distribution[disk_index];
fi
}
async fn meta_regen_test_set(
bucket: &str,
object: &str,
data_dirs: &[(Uuid, usize)],
) -> (Vec<TempDir>, Arc<SetDisks>, Vec<Option<DiskStore>>) {
let mut temp_dirs = Vec::new();
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_index in 0..4 {
let (temp_dir, endpoint, disk) = real_disk().await;
disk.make_volume(bucket).await.expect("test bucket should be created");
for (data_dir, shard_count) in data_dirs {
if disk_index >= *shard_count {
continue;
}
let part_dir = temp_dir.path().join(bucket).join(object).join(data_dir.to_string());
tokio::fs::create_dir_all(&part_dir)
.await
.expect("test data directory should be created");
tokio::fs::write(part_dir.join("part.1"), [1u8; 2])
.await
.expect("test data shard should be written");
}
temp_dirs.push(temp_dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
let set = set_disks_with(disks.clone(), endpoints, 2).await;
(temp_dirs, set, disks)
}
async fn seed_meta_regen_test_metadata(
disks: &[Option<DiskStore>],
disk_index: usize,
bucket: &str,
object: &str,
file_info: &FileInfo,
) {
disks[disk_index]
.as_ref()
.expect("metadata test disk should be online")
.write_metadata("", bucket, object, file_info.clone())
.await
.expect("test metadata should be written");
}
async fn formatted_single_disk_no_parity_set() -> (TempDir, Arc<SetDisks>) { async fn formatted_single_disk_no_parity_set() -> (TempDir, Arc<SetDisks>) {
let format = FormatV3::new(1, 1); let format = FormatV3::new(1, 1);
let dir = tempfile::tempdir().expect("tempdir should be created"); let dir = tempfile::tempdir().expect("tempdir should be created");
@@ -1753,6 +1961,312 @@ mod heal_result_report_tests {
assert_eq!(result.before.drives[3].state, DriveState::Ok.to_string()); assert_eq!(result.before.drives[3].state, DriveState::Ok.to_string());
} }
#[tokio::test]
async fn dangling_delete_guard_preserves_conflicting_identities_without_writing_metadata() {
let bucket = "bucket-delete-guard-conflict";
let object = "object.bin";
let old_data_dir = Uuid::parse_str("99999999-9999-9999-9999-999999999999").expect("old data dir should parse");
let new_data_dir = Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").expect("new data dir should parse");
let (_temp_dirs, set, disks) = meta_regen_test_set(bucket, object, &[(old_data_dir, 4), (new_data_dir, 2)]).await;
let version_id = Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").expect("version id should parse");
let mut metadata = vec![
meta_regen_test_fileinfo(object, old_data_dir, 9, 0),
meta_regen_test_fileinfo(object, new_data_dir, 10, 1),
FileInfo::default(),
FileInfo::default(),
];
metadata[0].version_id = Some(version_id);
metadata[1].version_id = Some(version_id);
assert_eq!(
metadata[0].version_id, metadata[1].version_id,
"the conflicting candidates must share one version id"
);
seed_meta_regen_test_metadata(&disks, 0, bucket, object, &metadata[0]).await;
seed_meta_regen_test_metadata(&disks, 1, bucket, object, &metadata[1]).await;
let errs = vec![None, None, Some(DiskError::FileNotFound), Some(DiskError::FileNotFound)];
assert!(
set.dangling_delete_safety(bucket, object, &metadata, &errs, &disks)
.await
.expect("conflicting identities should be classified")
== DanglingDeleteSafety::UnsafeToDelete
);
let reversed = vec![
metadata[1].clone(),
metadata[0].clone(),
FileInfo::default(),
FileInfo::default(),
];
assert!(
set.dangling_delete_safety(bucket, object, &reversed, &errs, &disks)
.await
.expect("reversed identities should be classified")
== DanglingDeleteSafety::UnsafeToDelete
);
let version_id = version_id.to_string();
assert!(
!set.try_regenerate_explicit_version_meta(bucket, object, &version_id, &metadata, &errs, &disks)
.await
.expect("conflicting explicit-version candidates should be rejected"),
"an explicit version must not select between conflicting metadata identities"
);
for disk_index in [2, 3] {
assert!(
matches!(
disks[disk_index]
.as_ref()
.expect("test disk should be online")
.read_version("", bucket, object, "", &ReadOptions::default())
.await,
Err(DiskError::FileNotFound)
),
"the delete guard must not manufacture metadata on missing disks"
);
}
let old = disks[0]
.as_ref()
.expect("first test disk should be online")
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("old metadata should remain readable");
let new = disks[1]
.as_ref()
.expect("second test disk should be online")
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("new metadata should remain readable");
assert_eq!(old.data_dir, Some(old_data_dir));
assert_eq!(new.data_dir, Some(new_data_dir));
}
#[tokio::test]
async fn heal_meta_quorum_failure_preserves_reconstructable_uncommitted_candidate() {
let bucket = "bucket-delete-guard-reconstructable";
let object = "object.bin";
let data_dir = Uuid::parse_str("33333333-3333-3333-3333-333333333333").expect("data dir should parse");
let (_temp_dirs, set, disks) = meta_regen_test_set(bucket, object, &[(data_dir, 2)]).await;
let metadata = [
meta_regen_test_fileinfo(object, data_dir, 3, 0),
FileInfo::default(),
FileInfo::default(),
FileInfo::default(),
];
seed_meta_regen_test_metadata(&disks, 0, bucket, object, &metadata[0]).await;
let (observed_metadata, observed_errs) = SetDisks::read_all_fileinfo(&disks, "", bucket, object, "", true, true, false)
.await
.expect("test metadata should be readable across the set");
assert_eq!(
set.dangling_delete_safety(bucket, object, &observed_metadata, &observed_errs, &disks)
.await
.expect("observed reconstructable candidate should be classified"),
DanglingDeleteSafety::UnsafeToDelete
);
let (_, err) = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
..Default::default()
},
)
.await
.expect("unsafe dangling state should be reported without deletion");
assert_eq!(err, Some(DiskError::FileNotFound));
let surviving = disks[0]
.as_ref()
.expect("first test disk should be online")
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("the only metadata copy must be preserved");
assert_eq!(surviving.data_dir, Some(data_dir));
assert!(
matches!(
disks[1]
.as_ref()
.expect("second test disk should be online")
.read_version("", bucket, object, "", &ReadOptions::default())
.await,
Err(DiskError::FileNotFound)
),
"the delete guard must not propagate metadata"
);
}
#[tokio::test]
async fn heal_meta_quorum_failure_preserves_candidate_when_required_shard_disk_is_offline() {
let bucket = "bucket-delete-guard-offline";
let object = "object.bin";
let data_dir = Uuid::parse_str("44444444-4444-4444-4444-444444444444").expect("data dir should parse");
let (temp_dirs, set, disks) = meta_regen_test_set(bucket, object, &[(data_dir, 2)]).await;
let metadata = meta_regen_test_fileinfo(object, data_dir, 4, 0);
seed_meta_regen_test_metadata(&disks, 0, bucket, object, &metadata).await;
set.disks.write().await[1] = None;
let (_, err) = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
..Default::default()
},
)
.await
.expect("offline shard state should be reported without deletion");
assert_eq!(err, Some(DiskError::FileNotFound));
let surviving = disks[0]
.as_ref()
.expect("first test disk should be online")
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("offline uncertainty must preserve the surviving metadata");
assert_eq!(surviving.data_dir, Some(data_dir));
assert!(
temp_dirs[0]
.path()
.join(bucket)
.join(object)
.join(data_dir.to_string())
.join("part.1")
.is_file(),
"offline uncertainty must preserve the last online shard"
);
}
#[tokio::test]
async fn heal_meta_quorum_failure_preserves_candidate_when_part_probe_times_out() {
let bucket = "bucket-delete-guard-timeout";
let object = "object.bin";
let data_dir = Uuid::parse_str("55555555-5555-5555-5555-555555555555").expect("data dir should parse");
let (temp_dirs, set, disks) = meta_regen_test_set(bucket, object, &[(data_dir, 2)]).await;
let metadata = meta_regen_test_fileinfo(object, data_dir, 5, 0);
seed_meta_regen_test_metadata(&disks, 0, bucket, object, &metadata).await;
let _failure = DanglingCheckPartsFailure::install(bucket, object, 1, DiskError::Timeout);
let (_, err) = set
.heal_object(
bucket,
object,
"",
&HealOpts {
no_lock: true,
..Default::default()
},
)
.await
.expect("part probe timeout should be reported without deletion");
assert_eq!(err, Some(DiskError::FileNotFound));
let surviving = disks[0]
.as_ref()
.expect("first test disk should be online")
.read_version("", bucket, object, "", &ReadOptions::default())
.await
.expect("probe uncertainty must preserve the surviving metadata");
assert_eq!(surviving.data_dir, Some(data_dir));
assert!(
temp_dirs[0]
.path()
.join(bucket)
.join(object)
.join(data_dir.to_string())
.join("part.1")
.is_file(),
"probe uncertainty must preserve the last confirmed shard"
);
}
#[tokio::test]
async fn dangling_delete_guard_ignores_set_incompatible_geometry() {
let bucket = "bucket-delete-guard-short-geometry";
let object = "object.bin";
let data_dir = Uuid::parse_str("abababab-abab-abab-abab-abababababab").expect("data dir should parse");
let (_temp_dirs, set, disks) = meta_regen_test_set(bucket, object, &[(data_dir, 1)]).await;
let mut candidate = FileInfo::new(object, 1, 0);
candidate.data_dir = Some(data_dir);
candidate.mod_time = Some(OffsetDateTime::from_unix_timestamp(18).expect("timestamp should parse"));
candidate.size = 1;
candidate.parts = vec![ObjectPartInfo {
number: 1,
size: 1,
actual_size: 1,
..Default::default()
}];
candidate.erasure.index = candidate.erasure.distribution[0];
seed_meta_regen_test_metadata(&disks, 0, bucket, object, &candidate).await;
let metadata = vec![candidate, FileInfo::default(), FileInfo::default(), FileInfo::default()];
let errs = vec![
None,
Some(DiskError::FileNotFound),
Some(DiskError::FileNotFound),
Some(DiskError::FileNotFound),
];
assert!(
set.dangling_delete_safety(bucket, object, &metadata, &errs, &disks)
.await
.expect("set-incompatible geometry should be classified")
== DanglingDeleteSafety::NoRecoverableCandidate
);
}
#[tokio::test]
async fn dangling_delete_guard_preserves_delete_marker_and_remote_metadata() {
let bucket = "bucket-delete-guard-nonlocal";
let object = "object.bin";
let (_temp_dirs, set, disks) = meta_regen_test_set(bucket, object, &[]).await;
let marker = FileInfo {
name: object.to_string(),
version_id: Some(Uuid::parse_str("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee").expect("version id should parse")),
deleted: true,
mod_time: Some(OffsetDateTime::from_unix_timestamp(14).expect("marker timestamp should parse")),
..Default::default()
};
let remote_dir = Uuid::parse_str("89898989-8989-8989-8989-898989898989").expect("remote data dir should parse");
let mut remote = meta_regen_test_fileinfo(object, remote_dir, 15, 1);
remote.transition_status = TRANSITION_COMPLETE.to_string();
remote.transition_tier = "WARM".to_string();
remote.transitioned_objname = "remote/object.bin".to_string();
for metadata in [marker, remote] {
let candidates = vec![metadata, FileInfo::default(), FileInfo::default(), FileInfo::default()];
let errs = vec![
None,
Some(DiskError::FileNotFound),
Some(DiskError::FileNotFound),
Some(DiskError::FileNotFound),
];
assert_eq!(
set.dangling_delete_safety(bucket, object, &candidates, &errs, &disks)
.await
.expect("non-local metadata should be classified"),
DanglingDeleteSafety::UnsafeToDelete
);
}
}
#[tokio::test]
async fn dangling_delete_guard_preserves_metadata_read_uncertainty() {
let bucket = "bucket-delete-guard-read-error";
let object = "object.bin";
let (_temp_dirs, set, disks) = meta_regen_test_set(bucket, object, &[]).await;
let metadata = vec![FileInfo::default(); disks.len()];
for read_error in [DiskError::Timeout, DiskError::DiskAccessDenied, DiskError::DiskNotFound] {
let mut errs = vec![Some(DiskError::FileNotFound); disks.len()];
errs[0] = Some(read_error);
assert_eq!(
set.dangling_delete_safety(bucket, object, &metadata, &errs, &disks)
.await
.expect("metadata read uncertainty should be classified"),
DanglingDeleteSafety::UnsafeToDelete
);
}
}
#[tokio::test] #[tokio::test]
async fn heal_no_parity_bitrot_reports_unrecoverable_integrity_failure() { async fn heal_no_parity_bitrot_reports_unrecoverable_integrity_failure() {
let (dir, set) = formatted_single_disk_no_parity_set().await; let (dir, set) = formatted_single_disk_no_parity_set().await;
+46 -13
View File
@@ -27,7 +27,7 @@ use crate::multipart_listing::paginate_multipart_listing;
use futures::{StreamExt, stream}; use futures::{StreamExt, stream};
use std::future::Future; use std::future::Future;
#[cfg(test)] #[cfg(test)]
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::task::JoinSet; use tokio::task::JoinSet;
@@ -36,6 +36,7 @@ const MULTIPART_LIST_IO_CONCURRENCY: usize = 16;
#[cfg(test)] #[cfg(test)]
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MultipartCommitPause { pub(crate) enum MultipartCommitPause {
PutPartBeforeLockAcquire,
PutPartBeforeLockLost, PutPartBeforeLockLost,
PutPartAfterRename, PutPartAfterRename,
BeforeLockLost, BeforeLockLost,
@@ -47,9 +48,10 @@ struct MultipartCommitBarrierState {
bucket: String, bucket: String,
object: String, object: String,
pause: MultipartCommitPause, pause: MultipartCommitPause,
armed: AtomicBool, expected_arrivals: usize,
arrivals: AtomicUsize,
arrived: tokio::sync::Notify, arrived: tokio::sync::Notify,
release: tokio::sync::Notify, release: tokio::sync::Semaphore,
} }
#[cfg(test)] #[cfg(test)]
@@ -64,13 +66,24 @@ static MULTIPART_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc
#[cfg(test)] #[cfg(test)]
impl MultipartCommitBarrier { impl MultipartCommitBarrier {
pub(crate) fn install(bucket: &str, object: &str, pause: MultipartCommitPause) -> Self { pub(crate) fn install(bucket: &str, object: &str, pause: MultipartCommitPause) -> Self {
Self::install_for_arrivals(bucket, object, pause, 1)
}
pub(crate) fn install_for_arrivals(
bucket: &str,
object: &str,
pause: MultipartCommitPause,
expected_arrivals: usize,
) -> Self {
assert!(expected_arrivals > 0, "multipart commit barrier must wait for at least one arrival");
let state = Arc::new(MultipartCommitBarrierState { let state = Arc::new(MultipartCommitBarrierState {
bucket: bucket.to_string(), bucket: bucket.to_string(),
object: object.to_string(), object: object.to_string(),
pause, pause,
armed: AtomicBool::new(true), expected_arrivals,
arrivals: AtomicUsize::new(0),
arrived: tokio::sync::Notify::new(), arrived: tokio::sync::Notify::new(),
release: tokio::sync::Notify::new(), release: tokio::sync::Semaphore::new(0),
}); });
let mut slot = MULTIPART_COMMIT_BARRIER let mut slot = MULTIPART_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None)) .get_or_init(|| std::sync::Mutex::new(None))
@@ -83,20 +96,28 @@ impl MultipartCommitBarrier {
} }
pub(crate) async fn wait_until_paused(&self) { pub(crate) async fn wait_until_paused(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified()) tokio::time::timeout(Duration::from_secs(30), async {
.await loop {
.expect("multipart completion should reach the deterministic commit barrier"); let arrived = self.state.arrived.notified();
if self.state.arrivals.load(Ordering::Acquire) >= self.state.expected_arrivals {
return;
}
arrived.await;
}
})
.await
.expect("multipart completion should reach the deterministic commit barrier");
} }
pub(crate) fn release(&self) { pub(crate) fn release(&self) {
self.state.release.notify_one(); self.state.release.add_permits(self.state.expected_arrivals);
} }
} }
#[cfg(test)] #[cfg(test)]
impl Drop for MultipartCommitBarrier { impl Drop for MultipartCommitBarrier {
fn drop(&mut self) { fn drop(&mut self) {
self.state.release.notify_one(); self.release();
let mut slot = MULTIPART_COMMIT_BARRIER let mut slot = MULTIPART_COMMIT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None)) .get_or_init(|| std::sync::Mutex::new(None))
.lock() .lock()
@@ -117,10 +138,20 @@ async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartComm
.filter(|barrier| barrier.bucket == bucket && barrier.object == object && barrier.pause == pause) .filter(|barrier| barrier.bucket == bucket && barrier.object == object && barrier.pause == pause)
.cloned(); .cloned();
if let Some(barrier) = barrier if let Some(barrier) = barrier
&& barrier.armed.swap(false, Ordering::AcqRel) && let Ok(previous) = barrier.arrivals.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
(current < barrier.expected_arrivals).then_some(current + 1)
})
{ {
barrier.arrived.notify_one(); let arrival = previous + 1;
barrier.release.notified().await; if arrival == barrier.expected_arrivals {
barrier.arrived.notify_one();
}
barrier
.release
.acquire()
.await
.expect("multipart commit barrier should remain open")
.forget();
} }
} }
@@ -693,6 +724,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
let part_path = format!("{}/{}/{}", upload_id_path, fi.data_dir.unwrap_or_default(), part_suffix); let part_path = format!("{}/{}/{}", upload_id_path, fi.data_dir.unwrap_or_default(), part_suffix);
#[cfg(test)]
pause_multipart_commit(bucket, object, MultipartCommitPause::PutPartBeforeLockAcquire).await;
// Serialize only the commit (rename_part), not the whole upload. Each // Serialize only the commit (rename_part), not the whole upload. Each
// concurrent stream writes to its own unique temp dir (see `tmp_part` // concurrent stream writes to its own unique temp dir (see `tmp_part`
// above), so the encode/stream phase never conflicts and must stay // above), so the encode/stream phase never conflicts and must stay
+364 -66
View File
@@ -25,7 +25,10 @@ use crate::set_disk::read::GetObjectDownstreamWriter;
use crate::bucket::lifecycle::{ use crate::bucket::lifecycle::{
tier_delete_journal::{persist_tier_delete_journal_entry, remove_tier_delete_journal_entry}, tier_delete_journal::{persist_tier_delete_journal_entry, remove_tier_delete_journal_entry},
tier_sweeper::{Jentry, RemoteTierDeleteOutcome, delete_object_from_remote_tier_with_lease_idempotent}, tier_sweeper::{
Jentry, RemoteTierDeleteOutcome, delete_confirmed_transition_candidate_exact_with_lease_idempotent,
delete_object_from_remote_tier_with_lease_idempotent,
},
transition_transaction::{ transition_transaction::{
TransitionRemoteVersion, TransitionSourceIdentity, TransitionSourceVersionMode, TransitionTransaction, TransitionRemoteVersion, TransitionSourceIdentity, TransitionSourceVersionMode, TransitionTransaction,
TransitionTransactionInit, TransitionTransactionState, delete_transition_transaction_record, TransitionTransactionInit, TransitionTransactionState, delete_transition_transaction_record,
@@ -547,13 +550,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
&self.ctx.tier_config_mgr(), &self.ctx.tier_config_mgr(),
) )
.await?; .await?;
return Ok(finish_set_disk_read_lock( return Ok(finish_set_disk_read_lock(gr, read_lock_guard.take(), None, bucket, object));
gr,
read_lock_guard.take(),
lock_optimization_enabled,
bucket,
object,
));
} }
// App-layer object data cache probe: metadata (etag/size) is resolved // App-layer object data cache probe: metadata (etag/size) is resolved
@@ -680,6 +677,18 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
return Ok(reader); return Ok(reader);
} }
let snapshot_lease = if lock_optimization_enabled {
match fi.data_dir.filter(|data_dir| !data_dir.is_nil()) {
Some(data_dir) => {
let data_dir_path = format!("{object}/{data_dir}");
acquire_snapshot_leases(&disks, bucket, &data_dir_path, fi.erasure.data_blocks).await
}
None => None,
}
} else {
None
};
match codec_streaming_gate.decision { match codec_streaming_gate.decision {
GetCodecStreamingDecision::Use => { GetCodecStreamingDecision::Use => {
match Self::get_object_decode_reader_with_fileinfo( match Self::get_object_decode_reader_with_fileinfo(
@@ -708,13 +717,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
// Carry the hook probe result so the app layer skips its // Carry the hook probe result so the app layer skips its
// now-redundant lookup on the streaming miss path (ODC-16). // now-redundant lookup on the streaming miss path (ODC-16).
reader.body_source = body_source; reader.body_source = body_source;
return Ok(finish_set_disk_read_lock( return Ok(finish_set_disk_read_lock(reader, read_lock_guard.take(), snapshot_lease, bucket, object));
reader,
read_lock_guard.take(),
lock_optimization_enabled,
bucket,
object,
));
} }
core::io_primitives::GetCodecStreamingReaderBuildOutcome::Fallback(reason) => { core::io_primitives::GetCodecStreamingReaderBuildOutcome::Fallback(reason) => {
record_get_codec_streaming_gate_decision( record_get_codec_streaming_gate_decision(
@@ -753,15 +756,22 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
let set_index = self.set_index; let set_index = self.set_index;
let pool_index = self.pool_index; let pool_index = self.pool_index;
let skip_verify = opts.skip_verify_bitrot; let skip_verify = opts.skip_verify_bitrot;
if lock_optimization_enabled { let producer_snapshot_lease = snapshot_lease.clone();
if let Some(lease) = snapshot_lease {
release_materialized_read_lock(&bucket, &object, read_lock_guard.take()); release_materialized_read_lock(&bucket, &object, read_lock_guard.take());
debug!(bucket, object, "Lock optimization: released read lock before streaming read"); reader.stream = Box::new(SnapshotLeaseReader {
inner: reader.stream,
lease: Some(lease),
terminal_error: false,
});
debug!(bucket, object, "Lock optimization: replaced read lock with snapshot leases");
} }
// When lock optimization is disabled, keep the read-lock guard in the // The producer shares the lease lifetime with the body so cancellation
// task so it lives for the duration of the streaming read. // cannot release the snapshot while the duplex task is still unwinding.
tokio::spawn(async move { tokio::spawn(async move {
let _guard = read_lock_guard; let _guard = read_lock_guard;
let _snapshot_lease = producer_snapshot_lease;
let mut writer = GetObjectDownstreamWriter::new(wd); let mut writer = GetObjectDownstreamWriter::new(wd);
// Do not wrap the entire read+write pipeline in `disk_read_timeout`. // Do not wrap the entire read+write pipeline in `disk_read_timeout`.
// `get_object_with_fileinfo` also waits on `writer`, so an outer timeout // `get_object_with_fileinfo` also waits on `writer`, so an outer timeout
@@ -1663,7 +1673,11 @@ pub(crate) async fn cleanup_uncommitted_transition_upload(
cleanup_version: &str, cleanup_version: &str,
version_id_exact: bool, version_id_exact: bool,
) -> std::io::Result<RemoteTierDeleteOutcome> { ) -> std::io::Result<RemoteTierDeleteOutcome> {
delete_object_from_remote_tier_with_lease_idempotent(object, cleanup_version, lease, version_id_exact).await if version_id_exact {
delete_confirmed_transition_candidate_exact_with_lease_idempotent(object, cleanup_version, lease).await
} else {
delete_object_from_remote_tier_with_lease_idempotent(object, cleanup_version, lease, false).await
}
} }
fn log_transition_upload_cleanup_failure(lease: &TierOperationLease, object: &str, cleanup_version: &str, err: &std::io::Error) { fn log_transition_upload_cleanup_failure(lease: &TierOperationLease, object: &str, cleanup_version: &str, err: &std::io::Error) {
@@ -1800,7 +1814,7 @@ impl Drop for TransitionUploadCleanup {
} }
} }
async fn cleanup_rejected_transition_upload_durably( pub(crate) async fn cleanup_rejected_transition_upload_durably(
lease: &TierOperationLease, lease: &TierOperationLease,
object: &str, object: &str,
cleanup_version: &str, cleanup_version: &str,
@@ -1813,6 +1827,13 @@ async fn cleanup_rejected_transition_upload_durably(
tier_name: lease.tier_name().to_string(), tier_name: lease.tier_name().to_string(),
backend_identity: Some(lease.backend_identity()), backend_identity: Some(lease.backend_identity()),
version_id_exact, version_id_exact,
version_state: if !version_id_exact {
rustfs_filemeta::TransitionVersionState::KnownDisabled
} else if cleanup_version == "null" {
rustfs_filemeta::TransitionVersionState::SuspendedNull
} else {
rustfs_filemeta::TransitionVersionState::Exact
},
}; };
let journal_error = if let Some(api) = api.as_ref() { let journal_error = if let Some(api) = api.as_ref() {
@@ -1972,12 +1993,83 @@ async fn advance_and_save_transition_transaction(
next: TransitionTransactionState, next: TransitionTransactionState,
remote_version: Option<TransitionRemoteVersion>, remote_version: Option<TransitionRemoteVersion>,
) -> Result<()> { ) -> Result<()> {
#[cfg(test)]
record_transition_uploaded_save_attempt(transaction, next);
transaction transaction
.advance(transaction.fence(), next, remote_version) .advance(transaction.fence(), next, remote_version)
.map_err(Error::other)?; .map_err(Error::other)?;
save_transition_transaction_if_available(api, transaction).await save_transition_transaction_if_available(api, transaction).await
} }
#[cfg(test)]
struct TransitionUploadedSaveProbeState {
bucket: String,
object: String,
attempts: std::sync::atomic::AtomicUsize,
}
#[cfg(test)]
struct TransitionUploadedSaveProbe {
state: Arc<TransitionUploadedSaveProbeState>,
}
#[cfg(test)]
static TRANSITION_UPLOADED_SAVE_PROBE: std::sync::OnceLock<std::sync::Mutex<Option<Arc<TransitionUploadedSaveProbeState>>>> =
std::sync::OnceLock::new();
#[cfg(test)]
impl TransitionUploadedSaveProbe {
fn install(bucket: &str, object: &str) -> Self {
let state = Arc::new(TransitionUploadedSaveProbeState {
bucket: bucket.to_string(),
object: object.to_string(),
attempts: std::sync::atomic::AtomicUsize::new(0),
});
let mut slot = TRANSITION_UPLOADED_SAVE_PROBE
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition uploaded-save probe mutex should not poison");
assert!(slot.is_none(), "transition uploaded-save probe must be installed by one test at a time");
*slot = Some(Arc::clone(&state));
drop(slot);
Self { state }
}
fn attempts(&self) -> usize {
self.state.attempts.load(std::sync::atomic::Ordering::Acquire)
}
}
#[cfg(test)]
impl Drop for TransitionUploadedSaveProbe {
fn drop(&mut self) {
let mut slot = TRANSITION_UPLOADED_SAVE_PROBE
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition uploaded-save probe mutex should not poison");
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
*slot = None;
}
}
}
#[cfg(test)]
fn record_transition_uploaded_save_attempt(transaction: &TransitionTransaction, next: TransitionTransactionState) {
if next != TransitionTransactionState::Uploaded {
return;
}
let state = TRANSITION_UPLOADED_SAVE_PROBE
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition uploaded-save probe mutex should not poison")
.as_ref()
.filter(|state| state.bucket == transaction.source.bucket && state.object == transaction.source.object)
.cloned();
if let Some(state) = state {
state.attempts.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
}
}
async fn delete_transition_transaction_if_available(api: Option<&Arc<ECStore>>, transaction_id: Uuid) -> Result<()> { async fn delete_transition_transaction_if_available(api: Option<&Arc<ECStore>>, transaction_id: Uuid) -> Result<()> {
if let Some(api) = api { if let Some(api) = api {
return delete_transition_transaction_record(api.clone(), transaction_id).await; return delete_transition_transaction_record(api.clone(), transaction_id).await;
@@ -2228,11 +2320,52 @@ async fn pause_transition_commit(bucket: &str, object: &str, pause: TransitionCo
} }
} }
fn parse_transition_version_id(remote_version: &str) -> std::result::Result<Option<Uuid>, uuid::Error> { fn persisted_transition_version(
remote_version: &str,
) -> std::io::Result<(Option<String>, rustfs_filemeta::TransitionVersionState)> {
persisted_transition_version_with_gate(remote_version, remote_version_state_writer_enabled())
}
fn remote_version_state_writer_enabled() -> bool {
remote_version_state_writer_enabled_for(
rustfs_utils::get_env_bool(
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE,
rustfs_config::DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE,
),
rustfs_utils::get_env_bool(
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
rustfs_config::DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
),
)
}
fn remote_version_state_writer_enabled_for(requested: bool, fleet_confirmed: bool) -> bool {
requested && fleet_confirmed
}
fn persisted_transition_version_with_gate(
remote_version: &str,
remote_version_state_writer_enabled: bool,
) -> std::io::Result<(Option<String>, rustfs_filemeta::TransitionVersionState)> {
if remote_version.is_empty() { if remote_version.is_empty() {
return Ok(None); return Ok((None, rustfs_filemeta::TransitionVersionState::KnownDisabled));
}
match Uuid::parse_str(remote_version) {
Ok(version_id) if version_id.is_nil() => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"remote tier returned a nil object version ID",
)),
Ok(_) => Ok((Some(remote_version.to_string()), rustfs_filemeta::TransitionVersionState::Exact)),
Err(_) if !remote_version_state_writer_enabled => Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"opaque remote tier versions require the operator-attested fleet gate",
)),
Err(_) if remote_version == "null" => {
Ok((Some(remote_version.to_string()), rustfs_filemeta::TransitionVersionState::SuspendedNull))
}
Err(_) => Ok((Some(remote_version.to_string()), rustfs_filemeta::TransitionVersionState::Exact)),
} }
Uuid::parse_str(remote_version).map(|version_id| (!version_id.is_nil()).then_some(version_id))
} }
#[cfg(test)] #[cfg(test)]
@@ -2470,16 +2603,20 @@ mod transition_upload_completion_tests {
#[cfg(test)] #[cfg(test)]
mod transition_version_id_tests { mod transition_version_id_tests {
use super::{TransitionUploadCandidate, parse_transition_version_id}; use super::{
TransitionUploadCandidate, persisted_transition_version, persisted_transition_version_with_gate,
remote_version_state_writer_enabled_for,
};
use rustfs_filemeta::TransitionVersionState;
use uuid::Uuid; use uuid::Uuid;
#[test] #[test]
fn normalizes_persisted_unversioned_ids_and_preserves_put_constraints() { fn normalizes_persisted_unversioned_ids_and_preserves_put_constraints() {
assert_eq!(parse_transition_version_id("").expect("empty remote version should be valid"), None);
assert_eq!( assert_eq!(
parse_transition_version_id(&Uuid::nil().to_string()).expect("nil remote version should be valid"), persisted_transition_version("").expect("empty remote version identifies an unversioned tier"),
None (None, TransitionVersionState::KnownDisabled)
); );
assert!(persisted_transition_version(&Uuid::nil().to_string()).is_err());
let nil_put_response = Uuid::nil().to_string(); let nil_put_response = Uuid::nil().to_string();
let nil_candidate = TransitionUploadCandidate::from_put_response(nil_put_response.clone()); let nil_candidate = TransitionUploadCandidate::from_put_response(nil_put_response.clone());
assert_eq!(nil_candidate.cleanup_version(), nil_put_response); assert_eq!(nil_candidate.cleanup_version(), nil_put_response);
@@ -2491,12 +2628,14 @@ mod transition_version_id_tests {
} }
#[test] #[test]
fn preserves_valid_remote_id_and_rejects_invalid_text() { fn preserves_uuid_and_gates_opaque_remote_ids() {
let version_id = Uuid::new_v4(); let version_id = Uuid::new_v4();
assert_eq!( assert_eq!(
parse_transition_version_id(&version_id.to_string()).expect("UUID remote version should be valid"), persisted_transition_version(&version_id.to_string()).expect("UUID remote version"),
Some(version_id) (Some(version_id.to_string()), TransitionVersionState::Exact)
); );
assert!(persisted_transition_version("null").is_err());
assert!(persisted_transition_version("opaque-version-token").is_err());
assert_eq!( assert_eq!(
TransitionUploadCandidate::from_put_response(version_id.to_string()).cleanup_version(), TransitionUploadCandidate::from_put_response(version_id.to_string()).cleanup_version(),
version_id.to_string() version_id.to_string()
@@ -2505,7 +2644,44 @@ mod transition_version_id_tests {
TransitionUploadCandidate::from_put_response("opaque-version-token".to_string()).cleanup_version(), TransitionUploadCandidate::from_put_response("opaque-version-token".to_string()).cleanup_version(),
"opaque-version-token" "opaque-version-token"
); );
assert!(parse_transition_version_id("not-a-uuid").is_err()); }
#[test]
fn remote_version_state_writer_requires_request_and_fleet_confirmation() {
for (case, requested, fleet_confirmed, expected) in [
("old defaults", false, false, false),
("missing fleet confirmation", true, false, false),
("missing local opt-in", false, true, false),
("explicitly unconfirmed fleet", true, false, false),
("rolled-back writer", false, true, false),
("fully upgraded fleet", true, true, true),
] {
assert_eq!(remote_version_state_writer_enabled_for(requested, fleet_confirmed), expected, "{case}");
}
}
#[test]
fn fleet_gate_enables_null_and_opaque_remote_version_states() {
for (remote_version, expected) in [
("null", (Some("null".to_string()), TransitionVersionState::SuspendedNull)),
(
"opaque-version-token",
(Some("opaque-version-token".to_string()), TransitionVersionState::Exact),
),
] {
assert!(
persisted_transition_version_with_gate(remote_version, false).is_err(),
"missing fleet confirmation must reject {remote_version:?}"
);
assert_eq!(
persisted_transition_version_with_gate(remote_version, true).expect("fleet-confirmed state must be persisted"),
expected
);
}
assert_eq!(
persisted_transition_version_with_gate("", true).expect("empty remote version identifies an unversioned tier"),
(None, TransitionVersionState::KnownDisabled)
);
} }
} }
@@ -3775,6 +3951,20 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object).await; delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object).await;
return Err(err.into()); return Err(err.into());
} }
let (transition_version_id, transition_version_state) = match persisted_transition_version(candidate.remote_version()) {
Ok(version) => version,
Err(err) => {
let cleanup_api = transition_cleanup_store(&self.ctx).await;
if let Err(cleanup_err) = upload_cleanup.cleanup_rejected_upload(cleanup_api).await {
return Err(StorageError::Io(std::io::Error::other(format!(
"{err}; rejected remote upload cleanup failed: {cleanup_err}"
))));
}
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object)
.await;
return Err(err.into());
}
};
if let Err(err) = advance_and_save_transition_transaction( if let Err(err) = advance_and_save_transition_transaction(
transaction_api.as_ref(), transaction_api.as_ref(),
&mut transaction, &mut transaction,
@@ -3792,16 +3982,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object).await; delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object).await;
return Err(err); return Err(err);
} }
let transition_version_id = match parse_transition_version_id(candidate.remote_version()) {
Ok(version_id) => version_id,
Err(err) => {
if upload_cleanup.cleanup().await.is_ok() {
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object)
.await;
}
return Err(err.into());
}
};
let mut commit_opts = opts.clone(); let mut commit_opts = opts.clone();
commit_opts.no_lock = true; commit_opts.no_lock = true;
@@ -3859,7 +4039,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
current_fi.transition_status = TRANSITION_COMPLETE.to_string(); current_fi.transition_status = TRANSITION_COMPLETE.to_string();
current_fi.transitioned_objname = dest_obj; current_fi.transitioned_objname = dest_obj;
current_fi.transition_tier = opts.transition.tier.clone(); current_fi.transition_tier = opts.transition.tier.clone();
current_fi.transition_version_id = transition_version_id; current_fi.transition_version_id = transition_version_id
.as_deref()
.and_then(|version_id| Uuid::parse_str(version_id).ok());
current_fi.transition_version = transition_version_id;
current_fi.transition_version_state = transition_version_state;
rustfs_utils::http::metadata_compat::insert_str( rustfs_utils::http::metadata_compat::insert_str(
&mut current_fi.metadata, &mut current_fi.metadata,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID, rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
@@ -4076,6 +4260,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let mut p_reader = PutObjReader::new(hash_reader); let mut p_reader = PutObjReader::new(hash_reader);
return match self_.clone().put_object(bucket, object, &mut p_reader, &ropts).await { return match self_.clone().put_object(bucket, object, &mut p_reader, &ropts).await {
Ok(restored_info) => { Ok(restored_info) => {
let restored_info = self_.finalize_restore_metadata(bucket, object, &restored_info, &opts).await?;
send_event(EventArgs { send_event(EventArgs {
event_name: EventName::ObjectRestoreCompleted.as_str().to_string(), event_name: EventName::ObjectRestoreCompleted.as_str().to_string(),
bucket_name: bucket.to_string(), bucket_name: bucket.to_string(),
@@ -4210,6 +4395,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
return set_restore_header_fn(&mut oi, Some(err)).await; return set_restore_header_fn(&mut oi, Some(err)).await;
} }
}; };
let restored_info = self_.finalize_restore_metadata(bucket, object, &restored_info, opts).await?;
send_event(EventArgs { send_event(EventArgs {
event_name: EventName::ObjectRestoreCompleted.as_str().to_string(), event_name: EventName::ObjectRestoreCompleted.as_str().to_string(),
bucket_name: bucket.to_string(), bucket_name: bucket.to_string(),
@@ -4668,6 +4854,33 @@ mod transition_commit_failure_tests {
} }
#[tokio::test] #[tokio::test]
async fn rejected_unsupported_remote_versions_are_cleaned_up() {
for remote_version in ["null", "opaque-version-token"] {
let manager = TierConfigMgr::new();
let backend = register_mock_tier(&manager, "WARM").await;
let lease = TierConfigMgr::acquire_operation_lease(&manager, "WARM")
.await
.expect("mock tier lease should be available");
let candidate = TransitionUploadCandidate::from_put_response(remote_version.to_string());
persisted_transition_version(candidate.remote_version()).expect_err("unsupported writer version must fail closed");
cleanup_rejected_transition_upload_durably(
&lease,
"remote/object",
candidate.cleanup_version(),
candidate.cleanup_version_is_exact(),
None,
)
.await
.expect("rejected remote upload must be cleaned up");
assert_eq!(
backend.remove_versions().await,
vec![("remote/object".to_string(), candidate.cleanup_version().to_string())]
);
}
}
#[serial_test::serial(restore_multipart_failure_point)] #[serial_test::serial(restore_multipart_failure_point)]
async fn multipart_restore_aborts_every_post_create_failure() { async fn multipart_restore_aborts_every_post_create_failure() {
let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await; let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
@@ -6615,6 +6828,45 @@ mod transition_upload_integrity_tests {
); );
} }
#[tokio::test]
#[serial_test::serial]
async fn unversioned_remote_version_is_persisted_without_version_id() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "transition-unversioned-tier-bucket";
let object = "object.bin";
let payload = b"unversioned remote tier must commit without a version id".repeat(1024);
let original = write_source(&set_disks, &disk_stores, bucket, object, &payload).await;
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
backend.set_put_remote_version(Some(String::new())).await;
let save_probe = TransitionUploadedSaveProbe::install(bucket, object);
set_disks
.transition_object(bucket, object, &transition_options(&original, tier_name))
.await
.expect("an unversioned remote version must commit");
let (fi, _, _) = set_disks
.get_object_fileinfo(
bucket,
object,
&ObjectOptions {
no_lock: true,
metadata_cache_safe: false,
..Default::default()
},
true,
false,
)
.await
.expect("committed unversioned transition metadata should be readable");
assert_eq!(fi.transition_version_id, None);
assert_eq!(fi.transition_version, None);
assert_eq!(fi.transition_version_state, rustfs_filemeta::TransitionVersionState::KnownDisabled);
assert_eq!(save_probe.attempts(), 1);
assert_eq!(backend.remove_count().await, 0);
assert_eq!(backend.object_count().await, 1);
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn opaque_remote_version_is_cleaned_before_parse_failure() { async fn opaque_remote_version_is_cleaned_before_parse_failure() {
@@ -6630,7 +6882,7 @@ mod transition_upload_integrity_tests {
set_disks set_disks
.transition_object(bucket, object, &transition_options(&original, tier_name)) .transition_object(bucket, object, &transition_options(&original, tier_name))
.await .await
.expect_err("an unparseable remote version must fail closed"); .expect_err("an opaque remote version must fail closed until the capability gate is active");
let removed_versions = backend.remove_versions().await; let removed_versions = backend.remove_versions().await;
assert_eq!(removed_versions.len(), 1); assert_eq!(removed_versions.len(), 1);
assert_eq!(removed_versions[0].1, "opaque-version-token"); assert_eq!(removed_versions[0].1, "opaque-version-token");
@@ -6638,6 +6890,38 @@ mod transition_upload_integrity_tests {
assert_local_source_intact(&set_disks, bucket, object, &payload).await; assert_local_source_intact(&set_disks, bucket, object, &payload).await;
} }
#[tokio::test]
#[serial_test::serial]
async fn nil_remote_version_is_cleaned_exactly_before_transaction_persistence() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "transition-nil-version-bucket";
let object = "object.bin";
let payload = b"nil remote version must retain local data".repeat(1024);
let original = write_source(&set_disks, &disk_stores, bucket, object, &payload).await;
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let remote_version = Uuid::nil().to_string();
let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
backend.set_put_remote_version(Some(remote_version.clone())).await;
let save_probe = TransitionUploadedSaveProbe::install(bucket, object);
set_disks
.transition_object(bucket, object, &transition_options(&original, tier_name))
.await
.expect_err("a nil remote version must fail closed before transaction persistence");
let put_versions = backend.put_versions().await;
let removed_versions = backend.remove_versions().await;
assert_eq!(removed_versions, put_versions);
assert_eq!(removed_versions.len(), 1);
assert_eq!(
removed_versions.first().map(|(_, version)| version.as_str()),
Some(remote_version.as_str())
);
assert_eq!(save_probe.attempts(), 0, "nil remote version must be rejected before saving Uploaded");
assert_eq!(backend.exact_remove_count(), 1);
assert_eq!(backend.object_count().await, 0);
assert_local_source_intact(&set_disks, bucket, object, &payload).await;
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn authoritative_read_failure_after_upload_cleans_exact_candidate_and_preserves_source() { async fn authoritative_read_failure_after_upload_cleans_exact_candidate_and_preserves_source() {
@@ -6957,7 +7241,6 @@ mod transition_source_identity_matrix_tests {
async fn transition_source_identity_field_matrix_rejects_single_field_drift() { async fn transition_source_identity_field_matrix_rejects_single_field_drift() {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
enum IdentityField { enum IdentityField {
VersionId,
DataDir, DataDir,
ModTime, ModTime,
Size, Size,
@@ -6973,7 +7256,6 @@ mod transition_source_identity_matrix_tests {
let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await; let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
for (index, field) in [ for (index, field) in [
IdentityField::VersionId,
IdentityField::DataDir, IdentityField::DataDir,
IdentityField::ModTime, IdentityField::ModTime,
IdentityField::Size, IdentityField::Size,
@@ -6985,23 +7267,36 @@ mod transition_source_identity_matrix_tests {
let object = format!("identity-{index}.bin"); let object = format!("identity-{index}.bin");
let payload = vec![u8::try_from(index + 1).expect("matrix index should fit u8"); 1024 * 1024]; let payload = vec![u8::try_from(index + 1).expect("matrix index should fit u8"); 1024 * 1024];
let mut reader = PutObjReader::from_vec(payload); let mut reader = PutObjReader::from_vec(payload);
let source_version_id = Uuid::new_v4();
let source_opts = ObjectOptions {
version_id: Some(source_version_id.to_string()),
versioned: true,
..Default::default()
};
let original = set_disks let original = set_disks
.put_object(bucket, &object, &mut reader, &ObjectOptions::default()) .put_object(bucket, &object, &mut reader, &source_opts)
.await .await
.expect("source object should be written"); .expect("source object should be written");
let (source, _, _) = set_disks let (source, _, _) = set_disks
.get_object_fileinfo(bucket, &object, &ObjectOptions::default(), true, false) .get_object_fileinfo(bucket, &object, &source_opts, true, false)
.await .await
.expect("source metadata should resolve"); .expect("source metadata should resolve");
assert_eq!(source.version_id, Some(source_version_id));
assert_eq!(
transition_source_identity(bucket, &object, &source, &source_opts, &get_raw_etag(&source.metadata))
.expect("persisted versioned source identity should build")
.version_mode,
TransitionSourceVersionMode::Versioned
);
let opts = ObjectOptions { let opts = ObjectOptions {
no_lock: true, no_lock: true,
versioned: true,
transition: TransitionOptions { transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(), status: TRANSITION_PENDING.to_string(),
tier: tier_name.clone(), tier: tier_name.clone(),
etag: original.etag.clone().unwrap_or_default(), etag: original.etag.clone().unwrap_or_default(),
..Default::default() ..Default::default()
}, },
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time, mod_time: original.mod_time,
..Default::default() ..Default::default()
}; };
@@ -7014,7 +7309,6 @@ mod transition_source_identity_matrix_tests {
let mut changed = source.clone(); let mut changed = source.clone();
match field { match field {
IdentityField::VersionId => changed.version_id = Some(Uuid::new_v4()),
IdentityField::DataDir => changed.data_dir = Some(Uuid::new_v4()), IdentityField::DataDir => changed.data_dir = Some(Uuid::new_v4()),
IdentityField::ModTime => { IdentityField::ModTime => {
changed.mod_time = changed.mod_time.map(|value| value + time::Duration::nanoseconds(1)); changed.mod_time = changed.mod_time.map(|value| value + time::Duration::nanoseconds(1));
@@ -7032,12 +7326,19 @@ mod transition_source_identity_matrix_tests {
.await .await
.expect("single-field metadata drift should be written"); .expect("single-field metadata drift should be written");
} }
let persisted_opts = ObjectOptions {
version_id: changed.version_id.map(|version_id| version_id.to_string()),
versioned: true,
..Default::default()
};
let (persisted, _, _) = set_disks
.get_object_fileinfo(bucket, &object, &persisted_opts, true, false)
.await
.expect("drifted source metadata should resolve");
put_barrier.release(); put_barrier.release();
transition let result = transition.await.expect("transition task should not panic");
.await assert!(result.is_err(), "transition must reject {field:?} drift");
.expect("transition task should not panic")
.expect_err("transition must reject a source whose identity changed after upload");
let expected_attempts = index + 1; let expected_attempts = index + 1;
assert_eq!(backend.put_count().await, expected_attempts); assert_eq!(backend.put_count().await, expected_attempts);
assert_eq!(backend.remove_count().await, expected_attempts); assert_eq!(backend.remove_count().await, expected_attempts);
@@ -7048,26 +7349,23 @@ mod transition_source_identity_matrix_tests {
); );
match field { match field {
IdentityField::VersionId => assert_ne!(source.version_id, changed.version_id), IdentityField::DataDir => assert_ne!(source.data_dir, persisted.data_dir),
IdentityField::DataDir => assert_ne!(source.data_dir, changed.data_dir), IdentityField::ModTime => assert_ne!(source.mod_time, persisted.mod_time),
IdentityField::ModTime => assert_ne!(source.mod_time, changed.mod_time), IdentityField::Size => assert_ne!(source.size, persisted.size),
IdentityField::Size => assert_ne!(source.size, changed.size), IdentityField::Etag => assert_ne!(get_raw_etag(&source.metadata), get_raw_etag(&persisted.metadata)),
IdentityField::Etag => assert_ne!(get_raw_etag(&source.metadata), get_raw_etag(&changed.metadata)),
}
if !matches!(field, IdentityField::VersionId) {
assert_eq!(source.version_id, changed.version_id);
} }
assert_eq!(source.version_id, persisted.version_id);
if !matches!(field, IdentityField::DataDir) { if !matches!(field, IdentityField::DataDir) {
assert_eq!(source.data_dir, changed.data_dir); assert_eq!(source.data_dir, persisted.data_dir);
} }
if !matches!(field, IdentityField::ModTime) { if !matches!(field, IdentityField::ModTime) {
assert_eq!(source.mod_time, changed.mod_time); assert_eq!(source.mod_time, persisted.mod_time);
} }
if !matches!(field, IdentityField::Size) { if !matches!(field, IdentityField::Size) {
assert_eq!(source.size, changed.size); assert_eq!(source.size, persisted.size);
} }
if !matches!(field, IdentityField::Etag) { if !matches!(field, IdentityField::Etag) {
assert_eq!(get_raw_etag(&source.metadata), get_raw_etag(&changed.metadata)); assert_eq!(get_raw_etag(&source.metadata), get_raw_etag(&persisted.metadata));
} }
} }
} }
@@ -13,7 +13,10 @@
// limitations under the License. // limitations under the License.
use super::*; use super::*;
use crate::bucket::lifecycle::lifecycle;
use rustfs_filemeta::RestoreStatusOps;
use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE}; use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE};
use s3s::dto::{RestoreStatus, Timestamp};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct RestoreCleanupIdentity { struct RestoreCleanupIdentity {
@@ -43,6 +46,69 @@ impl RestoreCleanupIdentity {
} }
impl SetDisks { impl SetDisks {
pub(super) async fn finalize_restore_metadata(
&self,
bucket: &str,
object: &str,
obj_info: &ObjectInfo,
opts: &ObjectOptions,
) -> Result<ObjectInfo> {
let expected = RestoreCleanupIdentity::from_object_info(obj_info);
let expected_operation_id = restore_operation_id_from_metadata(&opts.user_defined)?;
let expected_etag = obj_info
.etag
.clone()
.unwrap_or_else(|| get_raw_etag(obj_info.user_defined.as_ref()));
let version_id = expected.version_id.map(|v| v.to_string());
let _lock_guard = if !opts.no_lock {
Some(
self.acquire_write_lock_diag("restore_finalize_metadata", bucket, object)
.await?,
)
} else {
None
};
let read_opts = ObjectOptions {
version_id,
versioned: opts.versioned,
version_suspended: opts.version_suspended,
..Default::default()
};
let (mut fi, _, disks) = self
.get_object_fileinfo_gated(bucket, object, &read_opts, false, false)
.await?;
if let Some(expected_operation_id) = expected_operation_id {
require_restore_operation_id(&fi.metadata, expected_operation_id)?;
}
if !expected.matches_file_info(&fi, &expected_etag) {
return Err(Error::other("restored object changed before restore metadata finalization"));
}
let restore_expiry =
lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), opts.transition.restore_request.days.unwrap_or(1));
fi.metadata.insert(
X_AMZ_RESTORE.as_str().to_string(),
RestoreStatus {
is_restore_in_progress: Some(false),
restore_expiry_date: Some(Timestamp::from(restore_expiry)),
}
.to_string(),
);
self.invalidate_get_object_metadata_cache(bucket, object).await;
self.update_object_meta_with_opts(
bucket,
object,
fi.clone(),
disks.as_slice(),
&UpdateMetadataOpts {
replace_user_metadata: true,
..Default::default()
},
)
.await?;
self.invalidate_get_object_metadata_cache(bucket, object).await;
Ok(ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended))
}
pub async fn update_restore_metadata( pub async fn update_restore_metadata(
&self, &self,
bucket: &str, bucket: &str,
@@ -13,10 +13,11 @@
// limitations under the License. // limitations under the License.
use super::*; use super::*;
use crate::bucket::lifecycle::lifecycle::{TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions}; use crate::bucket::lifecycle::lifecycle::{TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time};
use crate::ecstore_validation_blackbox::make_local_set_disks; use crate::ecstore_validation_blackbox::make_local_set_disks;
use crate::services::tier::test_util::register_mock_tier; use crate::services::tier::test_util::register_mock_tier;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
use rustfs_filemeta::{RestoreStatusOps as _, parse_restore_obj_status};
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
async fn prime_metadata_generation(set_disks: &SetDisks, bucket: &str, object: &str) -> GetObjectMetadataCacheKey { async fn prime_metadata_generation(set_disks: &SetDisks, bucket: &str, object: &str) -> GetObjectMetadataCacheKey {
@@ -83,13 +84,57 @@ async fn transition_and_restore_reclaim_prior_metadata_generations() {
let transitioned_generation = prime_metadata_generation(&set_disks, bucket, object).await; let transitioned_generation = prime_metadata_generation(&set_disks, bucket, object).await;
let mut restore_opts = ObjectOptions::default(); let mut restore_opts = ObjectOptions::default();
restore_opts.transition.restore_request.days = Some(1); restore_opts.transition.restore_request.days = Some(1);
Arc::clone(&set_disks) let restore_started = OffsetDateTime::now_utc();
.restore_transitioned_object(bucket, object, &restore_opts) let expiry_from_restore_start = temp_env::async_with_vars(
.await [
.expect("restore should succeed"); ("RUSTFS_ILM_DEBUG_DAY_SECS", Some("1")),
("RUSTFS_ILM_PROCESS_TIME", Some("1")),
],
async {
let expiry_from_restore_start = expected_expiry_time(restore_started, 1);
let get_barrier = backend.arm_get_barrier().await;
let restore_set = Arc::clone(&set_disks);
let restore =
tokio::spawn(async move { restore_set.restore_transitioned_object(bucket, object, &restore_opts).await });
get_barrier.wait_until_paused().await;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if expected_expiry_time(OffsetDateTime::now_utc(), 1) > expiry_from_restore_start {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("test clock should cross the next accelerated lifecycle boundary");
get_barrier.release();
restore
.await
.expect("restore task should join")
.expect("restore should succeed");
expiry_from_restore_start
},
)
.await;
assert_generation_reclaimed(&set_disks, &transitioned_generation).await; assert_generation_reclaimed(&set_disks, &transitioned_generation).await;
assert_eq!(backend.get_count().await, 1, "restore should read the remote candidate exactly once"); assert_eq!(backend.get_count().await, 1, "restore should read the remote candidate exactly once");
let restored_info = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("restored object metadata should be readable");
let restore_status = parse_restore_obj_status(
restored_info
.user_defined
.get(s3s::header::X_AMZ_RESTORE.as_str())
.expect("completed restore header should be present"),
)
.expect("completed restore header should parse");
assert!(
restore_status.expiry().expect("completed restore should have an expiry") > expiry_from_restore_start,
"restore expiry must be based on completion, not the time the remote copy started"
);
let mut restored = Vec::new(); let mut restored = Vec::new();
set_disks set_disks
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default()) .get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
+1 -1
View File
@@ -87,7 +87,7 @@ fn bucket_deleted_marker_volume(bucket: &str) -> String {
format!("{RUSTFS_META_BUCKET}/{}", bucket_deleted_marker_prefix(bucket)) format!("{RUSTFS_META_BUCKET}/{}", bucket_deleted_marker_prefix(bucket))
} }
async fn await_bucket_namespace_operation<T, F>( pub(crate) async fn await_bucket_namespace_operation<T, F>(
guard: Option<&rustfs_lock::NamespaceLockGuard>, guard: Option<&rustfs_lock::NamespaceLockGuard>,
bucket: &str, bucket: &str,
operation: &'static str, operation: &'static str,
+6 -2
View File
@@ -1311,14 +1311,16 @@ mod tests {
version_id: "version-a".to_string(), version_id: "version-a".to_string(),
tier_name: tier_a.to_string(), tier_name: tier_a.to_string(),
backend_identity: Some(identity_a), backend_identity: Some(identity_a),
version_id_exact: false, version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
}; };
let entry_b = Jentry { let entry_b = Jentry {
obj_name: "remote-b".to_string(), obj_name: "remote-b".to_string(),
version_id: "version-b".to_string(), version_id: "version-b".to_string(),
tier_name: tier_b.to_string(), tier_name: tier_b.to_string(),
backend_identity: Some(identity_b), backend_identity: Some(identity_b),
version_id_exact: false, version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
}; };
let remove_a = backend_a.arm_failing_remove_barrier().await; let remove_a = backend_a.arm_failing_remove_barrier().await;
persist_tier_delete_journal_entry(store_a.clone(), &entry_a) persist_tier_delete_journal_entry(store_a.clone(), &entry_a)
@@ -2720,10 +2722,12 @@ mod tests {
#[serial_test::serial(storage_class_env)] #[serial_test::serial(storage_class_env)]
async fn transition_transaction_recovery_deletes_provider_recovered_unknown_upload() { async fn transition_transaction_recovery_deletes_provider_recovered_unknown_upload() {
let versioned_remote = uuid::Uuid::new_v4().to_string(); let versioned_remote = uuid::Uuid::new_v4().to_string();
let nil_remote = uuid::Uuid::nil().to_string();
for (case, tier_name, remote_version) in [ for (case, tier_name, remote_version) in [
("missing", "TXPROBEMISSING", None), ("missing", "TXPROBEMISSING", None),
("unversioned", "TXPROBEUNVERSIONED", Some(String::new())), ("unversioned", "TXPROBEUNVERSIONED", Some(String::new())),
("versioned", "TXPROBEVERSIONED", Some(versioned_remote)), ("versioned", "TXPROBEVERSIONED", Some(versioned_remote)),
("nil-version", "TXPROBENILVERSION", Some(nil_remote)),
] { ] {
let temp_dir = tempfile::tempdir().expect("create temp store dir"); let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store( let (ctx, store, _shutdown) = without_storage_class_env(build_isolated_test_store(
+1
View File
@@ -141,6 +141,7 @@ fn should_enqueue_transition_immediately(oi: &ObjectInfo) -> bool {
const MAX_UPLOADS_LIST: usize = 10000; const MAX_UPLOADS_LIST: usize = 10000;
mod bucket; mod bucket;
pub(crate) use bucket::await_bucket_namespace_operation;
mod heal; mod heal;
mod heal_walk; mod heal_walk;
pub use heal_walk::HealWalkVersion; pub use heal_walk::HealWalkVersion;
+55
View File
@@ -219,6 +219,16 @@ impl ErasureInfo {
} }
// #[derive(Debug, Clone)] // #[derive(Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, Default)]
#[serde(rename_all = "kebab-case")]
pub enum TransitionVersionState {
#[default]
Unknown,
KnownDisabled,
SuspendedNull,
Exact,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)] #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
pub struct FileInfo { pub struct FileInfo {
pub volume: String, pub volume: String,
@@ -230,6 +240,10 @@ pub struct FileInfo {
pub transitioned_objname: String, pub transitioned_objname: String,
pub transition_tier: String, pub transition_tier: String,
pub transition_version_id: Option<Uuid>, pub transition_version_id: Option<Uuid>,
#[serde(default)]
pub transition_version: Option<String>,
#[serde(default)]
pub transition_version_state: TransitionVersionState,
pub expire_restored: bool, pub expire_restored: bool,
pub data_dir: Option<Uuid>, pub data_dir: Option<Uuid>,
pub mod_time: Option<OffsetDateTime>, pub mod_time: Option<OffsetDateTime>,
@@ -459,6 +473,10 @@ impl FileInfo {
if self.mod_time.is_none_or(|mod_time| mod_time <= OffsetDateTime::UNIX_EPOCH) if self.mod_time.is_none_or(|mod_time| mod_time <= OffsetDateTime::UNIX_EPOCH)
|| (!allow_nil_version_id && self.version_id.is_some_and(|version_id| version_id.is_nil())) || (!allow_nil_version_id && self.version_id.is_some_and(|version_id| version_id.is_nil()))
|| self.transition_version_id.is_some_and(|version_id| version_id.is_nil()) || self.transition_version_id.is_some_and(|version_id| version_id.is_nil())
|| self
.transition_version
.as_ref()
.is_some_and(|version_id| version_id.is_empty())
|| self.size != 0 || self.size != 0
|| self.data_dir.is_some() || self.data_dir.is_some()
|| self.mode.is_some() || self.mode.is_some()
@@ -492,6 +510,7 @@ impl FileInfo {
|| !self.transitioned_objname.is_empty() || !self.transitioned_objname.is_empty()
|| !self.transition_tier.is_empty() || !self.transition_tier.is_empty()
|| self.transition_version_id.is_some() || self.transition_version_id.is_some()
|| self.transition_version.is_some()
|| self.expire_restored || self.expire_restored
|| self.size != 0 || self.size != 0
|| self.data_dir.is_some() || self.data_dir.is_some()
@@ -536,6 +555,25 @@ impl FileInfo {
/// return `None`. /// return `None`.
pub fn validate(&self, mode: ValidationMode) -> Result<Option<ValidatedErasureLayout>> { pub fn validate(&self, mode: ValidationMode) -> Result<Option<ValidatedErasureLayout>> {
self.validate_collection_bounds()?; self.validate_collection_bounds()?;
if let (Some(version), Some(version_id)) = (&self.transition_version, self.transition_version_id)
&& Uuid::parse_str(version).ok() != Some(version_id)
{
return Err(Error::FileCorrupt);
}
let transition_state_valid = match self.transition_version_state {
TransitionVersionState::Unknown => true,
TransitionVersionState::KnownDisabled => self.transition_version.is_none() && self.transition_version_id.is_none(),
TransitionVersionState::SuspendedNull => {
self.transition_version.as_deref() == Some("null") && self.transition_version_id.is_none()
}
TransitionVersionState::Exact => self
.transition_version
.as_deref()
.is_some_and(|version| version != "null" && !version.is_empty()),
};
if !transition_state_valid {
return Err(Error::FileCorrupt);
}
let erasure_layout = match mode { let erasure_layout = match mode {
ValidationMode::RequireErasure => Some(self.validate_erasure_geometry()?), ValidationMode::RequireErasure => Some(self.validate_erasure_geometry()?),
@@ -832,6 +870,8 @@ impl FileInfo {
&& self.transition_tier == other.transition_tier && self.transition_tier == other.transition_tier
&& self.transitioned_objname == other.transitioned_objname && self.transitioned_objname == other.transitioned_objname
&& self.transition_version_id == other.transition_version_id && self.transition_version_id == other.transition_version_id
&& self.transition_version == other.transition_version
&& self.transition_version_state == other.transition_version_state
} }
/// Check if metadata maps are equal /// Check if metadata maps are equal
@@ -1351,6 +1391,15 @@ mod tests {
assert_file_corrupt(&fi, ValidationMode::DeleteOnly); assert_file_corrupt(&fi, ValidationMode::DeleteOnly);
} }
#[test]
fn metadata_read_validation_rejects_conflicting_transition_versions() {
let mut fi = one_shard_validation_fileinfo(1);
fi.transition_version_id = Some(Uuid::new_v4());
fi.transition_version = Some(Uuid::new_v4().to_string());
assert_file_corrupt(&fi, ValidationMode::RequireErasure);
}
#[test] #[test]
fn metadata_read_validation_requires_canonical_delete_marker_shape() { fn metadata_read_validation_requires_canonical_delete_marker_shape() {
let marker = FileInfo { let marker = FileInfo {
@@ -1722,6 +1771,12 @@ mod tests {
transitioned_objname, transitioned_objname,
transition_tier, transition_tier,
transition_version_id, transition_version_id,
transition_version: transition_version_id.map(|version_id| version_id.to_string()),
transition_version_state: if transition_version_id.is_some() {
TransitionVersionState::Exact
} else {
TransitionVersionState::Unknown
},
expire_restored, expire_restored,
data_dir, data_dir,
mod_time, mod_time,
+59
View File
@@ -356,6 +356,14 @@ impl FileMeta {
.versions .versions
.partition_point(|existing| existing.header.sorts_before(&new_shallow.header)); .partition_point(|existing| existing.header.sorts_before(&new_shallow.header));
self.versions.insert(insert_pos, new_shallow); self.versions.insert(insert_pos, new_shallow);
// `partition_point` only returns the canonical slot when `versions` is
// already canonically ordered, and nothing establishes that: `FileMeta::load`
// replays the on-disk order verbatim, and metadata written before the
// canonical-order fix ordered equal-`mod_time` ties by insertion rather than
// by `sorts_before`. Re-sort so an insert leaves the list canonical either
// way, matching what `set_idx` already does on the replace path. Cheap: after
// a correctly placed insert the slice is a single sorted run.
self.sort_by_mod_time();
Ok(()) Ok(())
// if !ver.valid() { // if !ver.valid() {
@@ -1215,6 +1223,57 @@ mod test {
assert!(fm.versions[0].header.sorts_before(&fm.versions[1].header)); assert!(fm.versions[0].header.sorts_before(&fm.versions[1].header));
} }
/// `add_version_filemata` positions an inserted version with
/// `partition_point(sorts_before)`, which only yields the canonical slot when
/// `versions` is already canonically ordered. Nothing establishes that
/// precondition on load: `FileMeta::unmarshal_msg` pushes versions in file
/// order, and metadata written before the canonical-order fix ordered
/// equal-`mod_time` ties by insertion instead of by `sorts_before`. An insert
/// into such a list must still leave it canonical, the same way `set_idx`
/// already re-sorts on the replace path.
#[test]
fn add_version_filemata_canonicalizes_versions_loaded_out_of_order() {
let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid test timestamp");
let first = version_for_ordering(VersionType::Object, Uuid::from_u128(70), mod_time, 70);
let second = version_for_ordering(VersionType::Object, Uuid::from_u128(80), mod_time, 80);
let (early, late) = if first.header().sorts_before(&second.header()) {
(first, second)
} else {
(second, first)
};
// Persist the pair the wrong way round to emulate a pre-canonical-order
// xl.meta, bypassing add_version_filemata so the bad order really lands on
// the wire.
let mut legacy = FileMeta::new();
legacy
.versions
.push(FileMetaShallowVersion::try_from(late).expect("shallow later version"));
legacy
.versions
.push(FileMetaShallowVersion::try_from(early).expect("shallow earlier version"));
let mut loaded =
FileMeta::load(&legacy.marshal_msg().expect("serialize legacy metadata")).expect("reload legacy metadata");
assert!(
!loaded.versions[0].header.sorts_before(&loaded.versions[1].header),
"fixture must reach add_version_filemata non-canonically ordered"
);
loaded
.add_version_filemata(version_for_ordering(VersionType::Delete, Uuid::from_u128(90), mod_time, 90))
.expect("insert equal-time delete marker");
assert_eq!(loaded.versions.len(), 3);
assert!(
loaded
.versions
.windows(2)
.all(|pair| pair[0].header.sorts_before(&pair[1].header)),
"insert must leave the version list canonically ordered"
);
}
/// Regression for backlog#799 B16: replication reset state must be /// Regression for backlog#799 B16: replication reset state must be
/// persisted under both internal prefixes, never as a bare ARN. A bare-ARN /// persisted under both internal prefixes, never as a bare ARN. A bare-ARN
/// key (produced by `ObjectInfo::replication_state`) has no internal prefix, /// key (produced by `ObjectInfo::replication_state`) has no internal prefix,
+250 -41
View File
@@ -26,13 +26,14 @@ use super::msgp_decode::{
PrependByteReader, prealloc_hint, read_exact_vec, read_nil_or_array_len, read_nil_or_map_len, skip_msgp_value, PrependByteReader, prealloc_hint, read_exact_vec, read_nil_or_array_len, read_nil_or_map_len, skip_msgp_value,
}; };
use super::*; use super::*;
use crate::ChecksumInfo; use crate::{ChecksumInfo, TransitionVersionState};
use rustfs_utils::HashAlgorithm; use rustfs_utils::HashAlgorithm;
use rustfs_utils::http::{ use rustfs_utils::http::{
RUSTFS_INTERNAL_PREFIX, SUFFIX_CRC, SUFFIX_FREE_VERSION, SUFFIX_INLINE_DATA, SUFFIX_PURGESTATUS, SUFFIX_TIER_FV_ID, RUSTFS_INTERNAL_PREFIX, SUFFIX_CRC, SUFFIX_FREE_VERSION, SUFFIX_INLINE_DATA, SUFFIX_PURGESTATUS, SUFFIX_TIER_FV_ID,
SUFFIX_TIER_FV_MARKER, SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER_DESTINATION_ID, SUFFIX_TIER_FV_MARKER, SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER_DESTINATION_ID,
SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, contains_key_bytes, get_bytes, get_consistent_bytes, get_str, SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_STATE, contains_key_bytes,
has_internal_suffix, insert_bytes, is_internal_key, remove_bytes, strip_internal_prefix, get_bytes, get_consistent_bytes, get_str, has_internal_suffix, insert_bytes, is_internal_key, remove_bytes,
strip_internal_prefix,
}; };
const MSGPACK_EXT8: u8 = 0xc7; const MSGPACK_EXT8: u8 = 0xc7;
@@ -43,6 +44,7 @@ const MSGPACK_FIXEXT8: u8 = 0xd7;
const MSGPACK_TIME_EXT_LEGACY: i8 = 5; const MSGPACK_TIME_EXT_LEGACY: i8 = 5;
const MSGPACK_TIME_EXT_OFFICIAL: i8 = -1; const MSGPACK_TIME_EXT_OFFICIAL: i8 = -1;
const MSGPACK_TIME_LEN: u8 = 12; const MSGPACK_TIME_LEN: u8 = 12;
const MAX_TRANSITION_VERSION_LEN: usize = 1024;
/// Sentinel signature returned when a version has no computable body (invalid / /// Sentinel signature returned when a version has no computable body (invalid /
/// missing inner object). Mirrors MinIO's `signatureErr` so such versions never /// missing inner object). Mirrors MinIO's `signatureErr` so such versions never
@@ -251,23 +253,93 @@ fn parse_legacy_uuid_bytes(bytes: &[u8], field: &str) -> Result<Option<Uuid>> {
/// Decode a stored transitioned-version-id from a version's `meta_sys`. /// Decode a stored transitioned-version-id from a version's `meta_sys`.
/// ///
/// RustFS writes it as 16 raw UUID bytes; MinIO-migrated tiered objects store /// Legacy RustFS writes used 16 raw UUID bytes. New writes and MinIO-migrated
/// the remote tier's version id as a UUID *string*. Accept both, and treat any /// records use the provider's exact UTF-8 version text. Empty, nil UUID, and
/// absent / nil / otherwise-unparseable value as "no tier version" (matching the /// malformed bytes are not usable remote versions.
/// tolerant pre-hardening behavior) rather than failing the whole object read — fn transitioned_version_from_meta_sys(meta_sys: &HashMap<String, Vec<u8>>) -> Result<Option<String>> {
/// a malformed tier id must not make an otherwise-readable object unreadable. if !contains_key_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_ID) {
fn transitioned_version_id_from_meta_sys(meta_sys: &HashMap<String, Vec<u8>>) -> Option<Uuid> { return Ok(None);
let value = get_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_ID)?; }
let Some(value) = get_consistent_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_ID) else {
return Ok(None);
};
let value = value.to_vec();
if value.is_empty() { if value.is_empty() {
return None; return Ok(None);
} }
if let Ok(id) = Uuid::from_slice(&value) { if let Ok(id) = Uuid::from_slice(&value) {
return (!id.is_nil()).then_some(id); return Ok((!id.is_nil()).then(|| id.to_string()));
} }
std::str::from_utf8(&value) let Ok(value) = String::from_utf8(value) else {
return Ok(None);
};
if value.is_empty()
|| value.len() > MAX_TRANSITION_VERSION_LEN
|| value.chars().any(char::is_control)
|| Uuid::parse_str(&value).is_ok_and(|id| id.is_nil())
{
Ok(None)
} else {
Ok(Some(value))
}
}
fn transition_version_state_from_meta_sys(
meta_sys: &HashMap<String, Vec<u8>>,
version: Option<&str>,
) -> Result<TransitionVersionState> {
if !contains_key_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE) {
return Ok(TransitionVersionState::Unknown);
}
let value = get_consistent_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE).ok_or(Error::FileCorrupt)?;
let state = match value {
b"known-disabled" => TransitionVersionState::KnownDisabled,
b"suspended-null" => TransitionVersionState::SuspendedNull,
b"exact" => TransitionVersionState::Exact,
b"unknown" => TransitionVersionState::Unknown,
_ => return Err(Error::FileCorrupt),
};
let valid = match state {
TransitionVersionState::Unknown | TransitionVersionState::KnownDisabled => version.is_none(),
TransitionVersionState::SuspendedNull => version == Some("null"),
TransitionVersionState::Exact => version.is_some_and(|value| value != "null"),
};
valid.then_some(state).ok_or(Error::FileCorrupt)
}
fn transition_version_state_bytes(state: TransitionVersionState) -> &'static [u8] {
match state {
TransitionVersionState::Unknown => b"unknown",
TransitionVersionState::KnownDisabled => b"known-disabled",
TransitionVersionState::SuspendedNull => b"suspended-null",
TransitionVersionState::Exact => b"exact",
}
}
fn set_transition_version_state(meta_sys: &mut HashMap<String, Vec<u8>>, state: TransitionVersionState) {
if state == TransitionVersionState::Unknown {
remove_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE);
} else {
insert_bytes(
meta_sys,
SUFFIX_TRANSITIONED_VERSION_STATE,
transition_version_state_bytes(state).to_vec(),
);
}
}
fn legacy_transitioned_version_id_from_meta_sys(meta_sys: &HashMap<String, Vec<u8>>) -> Option<Uuid> {
transitioned_version_from_meta_sys(meta_sys)
.ok() .ok()
.and_then(|s| Uuid::parse_str(s.trim()).ok()) .flatten()
.filter(|id| !id.is_nil()) .and_then(|value| Uuid::parse_str(&value).ok())
}
fn transitioned_version_bytes(fi: &FileInfo) -> Option<Vec<u8>> {
fi.transition_version
.as_ref()
.map(|version| version.as_bytes().to_vec())
.or_else(|| fi.transition_version_id.map(|version_id| version_id.as_bytes().to_vec()))
} }
fn parse_legacy_erasure_algo(value: &str) -> ErasureAlgo { fn parse_legacy_erasure_algo(value: &str) -> ErasureAlgo {
@@ -2398,7 +2470,9 @@ impl MetaObject {
let transitioned_objname = get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME) let transitioned_objname = get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME)
.map(|v| String::from_utf8_lossy(&v).to_string()) .map(|v| String::from_utf8_lossy(&v).to_string())
.unwrap_or_default(); .unwrap_or_default();
let transition_version_id = transitioned_version_id_from_meta_sys(&self.meta_sys); let transition_version = transitioned_version_from_meta_sys(&self.meta_sys)?;
let transition_version_state = transition_version_state_from_meta_sys(&self.meta_sys, transition_version.as_deref())?;
let transition_version_id = transition_version.as_deref().and_then(|value| Uuid::parse_str(value).ok());
let transition_tier = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER) let transition_tier = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER)
.map(|v| String::from_utf8_lossy(&v).to_string()) .map(|v| String::from_utf8_lossy(&v).to_string())
.unwrap_or_default(); .unwrap_or_default();
@@ -2419,6 +2493,8 @@ impl MetaObject {
transition_status, transition_status,
transitioned_objname, transitioned_objname,
transition_version_id, transition_version_id,
transition_version,
transition_version_state,
transition_tier, transition_tier,
..Default::default() ..Default::default()
}) })
@@ -2431,13 +2507,12 @@ impl MetaObject {
SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_OBJECTNAME,
fi.transitioned_objname.as_bytes().to_vec(), fi.transitioned_objname.as_bytes().to_vec(),
); );
if let Some(transition_version_id) = fi.transition_version_id.as_ref() { if let Some(transition_version) = transitioned_version_bytes(fi) {
insert_bytes( insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version);
&mut self.meta_sys, } else {
SUFFIX_TRANSITIONED_VERSION_ID, remove_bytes(&mut self.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID);
transition_version_id.as_bytes().to_vec(),
);
} }
set_transition_version_state(&mut self.meta_sys, fi.transition_version_state);
insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_TIER, fi.transition_tier.as_bytes().to_vec()); insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_TIER, fi.transition_tier.as_bytes().to_vec());
if let Some(destination_id) = get_str(&fi.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID) { if let Some(destination_id) = get_str(&fi.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID) {
insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID, destination_id.into_bytes()); insert_bytes(&mut self.meta_sys, SUFFIX_TRANSITION_TIER_DESTINATION_ID, destination_id.into_bytes());
@@ -2501,6 +2576,7 @@ impl MetaObject {
SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER,
SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_OBJECTNAME,
SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_ID,
SUFFIX_TRANSITIONED_VERSION_STATE,
] { ] {
if let Some(v) = get_bytes(&self.meta_sys, suffix) { if let Some(v) = get_bytes(&self.meta_sys, suffix) {
insert_bytes(&mut delete_marker.meta_sys, suffix, v); insert_bytes(&mut delete_marker.meta_sys, suffix, v);
@@ -2562,8 +2638,11 @@ impl From<FileInfo> for MetaObject {
); );
} }
if let Some(vid) = &value.transition_version_id { if let Some(transition_version) = transitioned_version_bytes(&value) {
insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, vid.as_bytes().to_vec()); insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version);
}
if !value.transition_status.is_empty() {
set_transition_version_state(&mut meta_sys, value.transition_version_state);
} }
if !value.transition_tier.is_empty() { if !value.transition_tier.is_empty() {
@@ -2706,7 +2785,11 @@ impl MetaDeleteMarker {
.map(|v| String::from_utf8_lossy(&v).to_string()) .map(|v| String::from_utf8_lossy(&v).to_string())
.unwrap_or_default(); .unwrap_or_default();
fi.transition_version_id = transitioned_version_id_from_meta_sys(&self.meta_sys); fi.transition_version = transitioned_version_from_meta_sys(&self.meta_sys).ok().flatten();
fi.transition_version_id = legacy_transitioned_version_id_from_meta_sys(&self.meta_sys);
fi.transition_version_state =
transition_version_state_from_meta_sys(&self.meta_sys, fi.transition_version.as_deref())
.unwrap_or(TransitionVersionState::Unknown);
} }
fi fi
@@ -2859,8 +2942,11 @@ impl From<FileInfo> for MetaDeleteMarker {
value.transitioned_objname.as_bytes().to_vec(), value.transitioned_objname.as_bytes().to_vec(),
); );
} }
if let Some(version_id) = value.transition_version_id { if let Some(transition_version) = transitioned_version_bytes(&value) {
insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, version_id.as_bytes().to_vec()); insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, transition_version);
}
if !value.transition_status.is_empty() || value.tier_free_version() {
set_transition_version_state(&mut meta_sys, value.transition_version_state);
} }
if !value.transition_tier.is_empty() { if !value.transition_tier.is_empty() {
insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_TIER, value.transition_tier.as_bytes().to_vec()); insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_TIER, value.transition_tier.as_bytes().to_vec());
@@ -3412,7 +3498,7 @@ mod tests {
.insert("x-rustfs-internal-healing".to_string(), "true".to_string()); .insert("x-rustfs-internal-healing".to_string(), "true".to_string());
marker.metadata.insert("content-type".to_string(), "text/plain".to_string()); marker.metadata.insert("content-type".to_string(), "text/plain".to_string());
let remote_version_id = Uuid::new_v4(); let remote_version_id = Uuid::new_v4();
marker.transition_version_id = Some(remote_version_id); marker.transition_version = Some(remote_version_id.to_string());
let converted = MetaDeleteMarker::from(marker); let converted = MetaDeleteMarker::from(marker);
@@ -3420,7 +3506,19 @@ mod tests {
assert_eq!(converted.meta_sys.get("x-minio-internal-purgestatus"), Some(&b"pending".to_vec())); assert_eq!(converted.meta_sys.get("x-minio-internal-purgestatus"), Some(&b"pending".to_vec()));
assert_eq!( assert_eq!(
get_bytes(&converted.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID), get_bytes(&converted.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID),
Some(remote_version_id.as_bytes().to_vec()) Some(remote_version_id.to_string().into_bytes())
);
assert_eq!(
converted
.meta_sys
.get(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_ID}")),
Some(&remote_version_id.to_string().into_bytes())
);
assert_eq!(
converted
.meta_sys
.get(&format!("{}{SUFFIX_TRANSITIONED_VERSION_ID}", rustfs_utils::http::MINIO_INTERNAL_PREFIX)),
Some(&remote_version_id.to_string().into_bytes())
); );
assert!(!converted.meta_sys.contains_key("x-rustfs-internal-healing")); assert!(!converted.meta_sys.contains_key("x-rustfs-internal-healing"));
assert!(!converted.meta_sys.contains_key("content-type")); assert!(!converted.meta_sys.contains_key("content-type"));
@@ -4097,19 +4195,129 @@ mod tests {
.into_fileinfo("b", "k", false) .into_fileinfo("b", "k", false)
.expect("into_fileinfo"); .expect("into_fileinfo");
assert_eq!(fi.transition_version_id, Some(id)); assert_eq!(fi.transition_version_id, Some(id));
assert_eq!(fi.transition_version, Some(id.to_string()));
assert_eq!(fi.transition_version_state, TransitionVersionState::Unknown);
} }
#[test] #[test]
fn meta_object_transition_version_id_unparseable_stays_readable_as_none() { fn meta_object_transition_version_id_opaque_text_is_preserved() {
// A non-UUID / non-16-byte tier version id must NOT make the object
// unreadable; it is tolerated as "no tier version" (compat with
// pre-hardening behavior and foreign/edge metadata).
let mut sys = HashMap::new(); let mut sys = HashMap::new();
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"not-a-uuid".to_vec()); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"opaque-generation-42".to_vec());
let fi = make_meta_object_with_sys(sys) let fi = make_meta_object_with_sys(sys)
.into_fileinfo("b", "k", false) .into_fileinfo("b", "k", false)
.expect("unparseable transition version id must not fail the object read"); .expect("opaque transition version id must decode");
assert_eq!(fi.transition_version_id, None); assert_eq!(fi.transition_version_id, None);
assert_eq!(fi.transition_version.as_deref(), Some("opaque-generation-42"));
assert_eq!(fi.transition_version_state, TransitionVersionState::Unknown);
}
#[test]
fn meta_object_transition_version_state_exact_round_trips_dual_keys() {
let id = sample_version_id();
let expected_version = id.to_string();
let fi = FileInfo {
transition_status: "complete".to_string(),
transition_version: Some(expected_version.clone()),
transition_version_state: TransitionVersionState::Exact,
..Default::default()
};
let object = MetaObject::from(fi);
assert_eq!(
object
.meta_sys
.get(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_STATE}"))
.map(Vec::as_slice),
Some(b"exact".as_slice())
);
assert_eq!(
object
.meta_sys
.get(&format!(
"{}{SUFFIX_TRANSITIONED_VERSION_STATE}",
rustfs_utils::http::MINIO_INTERNAL_PREFIX
))
.map(Vec::as_slice),
Some(b"exact".as_slice())
);
assert_eq!(
legacy_transitioned_version_id_from_meta_sys(&object.meta_sys),
Some(id),
"UUID exact writes must remain readable by the legacy UUID consumer"
);
let decoded = object.into_fileinfo("b", "k", false).expect("exact state should round trip");
assert_eq!(decoded.transition_version_state, TransitionVersionState::Exact);
assert_eq!(decoded.transition_version.as_deref(), Some(expected_version.as_str()));
}
#[test]
fn set_transition_known_disabled_removes_stale_version_dual_keys() {
let mut meta_sys = HashMap::new();
insert_bytes(&mut meta_sys, SUFFIX_TRANSITIONED_VERSION_ID, b"stale-legacy-version".to_vec());
let mut object = make_meta_object_with_sys(meta_sys);
object.set_transition(&FileInfo {
transition_status: TRANSITION_COMPLETE.to_string(),
transitioned_objname: "remote/object".to_string(),
transition_version_state: TransitionVersionState::KnownDisabled,
transition_tier: "WARM".to_string(),
..Default::default()
});
assert_eq!(get_bytes(&object.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID), None);
assert!(
!object
.meta_sys
.contains_key(&format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_ID}"))
);
assert!(
!object
.meta_sys
.contains_key(&format!("{}{SUFFIX_TRANSITIONED_VERSION_ID}", rustfs_utils::http::MINIO_INTERNAL_PREFIX))
);
let decoded = object
.into_fileinfo("b", "k", false)
.expect("known-disabled transition must remain readable after replacing stale metadata");
assert_eq!(decoded.transition_version, None);
assert_eq!(decoded.transition_version_state, TransitionVersionState::KnownDisabled);
}
#[test]
fn meta_object_transition_version_state_conflict_fails_closed() {
let mut sys = HashMap::new();
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, sample_version_id().as_bytes().to_vec());
sys.insert(format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_STATE}"), b"exact".to_vec());
sys.insert(
format!("{}{SUFFIX_TRANSITIONED_VERSION_STATE}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
b"known-disabled".to_vec(),
);
make_meta_object_with_sys(sys)
.into_fileinfo("b", "k", false)
.expect_err("conflicting state keys must fail closed");
}
#[test]
fn meta_object_transition_version_id_invalid_utf8_yields_none() {
let mut sys = HashMap::new();
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, vec![0xff]);
let fi = make_meta_object_with_sys(sys)
.into_fileinfo("b", "k", false)
.expect("invalid transition version bytes must not fail the object read");
assert_eq!(fi.transition_version_id, None);
assert_eq!(fi.transition_version, None);
}
#[test]
fn meta_object_transition_version_id_unsafe_text_yields_none() {
for value in [b"opaque\0version".to_vec(), vec![b'x'; MAX_TRANSITION_VERSION_LEN + 1]] {
let mut sys = HashMap::new();
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, value);
let fi = make_meta_object_with_sys(sys)
.into_fileinfo("b", "k", false)
.expect("unsafe transition version text must not fail the object read");
assert_eq!(fi.transition_version_id, None);
assert_eq!(fi.transition_version, None);
}
} }
#[test] #[test]
@@ -4123,6 +4331,7 @@ mod tests {
.into_fileinfo("b", "k", false) .into_fileinfo("b", "k", false)
.expect("string-form transition version id must decode"); .expect("string-form transition version id must decode");
assert_eq!(fi.transition_version_id, Some(id)); assert_eq!(fi.transition_version_id, Some(id));
assert_eq!(fi.transition_version, Some(id.to_string()));
} }
#[test] #[test]
@@ -4152,16 +4361,14 @@ mod tests {
} }
.into_fileinfo("b", "k", false); .into_fileinfo("b", "k", false);
assert_eq!(fi.transition_version_id, Some(id)); assert_eq!(fi.transition_version_id, Some(id));
assert_eq!(fi.transition_version, Some(id.to_string()));
} }
#[test] #[test]
fn delete_marker_free_version_transition_version_id_unparseable_stays_readable() { fn delete_marker_free_version_transition_version_id_opaque_text_is_preserved() {
// A malformed tier version id must not make a free-version record corrupt:
// it decodes to None and stays readable. Otherwise free-version expiry
// fails and the remote-tier object leaks.
let mut sys = HashMap::new(); let mut sys = HashMap::new();
insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]); insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"not-a-uuid".to_vec()); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, b"opaque-generation-42".to_vec());
insert_bytes(&mut sys, SUFFIX_TRANSITION_TIER, b"WARM".to_vec()); insert_bytes(&mut sys, SUFFIX_TRANSITION_TIER, b"WARM".to_vec());
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_OBJECTNAME, b"remote-object".to_vec()); insert_bytes(&mut sys, SUFFIX_TRANSITIONED_OBJECTNAME, b"remote-object".to_vec());
let fi = MetaDeleteMarker { let fi = MetaDeleteMarker {
@@ -4172,8 +4379,9 @@ mod tests {
.into_fileinfo("b", "k", false); .into_fileinfo("b", "k", false);
assert_eq!(fi.transition_version_id, None); assert_eq!(fi.transition_version_id, None);
assert_eq!(fi.transition_version.as_deref(), Some("opaque-generation-42"));
fi.validate_for_metadata_read() fi.validate_for_metadata_read()
.expect("free-version record with an unparseable tier id must remain readable"); .expect("free-version record with an opaque tier id must remain readable");
} }
#[test] #[test]
@@ -4193,6 +4401,7 @@ mod tests {
.into_fileinfo("b", "k", false); .into_fileinfo("b", "k", false);
assert_eq!(fi.transition_version_id, Some(id)); assert_eq!(fi.transition_version_id, Some(id));
assert_eq!(fi.transition_version, Some(id.to_string()));
} }
#[test] #[test]
+4 -1
View File
@@ -26,6 +26,9 @@ documentation = "https://docs.rs/rustfs-heal/latest/rustfs_heal/"
keywords = ["RustFS", "heal", "erasure-coding", "Minio"] keywords = ["RustFS", "heal", "erasure-coding", "Minio"]
categories = ["web-programming", "development-tools", "filesystem"] categories = ["web-programming", "development-tools", "filesystem"]
[lints]
workspace = true
[dependencies] [dependencies]
rustfs-config = { workspace = true } rustfs-config = { workspace = true }
rustfs-concurrency = { workspace = true } rustfs-concurrency = { workspace = true }
@@ -53,7 +56,7 @@ serial_test = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
walkdir = { workspace = true } walkdir = { workspace = true }
http = { workspace = true } http = { workspace = true }
temp-env = { workspace = true } temp-env = { workspace = true, features = ["async_closure"] }
tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] } tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] }
[lib] [lib]
+1 -1
View File
@@ -482,7 +482,7 @@ impl HealChannelProcessor {
request_id: client_token, request_id: client_token,
success: false, success: false,
data: None, data: None,
error: Some(error_text.clone()), error: Some(error_text),
}; };
let _ = response_tx.send(Ok(response.clone())); let _ = response_tx.send(Ok(response.clone()));
self.publish_response(response); self.publish_response(response);
+3 -3
View File
@@ -576,7 +576,7 @@ mod tests {
assert_eq!(handler.event_count(), 1); assert_eq!(handler.event_count(), 1);
handler.add_event(event.clone()); handler.add_event(event.clone());
handler.add_event(event.clone()); handler.add_event(event);
assert_eq!(handler.event_count(), 3); assert_eq!(handler.event_count(), 3);
} }
@@ -593,7 +593,7 @@ mod tests {
handler.add_event(event.clone()); handler.add_event(event.clone());
handler.add_event(event.clone()); handler.add_event(event.clone());
handler.add_event(event.clone()); // Should remove oldest handler.add_event(event); // Should remove oldest
assert_eq!(handler.event_count(), 2); assert_eq!(handler.event_count(), 2);
} }
@@ -610,7 +610,7 @@ mod tests {
}; };
handler.add_event(event.clone()); handler.add_event(event.clone());
handler.add_event(event.clone()); handler.add_event(event);
let events = handler.get_events(); let events = handler.get_events();
assert_eq!(events.len(), 2); assert_eq!(events.len(), 2);
+2 -2
View File
@@ -2892,7 +2892,7 @@ fn update_task_running_metric_for_task(active_heals: &HashMap<String, Arc<HealTa
gauge!( gauge!(
"rustfs_heal_task_running", "rustfs_heal_task_running",
"type" => type_label.to_string(), "type" => type_label.to_string(),
"set" => set_label.clone() "set" => set_label
) )
.set(count as f64); .set(count as f64);
} }
@@ -3482,7 +3482,7 @@ mod tests {
); );
assert_eq!(queue.push(blocked), QueuePushOutcome::Accepted); assert_eq!(queue.push(blocked), QueuePushOutcome::Accepted);
assert_eq!(queue.push(runnable.clone()), QueuePushOutcome::Accepted); assert_eq!(queue.push(runnable), QueuePushOutcome::Accepted);
let mut running = HashMap::new(); let mut running = HashMap::new();
running.insert("pool_0_set_1".to_string(), 1); running.insert("pool_0_set_1".to_string(), 1);
@@ -28,6 +28,7 @@ use rustfs_heal::heal::storage::{
}; };
use serial_test::serial; use serial_test::serial;
use std::{ use std::{
future::Future,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
}; };
@@ -52,15 +53,9 @@ fn versioned_test_data(seed: u8) -> Vec<u8> {
.collect() .collect()
} }
/// Disable the dangling-delete grace window so the destructive path is genuinely /// Disable the grace window while the destructive heal decision is evaluated.
/// LIVE in these tests: without the decision-1 guard, a recoverable version WOULD async fn with_dangling_grace_disabled<T>(future: impl Future<Output = T>) -> T {
/// be dangling-deleted here. With grace at its 1h default the delete path would be temp_env::async_with_vars([(GRACE_ENV, Some("0"))], future).await
/// masked and the test would prove nothing.
fn disable_dangling_grace() {
// Safe under nextest: each test runs in its own process and is `#[serial]`.
unsafe {
std::env::set_var(GRACE_ENV, "0");
}
} }
/// Build a real N-disk single-set `ECStore` + `ECStoreHealStorage` via the /// Build a real N-disk single-set `ECStore` + `ECStoreHealStorage` via the
@@ -250,7 +245,6 @@ mod serial_tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial] #[serial]
async fn union_meta_lost_data_present_is_repaired_not_destroyed() { async fn union_meta_lost_data_present_is_repaired_not_destroyed() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = heal_env_n(8).await; let (disk_paths, ecstore, heal_storage) = heal_env_n(8).await;
let bucket = "b920-meta-lost"; let bucket = "b920-meta-lost";
let object = "obj.bin"; let object = "obj.bin";
@@ -268,10 +262,10 @@ mod serial_tests {
} }
// Heal the version through the real heal storage (Deep). // Heal the version through the real heal storage (Deep).
let (_result, error) = heal_storage let (_result, error) =
.heal_object(bucket, object, Some(&v1), &deep_heal_opts()) with_dangling_grace_disabled(heal_storage.heal_object(bucket, object, Some(&v1), &deep_heal_opts()))
.await .await
.expect("heal_object call must not itself error"); .expect("heal_object call must not itself error");
assert!( assert!(
error.is_none(), error.is_none(),
"recoverable version must heal without error (must NOT be dangling-deleted): {error:?}" "recoverable version must heal without error (must NOT be dangling-deleted): {error:?}"
@@ -300,7 +294,6 @@ mod serial_tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial] #[serial]
async fn deep_heal_torn_minority_is_dangling_deleted_with_grace_zero() { async fn deep_heal_torn_minority_is_dangling_deleted_with_grace_zero() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = heal_env_n(4).await; let (disk_paths, ecstore, heal_storage) = heal_env_n(4).await;
let bucket = "b920-torn"; let bucket = "b920-torn";
let object = "obj.bin"; let object = "obj.bin";
@@ -318,10 +311,10 @@ mod serial_tests {
// is a real destructive action, not a no-op). // is a real destructive action, not a no-op).
assert_eq!(count_part_files(&object_dir(&disk_paths[0], bucket, object)), 1); assert_eq!(count_part_files(&object_dir(&disk_paths[0], bucket, object)), 1);
let (_result, error) = heal_storage let (_result, error) =
.heal_object(bucket, object, Some(&v1), &deep_heal_opts()) with_dangling_grace_disabled(heal_storage.heal_object(bucket, object, Some(&v1), &deep_heal_opts()))
.await .await
.expect("heal_object call must not itself error"); .expect("heal_object call must not itself error");
// A dangling delete reports the version as gone (FileVersionNotFound), // A dangling delete reports the version as gone (FileVersionNotFound),
// proving the destructive path fired for a genuinely torn write. // proving the destructive path fired for a genuinely torn write.
assert!(error.is_some(), "a torn (< data_blocks) version must NOT be silently treated as healed"); assert!(error.is_some(), "a torn (< data_blocks) version must NOT be silently treated as healed");
@@ -349,7 +342,6 @@ mod serial_tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial] #[serial]
async fn deep_heal_restores_subquorum_but_reconstructable_version_wider_set() { async fn deep_heal_restores_subquorum_but_reconstructable_version_wider_set() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = heal_env_n(8).await; let (disk_paths, ecstore, heal_storage) = heal_env_n(8).await;
let bucket = "b920-reconstruct"; let bucket = "b920-reconstruct";
let object = "obj.bin"; let object = "obj.bin";
@@ -373,10 +365,10 @@ mod serial_tests {
"disk-walk must enumerate the reconstructable sub-quorum version" "disk-walk must enumerate the reconstructable sub-quorum version"
); );
let (_result, error) = heal_storage let (_result, error) =
.heal_object(bucket, object, Some(&v1), &deep_heal_opts()) with_dangling_grace_disabled(heal_storage.heal_object(bucket, object, Some(&v1), &deep_heal_opts()))
.await .await
.expect("heal_object call must not itself error"); .expect("heal_object call must not itself error");
assert!(error.is_none(), "reconstructable version must heal cleanly: {error:?}"); assert!(error.is_none(), "reconstructable version must heal cleanly: {error:?}");
// part.* + xl.meta physically restored on the 4 wiped disks. // part.* + xl.meta physically restored on the 4 wiped disks.
@@ -463,7 +455,6 @@ mod serial_tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial] #[serial]
async fn offline_disk_during_walk_does_not_dangling_delete() { async fn offline_disk_during_walk_does_not_dangling_delete() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = heal_env_n(4).await; let (disk_paths, ecstore, heal_storage) = heal_env_n(4).await;
let bucket = "b920-offline"; let bucket = "b920-offline";
let object = "obj.bin"; let object = "obj.bin";
@@ -491,8 +482,7 @@ mod serial_tests {
remove: false, remove: false,
..Default::default() ..Default::default()
}; };
let (_result, error) = heal_storage let (_result, error) = with_dangling_grace_disabled(heal_storage.heal_object(bucket, object, Some(&v1), &normal_opts))
.heal_object(bucket, object, Some(&v1), &normal_opts)
.await .await
.expect("heal_object call must not itself error"); .expect("heal_object call must not itself error");
assert!(error.is_none(), "quorum-present object must not be destroyed: {error:?}"); assert!(error.is_none(), "quorum-present object must not be destroyed: {error:?}");
@@ -510,7 +500,6 @@ mod serial_tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial] #[serial]
async fn deep_heal_keeps_present_ec2_plus_2_shards_healthy() { async fn deep_heal_keeps_present_ec2_plus_2_shards_healthy() {
disable_dangling_grace();
let (disk_paths, ecstore, heal_storage) = heal_env_n(4).await; let (disk_paths, ecstore, heal_storage) = heal_env_n(4).await;
let bucket = "b1044-deep-verify"; let bucket = "b1044-deep-verify";
let object = "obj.bin"; let object = "obj.bin";
@@ -524,10 +513,10 @@ mod serial_tests {
assert_eq!(count_part_files(&object_dir(disk, bucket, object)), 1, "intact shard must remain present"); assert_eq!(count_part_files(&object_dir(disk, bucket, object)), 1, "intact shard must remain present");
} }
let (_result, error) = heal_storage let (_result, error) =
.heal_object(bucket, object, Some(&v1), &deep_heal_opts()) with_dangling_grace_disabled(heal_storage.heal_object(bucket, object, Some(&v1), &deep_heal_opts()))
.await .await
.expect("deep heal_object call must not itself error"); .expect("deep heal_object call must not itself error");
assert!(error.is_none(), "Deep heal must retain the three intact EC2+2 shards: {error:?}"); assert!(error.is_none(), "Deep heal must retain the three intact EC2+2 shards: {error:?}");
assert!( assert!(
+3 -1
View File
@@ -390,8 +390,10 @@ mod serial_tests {
// ─── 1️⃣ delete format.json on one disk ────────────── // ─── 1️⃣ delete format.json on one disk ──────────────
let format_path = disk_paths[0].join(".rustfs.sys").join("format.json"); let format_path = disk_paths[0].join(".rustfs.sys").join("format.json");
std::fs::remove_dir_all(&disk_paths[0]).expect("failed to delete all contents under disk_paths[0]"); let failed_disk_path = disk_paths[0].with_extension("failed");
std::fs::rename(&disk_paths[0], &failed_disk_path).expect("failed to detach disk_paths[0]");
std::fs::create_dir_all(&disk_paths[0]).expect("failed to recreate disk_paths[0] directory"); std::fs::create_dir_all(&disk_paths[0]).expect("failed to recreate disk_paths[0] directory");
assert!(!format_path.exists(), "replacement disk already contains format.json before heal");
println!("✅ Deleted format.json on disk: {:?}", disk_paths[0]); println!("✅ Deleted format.json on disk: {:?}", disk_paths[0]);
let (_format_result, format_error) = heal_storage.heal_format(false).await.expect("failed to run heal_format"); let (_format_result, format_error) = heal_storage.heal_format(false).await.expect("failed to run heal_format");
+3
View File
@@ -25,6 +25,9 @@ keywords = ["rustfs", "openstack", "keystone", "authentication", "s3"]
categories = ["authentication", "web-programming"] categories = ["authentication", "web-programming"]
authors.workspace = true authors.workspace = true
[lints]
workspace = true
[dependencies] [dependencies]
tokio = { workspace = true, features = ["rt", "sync"] } tokio = { workspace = true, features = ["rt", "sync"] }
reqwest = { workspace = true, features = ["json"] } reqwest = { workspace = true, features = ["json"] }
+3 -1
View File
@@ -44,7 +44,9 @@ argon2 = { workspace = true }
chacha20poly1305 = { workspace = true } chacha20poly1305 = { workspace = true }
rand = { workspace = true, features = ["serde"] } rand = { workspace = true, features = ["serde"] }
base64 = { workspace = true } base64 = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
subtle = { workspace = true }
zeroize = { workspace = true, features = ["derive"] } zeroize = { workspace = true, features = ["derive"] }
# Configuration and storage # Configuration and storage
@@ -55,7 +57,7 @@ tempfile = { workspace = true }
moka = { workspace = true, features = ["future"] } moka = { workspace = true, features = ["future"] }
# Additional dependencies # Additional dependencies
md5 = { workspace = true } md-5 = { workspace = true }
arc-swap = { workspace = true } arc-swap = { workspace = true }
rustfs-utils = { workspace = true } rustfs-utils = { workspace = true }
rustfs-security-governance = { workspace = true } rustfs-security-governance = { workspace = true }
+209 -126
View File
@@ -35,7 +35,6 @@ use std::collections::HashMap;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use tokio::fs; use tokio::fs;
use tokio::sync::RwLock;
use tracing::{debug, warn}; use tracing::{debug, warn};
/// Reject key identifiers that would not name a single file directly inside the key /// Reject key identifiers that would not name a single file directly inside the key
@@ -77,8 +76,6 @@ const LOCAL_KMS_ARGON2_P_COST: u32 = 1;
/// Local KMS client that stores keys in local files /// Local KMS client that stores keys in local files
pub struct LocalKmsClient { pub struct LocalKmsClient {
config: LocalConfig, config: LocalConfig,
/// In-memory cache of loaded keys for performance
key_cache: RwLock<HashMap<String, MasterKeyInfo>>,
/// Master encryption key for encrypting stored keys /// Master encryption key for encrypting stored keys
master_cipher: Option<Aes256Gcm>, master_cipher: Option<Aes256Gcm>,
/// Legacy pre-beta.9 master cipher for reading pre-Argon2 key files /// Legacy pre-beta.9 master cipher for reading pre-Argon2 key files
@@ -139,13 +136,14 @@ impl LocalKmsClient {
(None, None) (None, None)
}; };
Ok(Self { let client = Self {
config, config,
key_cache: RwLock::new(HashMap::new()),
master_cipher, master_cipher,
legacy_master_cipher, legacy_master_cipher,
dek_crypto: AesDekCrypto::new(), dek_crypto: AesDekCrypto::new(),
}) };
client.validate_existing_keys().await?;
Ok(client)
} }
/// Derive a 256-bit key from the master key string using a persistent Argon2id salt. /// Derive a 256-bit key from the master key string using a persistent Argon2id salt.
@@ -193,10 +191,35 @@ impl LocalKmsClient {
let mut salt = [0u8; LOCAL_KMS_MASTER_KEY_SALT_LEN]; let mut salt = [0u8; LOCAL_KMS_MASTER_KEY_SALT_LEN];
rand::rng().fill(&mut salt[..]); rand::rng().fill(&mut salt[..]);
fs::write(&salt_path, salt).await?; let temp_path = config
Self::set_file_permissions(&salt_path, config.file_permissions).await?; .key_dir
debug!(path = ?salt_path, "Local KMS master key salt created"); .join(format!("{LOCAL_KMS_MASTER_KEY_SALT_FILE}.tmp-{}", uuid::Uuid::new_v4()));
Ok(salt) fs::write(&temp_path, salt).await?;
Self::set_file_permissions(&temp_path, config.file_permissions).await?;
match fs::hard_link(&temp_path, &salt_path).await {
Ok(()) => {
if let Err(error) = fs::remove_file(&temp_path).await {
warn!(path = ?temp_path, %error, "Failed to remove Local KMS salt temporary file");
}
debug!(path = ?salt_path, "Local KMS master key salt created");
Ok(salt)
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let _ = fs::remove_file(&temp_path).await;
let bytes = fs::read(&salt_path).await?;
bytes.try_into().map_err(|_| {
KmsError::configuration_error(format!(
"Local KMS master key salt at {} must be exactly {} bytes",
salt_path.display(),
LOCAL_KMS_MASTER_KEY_SALT_LEN
))
})
}
Err(error) => {
let _ = fs::remove_file(&temp_path).await;
Err(error.into())
}
}
} }
#[cfg(unix)] #[cfg(unix)]
@@ -242,6 +265,12 @@ impl LocalKmsClient {
let content = fs::read(&key_path).await?; let content = fs::read(&key_path).await?;
let stored_key: StoredMasterKey = serde_json::from_slice(&content)?; let stored_key: StoredMasterKey = serde_json::from_slice(&content)?;
if stored_key.key_id != key_id {
return Err(KmsError::invalid_key(format!(
"Local KMS key file identity mismatch: expected {key_id:?}, found {:?}",
stored_key.key_id
)));
}
let encrypted_bytes = BASE64 let encrypted_bytes = BASE64
.decode(&stored_key.encrypted_key_material) .decode(&stored_key.encrypted_key_material)
@@ -326,8 +355,43 @@ impl LocalKmsClient {
/// Save a master key to disk /// Save a master key to disk
async fn save_master_key(&self, master_key: &MasterKeyInfo, key_material: &[u8]) -> Result<()> { async fn save_master_key(&self, master_key: &MasterKeyInfo, key_material: &[u8]) -> Result<()> {
let key_path = self.master_key_path(&master_key.key_id)?; let key_path = self.master_key_path(&master_key.key_id)?;
let content = self.encode_master_key(master_key, key_material)?;
let temp_path = key_path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
fs::write(&temp_path, &content).await?;
Self::set_file_permissions(&temp_path, self.config.file_permissions).await?;
fs::rename(&temp_path, &key_path).await?;
// Encrypt key material if master cipher is available debug!(key_id = %master_key.key_id, path = ?key_path, "Local KMS master key saved");
Ok(())
}
async fn save_new_master_key(&self, master_key: &MasterKeyInfo, key_material: &[u8]) -> Result<()> {
let key_path = self.master_key_path(&master_key.key_id)?;
let content = self.encode_master_key(master_key, key_material)?;
let temp_path = key_path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
fs::write(&temp_path, &content).await?;
Self::set_file_permissions(&temp_path, self.config.file_permissions).await?;
match fs::hard_link(&temp_path, &key_path).await {
Ok(()) => {
if let Err(error) = fs::remove_file(&temp_path).await {
warn!(path = ?temp_path, %error, "Failed to remove Local KMS key temporary file");
}
debug!(key_id = %master_key.key_id, path = ?key_path, "Local KMS master key created");
Ok(())
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let _ = fs::remove_file(&temp_path).await;
Err(KmsError::key_already_exists(&master_key.key_id))
}
Err(error) => {
let _ = fs::remove_file(&temp_path).await;
Err(error.into())
}
}
}
fn encode_master_key(&self, master_key: &MasterKeyInfo, key_material: &[u8]) -> Result<Vec<u8>> {
let (encrypted_key_material, nonce, at_rest_protection) = if let Some(ref cipher) = self.master_cipher { let (encrypted_key_material, nonce, at_rest_protection) = if let Some(ref cipher) = self.master_cipher {
let mut nonce_bytes = [0u8; 12]; let mut nonce_bytes = [0u8; 12];
rand::rng().fill(&mut nonce_bytes[..]); rand::rng().fill(&mut nonce_bytes[..]);
@@ -362,17 +426,7 @@ impl LocalKmsClient {
at_rest_protection, at_rest_protection,
}; };
let content = serde_json::to_vec_pretty(&stored_key)?; serde_json::to_vec_pretty(&stored_key).map_err(Into::into)
// Write to temporary file first, then rename for atomicity
let temp_path = key_path.with_extension("tmp");
fs::write(&temp_path, &content).await?;
Self::set_file_permissions(&temp_path, self.config.file_permissions).await?;
fs::rename(&temp_path, &key_path).await?;
debug!(key_id = %master_key.key_id, path = ?key_path, "Local KMS master key saved");
Ok(())
} }
/// Get the actual key material for a master key /// Get the actual key material for a master key
@@ -381,6 +435,23 @@ impl LocalKmsClient {
Ok(key_material) Ok(key_material)
} }
async fn validate_existing_keys(&self) -> Result<()> {
let mut entries = fs::read_dir(&self.config.key_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().is_none_or(|extension| extension != "key") {
continue;
}
let key_id = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| KmsError::configuration_error("Local KMS key file name must be valid UTF-8"))?;
self.decode_stored_key(key_id).await?;
}
Ok(())
}
/// Encrypt data using a master key /// Encrypt data using a master key
async fn encrypt_with_master_key(&self, key_id: &str, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> { async fn encrypt_with_master_key(&self, key_id: &str, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
// Load the actual master key material // Load the actual master key material
@@ -513,11 +584,7 @@ impl KmsClient for LocalKmsClient {
let master_key = MasterKeyInfo::new_with_description(key_id.to_string(), algorithm.to_string(), Some(created_by), None); let master_key = MasterKeyInfo::new_with_description(key_id.to_string(), algorithm.to_string(), Some(created_by), None);
// Save to disk // Save to disk
self.save_master_key(&master_key, &key_material).await?; self.save_new_master_key(&master_key, &key_material).await?;
// Cache the key
let mut cache = self.key_cache.write().await;
cache.insert(key_id.to_string(), master_key.clone());
debug!(key_id, "Local KMS master key created"); debug!(key_id, "Local KMS master key created");
Ok(master_key) Ok(master_key)
@@ -526,23 +593,7 @@ impl KmsClient for LocalKmsClient {
async fn describe_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<KeyInfo> { async fn describe_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<KeyInfo> {
debug!("Describing key: {}", key_id); debug!("Describing key: {}", key_id);
// Check cache first
{
let cache = self.key_cache.read().await;
if let Some(master_key) = cache.get(key_id) {
return Ok(master_key.clone().into());
}
}
// Load from disk
let master_key = self.load_master_key(key_id).await?; let master_key = self.load_master_key(key_id).await?;
// Update cache
{
let mut cache = self.key_cache.write().await;
cache.insert(key_id.to_string(), master_key.clone());
}
Ok(master_key.into()) Ok(master_key.into())
} }
@@ -602,10 +653,6 @@ impl KmsClient for LocalKmsClient {
let key_material = self.get_key_material(key_id).await?; let key_material = self.get_key_material(key_id).await?;
self.save_master_key(&master_key, &key_material).await?; self.save_master_key(&master_key, &key_material).await?;
// Update cache
let mut cache = self.key_cache.write().await;
cache.insert(key_id.to_string(), master_key);
debug!(key_id, "Local KMS key enabled"); debug!(key_id, "Local KMS key enabled");
Ok(()) Ok(())
} }
@@ -621,10 +668,6 @@ impl KmsClient for LocalKmsClient {
let key_material = self.get_key_material(key_id).await?; let key_material = self.get_key_material(key_id).await?;
self.save_master_key(&master_key, &key_material).await?; self.save_master_key(&master_key, &key_material).await?;
// Update cache
let mut cache = self.key_cache.write().await;
cache.insert(key_id.to_string(), master_key);
debug!(key_id, "Local KMS key disabled"); debug!(key_id, "Local KMS key disabled");
Ok(()) Ok(())
} }
@@ -646,10 +689,6 @@ impl KmsClient for LocalKmsClient {
let key_material = self.get_key_material(key_id).await?; let key_material = self.get_key_material(key_id).await?;
self.save_master_key(&master_key, &key_material).await?; self.save_master_key(&master_key, &key_material).await?;
// Update cache
let mut cache = self.key_cache.write().await;
cache.insert(key_id.to_string(), master_key);
debug!(key_id, "Local KMS key deletion scheduled"); debug!(key_id, "Local KMS key deletion scheduled");
Ok(()) Ok(())
} }
@@ -665,31 +704,17 @@ impl KmsClient for LocalKmsClient {
let key_material = self.get_key_material(key_id).await?; let key_material = self.get_key_material(key_id).await?;
self.save_master_key(&master_key, &key_material).await?; self.save_master_key(&master_key, &key_material).await?;
// Update cache
let mut cache = self.key_cache.write().await;
cache.insert(key_id.to_string(), master_key);
debug!(key_id, "Local KMS key deletion canceled"); debug!(key_id, "Local KMS key deletion canceled");
Ok(()) Ok(())
} }
async fn rotate_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> { async fn rotate_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> {
debug!("Rotating key: {}", key_id); if !fs::try_exists(self.master_key_path(key_id)?).await? {
return Err(KmsError::key_not_found(key_id));
let mut master_key = self.load_master_key(key_id).await?; }
master_key.version += 1; Err(KmsError::invalid_operation(
master_key.rotated_at = Some(Zoned::now()); "Local KMS key rotation is unavailable until historical key versions can be retained",
))
// Generate new key material
let key_material = generate_key_material(&master_key.algorithm)?;
self.save_master_key(&master_key, &key_material).await?;
// Update cache
let mut cache = self.key_cache.write().await;
cache.insert(key_id.to_string(), master_key.clone());
debug!(key_id, "Local KMS key rotated");
Ok(master_key)
} }
async fn health_check(&self) -> Result<()> { async fn health_check(&self) -> Result<()> {
@@ -745,11 +770,6 @@ impl KmsBackend for LocalKmsBackend {
async fn create_key(&self, request: CreateKeyRequest) -> Result<CreateKeyResponse> { async fn create_key(&self, request: CreateKeyRequest) -> Result<CreateKeyResponse> {
let key_id = request.key_name.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let key_id = request.key_name.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
// `save_master_key` writes through a temp file and renames over the destination, so
// creating a key under an existing name would replace its material and silently
// destroy the ability to decrypt everything wrapped under it. The sibling
// `KmsClient::create_key` has always refused this; the backend path did not, and
// this is the path the admin API uses.
if self.client.master_key_path(&key_id)?.exists() { if self.client.master_key_path(&key_id)?.exists() {
return Err(KmsError::key_already_exists(&key_id)); return Err(KmsError::key_already_exists(&key_id));
} }
@@ -767,11 +787,8 @@ impl KmsBackend for LocalKmsBackend {
request.description.clone(), request.description.clone(),
); );
// Save to disk and cache // Save to disk
self.client.save_master_key(&master_key, &key_material).await?; self.client.save_new_master_key(&master_key, &key_material).await?;
let mut cache = self.client.key_cache.write().await;
cache.insert(key_id.clone(), master_key.clone());
master_key master_key
}; };
@@ -888,10 +905,6 @@ impl KmsBackend for LocalKmsBackend {
.await .await
.map_err(|e| KmsError::internal_error(format!("Failed to delete key file: {e}")))?; .map_err(|e| KmsError::internal_error(format!("Failed to delete key file: {e}")))?;
// Remove from cache
let mut cache = self.client.key_cache.write().await;
cache.remove(key_id);
debug!(key_id, "Local KMS key deleted immediately"); debug!(key_id, "Local KMS key deleted immediately");
// Return success response for immediate deletion // Return success response for immediate deletion
@@ -935,10 +948,6 @@ impl KmsBackend for LocalKmsBackend {
self.client.save_master_key(&master_key, &existing_key_material).await?; self.client.save_master_key(&master_key, &existing_key_material).await?;
// Update cache
let mut cache = self.client.key_cache.write().await;
cache.insert(key_id.to_string(), master_key.clone());
// Convert master_key to KeyMetadata for response // Convert master_key to KeyMetadata for response
let key_metadata = KeyMetadata { let key_metadata = KeyMetadata {
key_id: master_key.key_id.clone(), key_id: master_key.key_id.clone(),
@@ -986,10 +995,6 @@ impl KmsBackend for LocalKmsBackend {
self.client.save_master_key(&master_key, &existing_key_material).await?; self.client.save_master_key(&master_key, &existing_key_material).await?;
// Update cache
let mut cache = self.client.key_cache.write().await;
cache.insert(key_id.to_string(), master_key.clone());
// Convert master_key to KeyMetadata for response // Convert master_key to KeyMetadata for response
let key_metadata = KeyMetadata { let key_metadata = KeyMetadata {
key_id: master_key.key_id.clone(), key_id: master_key.key_id.clone(),
@@ -1189,6 +1194,18 @@ mod tests {
.expect("stored encrypted key should deserialize"); .expect("stored encrypted key should deserialize");
assert_eq!(stored.at_rest_protection, StoredKeyProtection::EncryptedMasterKey); assert_eq!(stored.at_rest_protection, StoredKeyProtection::EncryptedMasterKey);
assert_eq!(stored.nonce.len(), 12); assert_eq!(stored.nonce.len(), 12);
let wrong_master_error = match LocalKmsClient::new(LocalConfig {
key_dir: client.config.key_dir.clone(),
master_key: Some("wrong-master-key".to_string()),
file_permissions: Some(0o600),
})
.await
{
Ok(_) => panic!("wrong master key must fail initialization"),
Err(error) => error,
};
assert!(matches!(wrong_master_error, KmsError::CryptographicError { .. }));
} }
#[tokio::test] #[tokio::test]
@@ -1228,17 +1245,57 @@ mod tests {
master_key: None, master_key: None,
file_permissions: Some(0o600), file_permissions: Some(0o600),
}; };
let client_without_master = LocalKmsClient::new(config) let err = match LocalKmsClient::new(config).await {
.await Ok(_) => panic!("initialization must reject an unreadable encrypted key"),
.expect("client without master key should still initialize in dev-mode tests"); Err(error) => error,
};
let err = client_without_master
.describe_key("encrypted-key", None)
.await
.expect_err("encrypted key should require a master key to read");
assert!(err.to_string().contains("requires a configured master key")); assert!(err.to_string().contains("requires a configured master key"));
} }
#[tokio::test]
async fn local_key_rotation_is_rejected_without_overwriting_key_material() {
let (client, _temp_dir) = create_test_client().await;
let key_id = "rotation-key";
client.create_key(key_id, "AES_256", None).await.expect("create key");
let original_material = client.get_key_material(key_id).await.expect("load original material");
let error = client
.rotate_key(key_id, None)
.await
.expect_err("rotation must remain unavailable without historical key versions");
assert!(matches!(error, KmsError::InvalidOperation { .. }));
assert_eq!(
client.get_key_material(key_id).await.expect("reload original material"),
original_material
);
}
#[tokio::test]
async fn startup_rejects_key_file_with_mismatched_embedded_id() {
let (client, temp_dir) = create_dev_mode_client().await;
client.create_key("file-name", "AES_256", None).await.expect("create key");
let key_path = client.master_key_path("file-name").expect("valid key id");
let mut stored: serde_json::Value =
serde_json::from_slice(&fs::read(&key_path).await.expect("read key file")).expect("decode key file");
stored["key_id"] = serde_json::json!("embedded-name");
fs::write(&key_path, serde_json::to_vec_pretty(&stored).expect("encode mismatched key"))
.await
.expect("write mismatched key");
let error = match LocalKmsClient::new(LocalConfig {
key_dir: temp_dir.path().to_path_buf(),
master_key: None,
file_permissions: Some(0o600),
})
.await
{
Ok(_) => panic!("mismatched key identity must fail initialization"),
Err(error) => error,
};
assert!(matches!(error, KmsError::InvalidKey { .. }));
}
#[tokio::test] #[tokio::test]
async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() { async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() {
let (client, _temp_dir) = create_dev_mode_client().await; let (client, _temp_dir) = create_dev_mode_client().await;
@@ -1329,17 +1386,6 @@ mod tests {
) )
.await .await
.expect("write beta.5 fixture"); .expect("write beta.5 fixture");
let mut explicit_protection = stored_key.clone();
let explicit_object = explicit_protection.as_object_mut().expect("beta.5 fixture is a JSON object");
explicit_object.insert("key_id".to_string(), serde_json::json!("beta5-explicit-key"));
explicit_object.insert("at_rest_protection".to_string(), serde_json::json!("encrypted-master-key"));
fs::write(
temp_dir.path().join("beta5-explicit-key.key"),
serde_json::to_vec_pretty(&explicit_protection).expect("serialize explicit-protection fixture"),
)
.await
.expect("write explicit-protection fixture");
let client = LocalKmsClient::new(LocalConfig { let client = LocalKmsClient::new(LocalConfig {
key_dir: temp_dir.path().to_path_buf(), key_dir: temp_dir.path().to_path_buf(),
master_key: Some("beta5-test-master-key".to_string()), master_key: Some("beta5-test-master-key".to_string()),
@@ -1353,24 +1399,34 @@ mod tests {
.await .await
.expect("decrypt beta.5 SHA-256 protected key"); .expect("decrypt beta.5 SHA-256 protected key");
assert_eq!(material, vec![0x42; 32]); assert_eq!(material, vec![0x42; 32]);
let mut explicit_protection = stored_key.clone();
let explicit_object = explicit_protection.as_object_mut().expect("beta.5 fixture is a JSON object");
explicit_object.insert("key_id".to_string(), serde_json::json!("beta5-explicit-key"));
explicit_object.insert("at_rest_protection".to_string(), serde_json::json!("encrypted-master-key"));
fs::write(
temp_dir.path().join("beta5-explicit-key.key"),
serde_json::to_vec_pretty(&explicit_protection).expect("serialize explicit-protection fixture"),
)
.await
.expect("write explicit-protection fixture");
let explicit_error = client let explicit_error = client
.get_key_material("beta5-explicit-key") .get_key_material("beta5-explicit-key")
.await .await
.expect_err("explicit current protection must not fall back to the beta.5 KDF"); .expect_err("explicit current protection must not fall back to the beta.5 KDF");
assert!(matches!(explicit_error, KmsError::CryptographicError { .. })); assert!(matches!(explicit_error, KmsError::CryptographicError { .. }));
let wrong_key_client = LocalKmsClient::new(LocalConfig { let wrong_key_error = match LocalKmsClient::new(LocalConfig {
key_dir: temp_dir.path().to_path_buf(), key_dir: temp_dir.path().to_path_buf(),
master_key: Some("wrong-beta5-master-key".to_string()), master_key: Some("wrong-beta5-master-key".to_string()),
file_permissions: Some(0o600), file_permissions: Some(0o600),
}) })
.await .await
.expect("initialize local KMS with wrong beta.5 master key"); {
let error = wrong_key_client Ok(_) => panic!("wrong beta.5 master key must fail initialization"),
.get_key_material("beta5-key") Err(error) => error,
.await };
.expect_err("wrong beta.5 master key must not decrypt the fixture"); assert!(matches!(wrong_key_error, KmsError::CryptographicError { .. }));
assert!(matches!(error, KmsError::CryptographicError { .. }));
} }
/// R03-CAN-072 / R03-CAN-073: key identifiers arrive from request input, so every path /// R03-CAN-072 / R03-CAN-073: key identifiers arrive from request input, so every path
@@ -1429,9 +1485,7 @@ mod tests {
} }
} }
/// R07-CAN-103: `save_master_key` writes a temp file and renames over the destination, /// R07-CAN-103: creating a duplicate key must preserve its original material.
/// so creating a key under an existing name would replace its material and silently
/// destroy the ability to decrypt anything wrapped under it.
#[tokio::test] #[tokio::test]
async fn backend_create_key_refuses_to_replace_existing_key_material() { async fn backend_create_key_refuses_to_replace_existing_key_material() {
let temp_dir = TempDir::new().expect("Failed to create temp dir"); let temp_dir = TempDir::new().expect("Failed to create temp dir");
@@ -1469,4 +1523,33 @@ mod tests {
.expect("original key material must survive the refused create"); .expect("original key material must survive the refused create");
assert_eq!(original, after, "existing key material must not be replaced"); assert_eq!(original, after, "existing key material must not be replaced");
} }
#[tokio::test]
async fn concurrent_backend_create_allows_only_one_writer() {
let temp_dir = TempDir::new().expect("create key directory");
let config = || LocalConfig {
key_dir: temp_dir.path().to_path_buf(),
master_key: Some("test-master-key".to_string()),
file_permissions: Some(0o600),
};
let first = LocalKmsBackend {
client: LocalKmsClient::new(config()).await.expect("create first client"),
};
let second = LocalKmsBackend {
client: LocalKmsClient::new(config()).await.expect("create second client"),
};
let request = || CreateKeyRequest {
key_name: Some("concurrent-key".to_string()),
..Default::default()
};
let (first_result, second_result) = tokio::join!(first.create_key(request()), second.create_key(request()));
assert_ne!(first_result.is_ok(), second_result.is_ok(), "exactly one create must succeed");
let error = first_result
.err()
.or_else(|| second_result.err())
.expect("one create must fail");
assert!(matches!(error, KmsError::KeyAlreadyExists { .. }));
assert_eq!(first.client.get_key_material("concurrent-key").await.expect("load key").len(), 32);
}
} }
+2 -2
View File
@@ -177,8 +177,8 @@ mod tests {
let service1 = manager.get_encryption_service().await.expect("Service should be available"); let service1 = manager.get_encryption_service().await.expect("Service should be available");
// Reconfigure to new service (zero-downtime) // Reconfigure to new service (zero-downtime)
let temp_dir2 = TempDir::new().expect("Failed to create temp dir"); let mut config2 = config1;
let config2 = KmsConfig::local(temp_dir2.path().to_path_buf()).with_insecure_development_defaults(); config2.timeout = std::time::Duration::from_secs(45);
manager.reconfigure(config2).await.expect("Reconfiguration should succeed"); manager.reconfigure(config2).await.expect("Reconfiguration should succeed");
// Verify version 2 // Verify version 2
+8 -2
View File
@@ -20,6 +20,7 @@ use crate::manager::KmsManager;
use crate::types::*; use crate::types::*;
use base64::Engine; use base64::Engine;
use jiff::Zoned; use jiff::Zoned;
use md5::{Digest as Md5Digest, Md5};
use rand::random; use rand::random;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Cursor; use std::io::Cursor;
@@ -27,6 +28,12 @@ use tokio::io::{AsyncRead, AsyncReadExt};
use tracing::debug; use tracing::debug;
use zeroize::Zeroize; use zeroize::Zeroize;
fn md5_hex(input: impl AsRef<[u8]>) -> String {
let mut hasher = Md5::new();
hasher.update(input.as_ref());
hex::encode(hasher.finalize())
}
/// Data key for object encryption /// Data key for object encryption
/// SECURITY: This struct automatically zeros sensitive key material when dropped /// SECURITY: This struct automatically zeros sensitive key material when dropped
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -486,8 +493,7 @@ impl ObjectEncryptionService {
// Validate key MD5 if provided // Validate key MD5 if provided
if let Some(expected_md5) = customer_key_md5 { if let Some(expected_md5) = customer_key_md5 {
let actual_md5 = md5::compute(customer_key); let actual_md5_hex = md5_hex(customer_key);
let actual_md5_hex = format!("{actual_md5:x}");
if actual_md5_hex != expected_md5.to_lowercase() { if actual_md5_hex != expected_md5.to_lowercase() {
return Err(KmsError::validation_error("Customer key MD5 mismatch")); return Err(KmsError::validation_error("Customer key MD5 mismatch"));
} }
+162 -6
View File
@@ -20,10 +20,12 @@ use crate::error::{KmsError, Result};
use crate::manager::KmsManager; use crate::manager::KmsManager;
use crate::service::ObjectEncryptionService; use crate::service::ObjectEncryptionService;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use sha2::{Digest, Sha256};
use std::sync::{ use std::sync::{
Arc, OnceLock, Arc, OnceLock,
atomic::{AtomicU64, Ordering}, atomic::{AtomicU64, Ordering},
}; };
use subtle::ConstantTimeEq;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
@@ -31,6 +33,53 @@ const LOG_COMPONENT_KMS: &str = "kms";
const LOG_SUBSYSTEM_SERVICE: &str = "service"; const LOG_SUBSYSTEM_SERVICE: &str = "service";
const EVENT_KMS_SERVICE_STATE: &str = "kms_service_state"; const EVENT_KMS_SERVICE_STATE: &str = "kms_service_state";
fn local_master_key_fingerprint(master_key: Option<&str>) -> [u8; 32] {
let mut digest = Sha256::new();
digest.update([u8::from(master_key.is_some())]);
if let Some(master_key) = master_key {
digest.update(master_key.as_bytes());
}
digest.finalize().into()
}
fn validate_local_transition(current: Option<&KmsConfig>, new: &KmsConfig) -> Result<()> {
let Some(current) = current else {
return Ok(());
};
let BackendConfig::Local(current_local) = &current.backend_config else {
return Ok(());
};
let BackendConfig::Local(new_local) = &new.backend_config else {
return Err(KmsError::configuration_error("Local KMS backend cannot be changed after configuration"));
};
if current_local.key_dir != new_local.key_dir {
return Err(KmsError::configuration_error(
"Local KMS key directory cannot be changed after configuration",
));
}
if current_local.file_permissions != new_local.file_permissions {
return Err(KmsError::configuration_error(
"Local KMS file permissions cannot be changed after configuration",
));
}
if current.allow_insecure_dev_defaults != new.allow_insecure_dev_defaults {
return Err(KmsError::configuration_error(
"Local KMS development mode cannot be changed after configuration",
));
}
let current_master_key = local_master_key_fingerprint(current_local.master_key.as_deref());
let new_master_key = local_master_key_fingerprint(new_local.master_key.as_deref());
if !bool::from(current_master_key.ct_eq(&new_master_key)) {
return Err(KmsError::configuration_error(
"Local KMS master key cannot be changed after configuration",
));
}
Ok(())
}
/// KMS service status /// KMS service status
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum KmsServiceStatus { pub enum KmsServiceStatus {
@@ -106,7 +155,12 @@ impl KmsServiceManager {
/// Configure KMS with new configuration /// Configure KMS with new configuration
pub async fn configure(&self, new_config: KmsConfig) -> Result<()> { pub async fn configure(&self, new_config: KmsConfig) -> Result<()> {
let _guard = self.lifecycle_mutex.lock().await;
new_config.validate()?; new_config.validate()?;
{
let config = self.config.read().await;
validate_local_transition(config.as_ref(), &new_config)?;
}
// Update configuration // Update configuration
{ {
@@ -254,11 +308,9 @@ impl KmsServiceManager {
"KMS service reconfiguring" "KMS service reconfiguring"
); );
new_config.validate()?; new_config.validate()?;
// Configure with new config
{ {
let mut config = self.config.write().await; let config = self.config.read().await;
*config = Some(new_config.clone()); validate_local_transition(config.as_ref(), &new_config)?;
} }
// Create new service version without stopping old one // Create new service version without stopping old one
@@ -268,6 +320,11 @@ impl KmsServiceManager {
// Get old version for logging (lock-free read) // Get old version for logging (lock-free read)
let old_version = self.current_service.load().as_ref().as_ref().map(|sv| sv.version); let old_version = self.current_service.load().as_ref().as_ref().map(|sv| sv.version);
{
let mut config = self.config.write().await;
*config = Some(new_config);
}
// Atomically switch to new service version (lock-free, instant CAS operation) // Atomically switch to new service version (lock-free, instant CAS operation)
// This is a true atomic operation - no waiting for locks, instant switch // This is a true atomic operation - no waiting for locks, instant switch
// Old service will be dropped when no more Arc references exist // Old service will be dropped when no more Arc references exist
@@ -304,8 +361,6 @@ impl KmsServiceManager {
Err(e) => { Err(e) => {
let err_msg = format!("Failed to reconfigure KMS: {e}"); let err_msg = format!("Failed to reconfigure KMS: {e}");
error!("{}", err_msg); error!("{}", err_msg);
let mut status = self.status.write().await;
*status = KmsServiceStatus::Error(err_msg.clone());
Err(KmsError::backend_error(&err_msg)) Err(KmsError::backend_error(&err_msg))
} }
} }
@@ -477,4 +532,105 @@ mod tests {
}; };
assert!(static_config.secret_key.is_empty()); assert!(static_config.secret_key.is_empty());
} }
#[tokio::test]
async fn forbidden_local_master_key_change_preserves_running_config_and_service() {
use crate::types::{CreateKeyRequest, KeyUsage};
use std::collections::HashMap;
use tempfile::TempDir;
let key_dir = TempDir::new().expect("create local KMS directory");
let config = |master_key: &str| {
let mut config = KmsConfig::local(key_dir.path().to_path_buf());
let BackendConfig::Local(local) = &mut config.backend_config else {
panic!("local constructor must create local backend config");
};
local.master_key = Some(master_key.to_string());
config.allow_insecure_dev_defaults = true;
config
};
let manager = KmsServiceManager::new();
manager
.configure(config("working-master-key"))
.await
.expect("configure local KMS");
manager.start().await.expect("start local KMS");
manager
.get_manager()
.await
.expect("running KMS manager")
.create_key(CreateKeyRequest {
key_name: Some("existing-key".to_string()),
key_usage: KeyUsage::EncryptDecrypt,
description: None,
policy: None,
tags: HashMap::new(),
origin: None,
})
.await
.expect("create encrypted key");
let service_version = manager.get_service_version().await;
let error = manager
.reconfigure(config("wrong-master-key"))
.await
.expect_err("local master key change must be rejected");
assert!(error.to_string().contains("master key cannot be changed"));
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
assert_eq!(manager.get_service_version().await, service_version);
let current = manager.get_config().await.expect("working config must remain");
let BackendConfig::Local(local) = current.backend_config else {
panic!("working config must remain local");
};
assert_eq!(local.master_key.as_deref(), Some("working-master-key"));
}
#[tokio::test]
async fn configure_cannot_replace_existing_local_backend() {
use base64::Engine as _;
use tempfile::TempDir;
let key_dir = TempDir::new().expect("create local KMS directory");
let mut local = KmsConfig::local(key_dir.path().to_path_buf());
local.allow_insecure_dev_defaults = true;
let manager = KmsServiceManager::new();
manager.configure(local.clone()).await.expect("configure local KMS");
let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]);
let error = manager
.configure(KmsConfig::static_kms("static-key".to_string(), encoded_key))
.await
.expect_err("existing local backend must be immutable");
assert!(error.to_string().contains("backend cannot be changed"));
let current = manager.get_config().await.expect("local config must remain");
assert!(matches!(current.backend_config, BackendConfig::Local(_)));
}
#[tokio::test]
async fn reconfigure_allows_safe_local_runtime_settings_only() {
use tempfile::TempDir;
let key_dir = TempDir::new().expect("create local KMS directory");
let mut initial = KmsConfig::local(key_dir.path().to_path_buf());
initial.allow_insecure_dev_defaults = true;
let manager = KmsServiceManager::new();
manager.configure(initial.clone()).await.expect("configure local KMS");
manager.start().await.expect("start local KMS");
let mut updated = initial;
updated.default_key_id = Some("evaluation-key".to_string());
updated.timeout = std::time::Duration::from_secs(45);
updated.enable_cache = false;
manager
.reconfigure(updated.clone())
.await
.expect("update safe local settings");
let current = manager.get_config().await.expect("updated config");
assert_eq!(current.default_key_id, updated.default_key_id);
assert_eq!(current.timeout, updated.timeout);
assert!(!current.enable_cache);
}
} }
+309 -6
View File
@@ -545,6 +545,8 @@ pub struct ScannerMetrics {
pub collected_at: DateTime<Utc>, pub collected_at: DateTime<Utc>,
#[serde(rename = "current_cycle")] #[serde(rename = "current_cycle")]
pub current_cycle: u64, pub current_cycle: u64,
#[serde(rename = "current_cycle_active", default, skip_serializing_if = "Option::is_none")]
pub current_cycle_active: Option<bool>,
#[serde(rename = "current_started")] #[serde(rename = "current_started")]
pub current_started: DateTime<Utc>, pub current_started: DateTime<Utc>,
#[serde(rename = "cycle_complete_times")] #[serde(rename = "cycle_complete_times")]
@@ -718,7 +720,31 @@ pub struct ScannerMetrics {
} }
impl ScannerMetrics { impl ScannerMetrics {
pub fn is_current_cycle_active(&self) -> bool {
self.current_cycle_active.unwrap_or(self.current_cycle > 0)
}
pub fn merge(&mut self, other: &Self) { pub fn merge(&mut self, other: &Self) {
// Legacy nodes omit the activity field and use a non-zero cycle as
// their active signal. New nodes publish explicit first-cycle and idle
// states, including cycle zero.
let self_cycle_active = self.is_current_cycle_active();
let other_cycle_active = other.is_current_cycle_active();
let self_cycle_authority = (
self_cycle_active,
self.current_cycle,
self.cycles_completed_at.len(),
self.cycles_completed_at.as_slice(),
self.current_started,
);
let other_cycle_authority = (
other_cycle_active,
other.current_cycle,
other.cycles_completed_at.len(),
other.cycles_completed_at.as_slice(),
other.current_started,
);
let other_cycle_is_authoritative = self_cycle_authority < other_cycle_authority;
let other_is_newer = self.collected_at < other.collected_at; let other_is_newer = self.collected_at < other.collected_at;
if other_is_newer { if other_is_newer {
self.collected_at = other.collected_at; self.collected_at = other.collected_at;
@@ -854,15 +880,12 @@ impl ScannerMetrics {
self.ongoing_buckets = other.ongoing_buckets; self.ongoing_buckets = other.ongoing_buckets;
} }
if self.current_cycle < other.current_cycle { if other_cycle_is_authoritative {
self.current_cycle = other.current_cycle; self.current_cycle = other.current_cycle;
self.cycles_completed_at = other.cycles_completed_at.clone(); self.cycles_completed_at = other.cycles_completed_at.clone();
self.current_started = other.current_started; self.current_started = other.current_started;
} }
self.current_cycle_active = Some(self_cycle_active || other_cycle_active);
if other.cycles_completed_at.len() > self.cycles_completed_at.len() {
self.cycles_completed_at = other.cycles_completed_at.clone();
}
if !other.life_time_ops.is_empty() && self.life_time_ops.is_empty() { if !other.life_time_ops.is_empty() && self.life_time_ops.is_empty() {
self.life_time_ops = other.life_time_ops.clone(); self.life_time_ops = other.life_time_ops.clone();
@@ -931,7 +954,13 @@ impl Metrics {
if let Some(scanner) = other.scanner.as_ref() { if let Some(scanner) = other.scanner.as_ref() {
match self.scanner { match self.scanner {
Some(ref mut s_scanner) => s_scanner.merge(scanner), Some(ref mut s_scanner) => s_scanner.merge(scanner),
None => self.scanner = Some(scanner.clone()), None => {
let mut scanner = scanner.clone();
if scanner.current_cycle_active.is_none() {
scanner.current_cycle_active = Some(scanner.is_current_cycle_active());
}
self.scanner = Some(scanner);
}
} }
} }
@@ -1389,6 +1418,280 @@ pub struct Operations {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn scanner_metrics_serializes_cycle_active_presence() {
let missing_value = serde_json::to_value(ScannerMetrics::default()).expect("scanner metrics should serialize");
assert!(missing_value.get("current_cycle_active").is_none());
let missing: ScannerMetrics =
serde_json::from_value(missing_value).expect("older scanner metrics without cycle-active should decode");
assert_eq!(missing.current_cycle_active, None);
let explicit_false_value = serde_json::to_value(ScannerMetrics {
current_cycle_active: Some(false),
..Default::default()
})
.expect("scanner metrics with explicit cycle-active should serialize");
assert_eq!(explicit_false_value["current_cycle_active"], serde_json::Value::Bool(false));
let explicit_false: ScannerMetrics =
serde_json::from_value(explicit_false_value).expect("explicit cycle-active should decode");
assert_eq!(explicit_false.current_cycle_active, Some(false));
}
#[test]
fn scanner_metrics_merge_prefers_an_active_first_cycle() {
let collected_at = Utc::now();
let idle_started = collected_at - chrono::Duration::hours(1);
let active_started = collected_at - chrono::Duration::seconds(5);
let mut scanner = ScannerMetrics {
collected_at,
current_cycle: 0,
current_cycle_active: Some(false),
current_started: idle_started,
..Default::default()
};
scanner.merge(&ScannerMetrics {
collected_at: collected_at + chrono::Duration::seconds(1),
current_cycle: 0,
current_cycle_active: Some(true),
current_started: active_started,
..Default::default()
});
assert_eq!(scanner.current_cycle_active, Some(true));
assert_eq!(scanner.current_cycle, 0);
assert_eq!(scanner.current_started, active_started);
}
#[test]
fn metrics_merge_preserves_explicit_active_first_cycle() {
let mut aggregated = Metrics::default();
aggregated.merge(&Metrics {
scanner: Some(ScannerMetrics {
current_cycle_active: Some(true),
..Default::default()
}),
..Default::default()
});
let scanner = aggregated.scanner.expect("aggregated scanner metrics");
assert_eq!(scanner.current_cycle_active, Some(true));
assert_eq!(scanner.current_cycle, 0);
}
#[test]
fn scanner_metrics_merge_preserves_legacy_nonzero_active_signal() {
let collected_at = Utc::now();
let mut scanner = ScannerMetrics {
collected_at,
..Default::default()
};
scanner.merge(&ScannerMetrics {
collected_at: collected_at + chrono::Duration::seconds(1),
current_cycle: 7,
..Default::default()
});
assert_eq!(scanner.current_cycle_active, Some(true));
assert_eq!(scanner.current_cycle, 7);
}
#[test]
fn metrics_merge_normalizes_first_legacy_scanner_snapshot() {
let legacy = Metrics {
scanner: Some(ScannerMetrics {
current_cycle: 7,
..Default::default()
}),
..Default::default()
};
let mut aggregated = Metrics::default();
aggregated.merge(&legacy);
let scanner = aggregated.scanner.expect("aggregated scanner metrics");
assert_eq!(scanner.current_cycle_active, Some(true));
assert_eq!(scanner.current_cycle, 7);
}
#[test]
fn scanner_metrics_merge_preserves_explicit_inactive_nonzero_cycle() {
let collected_at = Utc::now();
let mut scanner = ScannerMetrics::default();
scanner.merge(&ScannerMetrics {
collected_at,
current_cycle: 7,
current_cycle_active: Some(false),
..Default::default()
});
assert_eq!(scanner.current_cycle_active, Some(false));
assert_eq!(scanner.current_cycle, 7);
}
#[test]
fn scanner_metrics_merge_cycle_active_is_order_independent() {
let collected_at = Utc::now();
let legacy_active = ScannerMetrics {
collected_at,
current_cycle: 7,
current_started: collected_at - chrono::Duration::seconds(10),
..Default::default()
};
let explicit_idle = ScannerMetrics {
collected_at,
current_cycle: 0,
current_cycle_active: Some(false),
current_started: collected_at - chrono::Duration::hours(1),
..Default::default()
};
let mut legacy_first = legacy_active.clone();
legacy_first.merge(&explicit_idle);
let mut legacy_second = explicit_idle.clone();
legacy_second.merge(&legacy_active);
assert_eq!(legacy_first.current_cycle_active, Some(true));
assert_eq!(legacy_second.current_cycle_active, Some(true));
assert_eq!(legacy_first.current_cycle, 7);
assert_eq!(legacy_second.current_cycle, 7);
let explicit_inactive_nonzero = ScannerMetrics {
collected_at,
current_cycle: 7,
current_cycle_active: Some(false),
..Default::default()
};
let mut inactive_first = explicit_inactive_nonzero.clone();
inactive_first.merge(&explicit_idle);
let mut inactive_second = explicit_idle;
inactive_second.merge(&explicit_inactive_nonzero);
assert_eq!(inactive_first.current_cycle_active, Some(false));
assert_eq!(inactive_second.current_cycle_active, Some(false));
assert_eq!(inactive_first.current_cycle, 7);
assert_eq!(inactive_second.current_cycle, 7);
}
#[test]
fn scanner_metrics_merge_cycle_authority_is_order_independent() {
let collected_at = Utc::now();
let completion = collected_at - chrono::Duration::minutes(1);
let earlier_active = ScannerMetrics {
collected_at,
current_cycle: 7,
current_cycle_active: Some(true),
current_started: collected_at - chrono::Duration::seconds(10),
cycles_completed_at: vec![completion],
..Default::default()
};
let later_active = ScannerMetrics {
collected_at: collected_at + chrono::Duration::seconds(1),
current_cycle: 7,
current_cycle_active: Some(true),
current_started: collected_at - chrono::Duration::seconds(5),
cycles_completed_at: vec![completion],
..Default::default()
};
let mut earlier_first = earlier_active.clone();
earlier_first.merge(&later_active);
let mut later_first = later_active.clone();
later_first.merge(&earlier_active);
assert_eq!(earlier_first.current_started, later_active.current_started);
assert_eq!(later_first.current_started, later_active.current_started);
assert_eq!(earlier_first.cycles_completed_at, later_active.cycles_completed_at);
assert_eq!(later_first.cycles_completed_at, later_active.cycles_completed_at);
let stale_idle = ScannerMetrics {
collected_at,
current_cycle_active: Some(false),
current_started: collected_at - chrono::Duration::hours(1),
..Default::default()
};
let completed_idle = ScannerMetrics {
collected_at: collected_at + chrono::Duration::seconds(1),
current_cycle_active: Some(false),
current_started: collected_at - chrono::Duration::seconds(5),
cycles_completed_at: vec![completion],
..Default::default()
};
let mut stale_first = stale_idle.clone();
stale_first.merge(&completed_idle);
let mut completed_first = completed_idle.clone();
completed_first.merge(&stale_idle);
assert_eq!(stale_first.current_started, completed_idle.current_started);
assert_eq!(completed_first.current_started, completed_idle.current_started);
assert_eq!(stale_first.cycles_completed_at, completed_idle.cycles_completed_at);
assert_eq!(completed_first.cycles_completed_at, completed_idle.cycles_completed_at);
}
#[test]
fn scanner_metrics_merge_cycle_authority_is_associative() {
let collected_at = Utc::now();
let older_completion = collected_at - chrono::Duration::minutes(3);
let last_completion = collected_at - chrono::Duration::minutes(1);
let cycle_seven = ScannerMetrics {
collected_at,
current_cycle: 7,
current_cycle_active: Some(true),
current_started: collected_at - chrono::Duration::seconds(10),
cycles_completed_at: vec![older_completion, last_completion],
..Default::default()
};
let cycle_eight = ScannerMetrics {
collected_at: collected_at + chrono::Duration::seconds(1),
current_cycle: 8,
current_cycle_active: Some(true),
current_started: collected_at - chrono::Duration::seconds(5),
cycles_completed_at: vec![last_completion],
..Default::default()
};
let newer_idle = ScannerMetrics {
collected_at: collected_at + chrono::Duration::hours(1),
current_cycle_active: Some(false),
current_started: collected_at - chrono::Duration::hours(1),
..Default::default()
};
let mut left_associative = cycle_seven.clone();
left_associative.merge(&cycle_eight);
left_associative.merge(&newer_idle);
let mut right_group = cycle_eight.clone();
right_group.merge(&newer_idle);
let mut right_associative = cycle_seven.clone();
right_associative.merge(&right_group);
assert_eq!(left_associative.current_cycle, 8);
assert_eq!(right_associative.current_cycle, 8);
assert_eq!(left_associative.current_started, cycle_eight.current_started);
assert_eq!(right_associative.current_started, cycle_eight.current_started);
assert_eq!(left_associative.cycles_completed_at, cycle_eight.cycles_completed_at);
assert_eq!(right_associative.cycles_completed_at, cycle_eight.cycles_completed_at);
for order in [
[&cycle_seven, &cycle_eight, &newer_idle],
[&cycle_seven, &newer_idle, &cycle_eight],
[&cycle_eight, &cycle_seven, &newer_idle],
[&cycle_eight, &newer_idle, &cycle_seven],
[&newer_idle, &cycle_seven, &cycle_eight],
[&newer_idle, &cycle_eight, &cycle_seven],
] {
let mut merged = ScannerMetrics::default();
for scanner in order {
merged.merge(scanner);
}
assert_eq!(merged.current_cycle_active, Some(true));
assert_eq!(merged.current_cycle, 8);
assert_eq!(merged.current_started, cycle_eight.current_started);
assert_eq!(merged.cycles_completed_at, cycle_eight.cycles_completed_at);
}
}
#[test] #[test]
fn scanner_metrics_merge_aggregates_partial_cycles_by_source() { fn scanner_metrics_merge_aggregates_partial_cycles_by_source() {
let collected_at = Utc::now(); let collected_at = Utc::now();
+7 -7
View File
@@ -262,11 +262,11 @@ async fn obs_site_replication_stats() -> ReplicationStats {
} }
fn current_scanner_cycle_age_seconds( fn current_scanner_cycle_age_seconds(
current_cycle: u64, current_cycle_active: bool,
current_started: chrono::DateTime<Utc>, current_started: chrono::DateTime<Utc>,
now: chrono::DateTime<Utc>, now: chrono::DateTime<Utc>,
) -> u64 { ) -> u64 {
if current_cycle == 0 { if !current_cycle_active {
0 0
} else { } else {
now.signed_duration_since(current_started).num_seconds().max(0) as u64 now.signed_duration_since(current_started).num_seconds().max(0) as u64
@@ -1090,7 +1090,7 @@ pub async fn collect_scanner_metric_stats() -> Option<ScannerStats> {
let reference_time = metrics.cycles_completed_at.last().copied().unwrap_or(metrics.current_started); let reference_time = metrics.cycles_completed_at.last().copied().unwrap_or(metrics.current_started);
let last_activity_seconds = now.signed_duration_since(reference_time).num_seconds().max(0) as u64; let last_activity_seconds = now.signed_duration_since(reference_time).num_seconds().max(0) as u64;
let active_paths = metrics.active_scan_paths as u64; let active_paths = metrics.active_scan_paths as u64;
let current_cycle_age_seconds = current_scanner_cycle_age_seconds(metrics.current_cycle, metrics.current_started, now); let current_cycle_age_seconds = current_scanner_cycle_age_seconds(metrics.current_cycle_active, metrics.current_started, now);
let current_scan_mode = scanner_scan_mode_code(&metrics.current_scan_mode); let current_scan_mode = scanner_scan_mode_code(&metrics.current_scan_mode);
let current_cycle_age = current_cycle_age_seconds as f64; let current_cycle_age = current_cycle_age_seconds as f64;
let last_cycle_duration = metrics.last_cycle_duration_seconds; let last_cycle_duration = metrics.last_cycle_duration_seconds;
@@ -1464,21 +1464,21 @@ mod tests {
fn current_scanner_cycle_age_seconds_returns_zero_when_idle() { fn current_scanner_cycle_age_seconds_returns_zero_when_idle() {
let now = Utc::now(); let now = Utc::now();
assert_eq!(current_scanner_cycle_age_seconds(0, now - chrono::Duration::seconds(30), now), 0); assert_eq!(current_scanner_cycle_age_seconds(false, now - chrono::Duration::seconds(30), now), 0);
} }
#[test] #[test]
fn current_scanner_cycle_age_seconds_clamps_future_start() { fn current_scanner_cycle_age_seconds_clamps_future_start() {
let now = Utc::now(); let now = Utc::now();
assert_eq!(current_scanner_cycle_age_seconds(4, now + chrono::Duration::seconds(30), now), 0); assert_eq!(current_scanner_cycle_age_seconds(true, now + chrono::Duration::seconds(30), now), 0);
} }
#[test] #[test]
fn current_scanner_cycle_age_seconds_reports_active_elapsed_time() { fn current_scanner_cycle_age_seconds_reports_active_first_cycle_elapsed_time() {
let now = Utc::now(); let now = Utc::now();
assert_eq!(current_scanner_cycle_age_seconds(4, now - chrono::Duration::seconds(45), now), 45); assert_eq!(current_scanner_cycle_age_seconds(true, now - chrono::Duration::seconds(45), now), 45);
} }
#[test] #[test]
+5 -2
View File
@@ -25,6 +25,9 @@ description = "Protocol implementations for RustFS (FTPS, SFTP, etc.)"
keywords = ["ftp", "sftp", "protocol", "storage", "rustfs"] keywords = ["ftp", "sftp", "protocol", "storage", "rustfs"]
categories = ["network-programming", "filesystem"] categories = ["network-programming", "filesystem"]
[lints]
workspace = true
[features] [features]
default = [] default = []
ftps = ["dep:libunftp", "dep:unftp-core", "dep:rustls", "dep:rustfs-tls-runtime", "dep:subtle"] ftps = ["dep:libunftp", "dep:unftp-core", "dep:rustls", "dep:rustfs-tls-runtime", "dep:subtle"]
@@ -44,7 +47,7 @@ swift = [
"dep:tokio-util", "dep:tokio-util",
"dep:serde", "dep:serde",
"dep:urlencoding", "dep:urlencoding",
"dep:md5", "dep:md-5",
"dep:quick-xml", "dep:quick-xml",
"dep:hmac", "dep:hmac",
"dep:sha1", "dep:sha1",
@@ -108,7 +111,7 @@ http-body-util = { workspace = true, optional = true }
tokio-util = { workspace = true, optional = true, features = ["rt", "io", "compat"] } tokio-util = { workspace = true, optional = true, features = ["rt", "io", "compat"] }
serde = { workspace = true, optional = true, features = ["derive"] } serde = { workspace = true, optional = true, features = ["derive"] }
urlencoding = { workspace = true, optional = true } urlencoding = { workspace = true, optional = true }
md5 = { workspace = true, optional = true } md-5 = { workspace = true, optional = true }
quick-xml = { workspace = true, optional = true, features = ["serialize"] } quick-xml = { workspace = true, optional = true, features = ["serialize"] }
hmac = { workspace = true, optional = true } hmac = { workspace = true, optional = true }
sha1 = { workspace = true, optional = true } sha1 = { workspace = true, optional = true }
+5 -5
View File
@@ -608,7 +608,7 @@ impl StorageBackend for DummyBackend {
inner.put_object_calls.push(PutObjectCall { inner.put_object_calls.push(PutObjectCall {
bucket: input.bucket.to_string(), bucket: input.bucket.to_string(),
key: input.key.to_string(), key: input.key.to_string(),
metadata: input.metadata.clone(), metadata: input.metadata,
}); });
let stall = inner.stall_put_object; let stall = inner.stall_put_object;
let entered = inner.put_object_entered.clone(); let entered = inner.put_object_entered.clone();
@@ -731,7 +731,7 @@ impl StorageBackend for DummyBackend {
inner.create_multipart_calls.push(CreateMultipartCall { inner.create_multipart_calls.push(CreateMultipartCall {
bucket: input.bucket.to_string(), bucket: input.bucket.to_string(),
key: input.key.to_string(), key: input.key.to_string(),
metadata: input.metadata.clone(), metadata: input.metadata,
}); });
} }
match self.inner.lock().expect("lock").create_multipart_upload.pop_front() { match self.inner.lock().expect("lock").create_multipart_upload.pop_front() {
@@ -749,7 +749,7 @@ impl StorageBackend for DummyBackend {
inner.upload_part_calls.push(UploadPartCall { inner.upload_part_calls.push(UploadPartCall {
bucket: input.bucket.to_string(), bucket: input.bucket.to_string(),
key: input.key.to_string(), key: input.key.to_string(),
upload_id: input.upload_id.to_string(), upload_id: input.upload_id,
part_number: input.part_number, part_number: input.part_number,
content_length: input.content_length, content_length: input.content_length,
}); });
@@ -787,7 +787,7 @@ impl StorageBackend for DummyBackend {
inner.complete_multipart_calls.push(CompleteCall { inner.complete_multipart_calls.push(CompleteCall {
bucket: input.bucket.to_string(), bucket: input.bucket.to_string(),
key: input.key.to_string(), key: input.key.to_string(),
upload_id: input.upload_id.to_string(), upload_id: input.upload_id,
part_count, part_count,
}); });
} }
@@ -808,7 +808,7 @@ impl StorageBackend for DummyBackend {
inner.abort_multipart_calls.push(AbortCall { inner.abort_multipart_calls.push(AbortCall {
bucket: input.bucket.to_string(), bucket: input.bucket.to_string(),
key: input.key.to_string(), key: input.key.to_string(),
upload_id: input.upload_id.to_string(), upload_id: input.upload_id,
}); });
} }
match self.inner.lock().expect("lock").abort_multipart_upload.pop_front() { match self.inner.lock().expect("lock").abort_multipart_upload.pop_front() {
+1 -3
View File
@@ -379,9 +379,7 @@ where
.await .await
.map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?;
let prefix_with_slash = prefix let prefix_with_slash = prefix.clone().map(|p| if p.ends_with('/') { p } else { format!("{}/", p) });
.clone()
.map(|p| if p.ends_with('/') { p.to_string() } else { format!("{}/", p) });
let list_input = ListObjectsV2Input::builder() let list_input = ListObjectsV2Input::builder()
.bucket(bucket) .bucket(bucket)
+1 -1
View File
@@ -371,7 +371,7 @@ impl UserDetailProvider for FtpsUserDetailProvider {
let ftps_user = FtpsUser { let ftps_user = FtpsUser {
username: principal.username.clone(), username: principal.username.clone(),
name: identity.credentials.name.clone(), name: identity.credentials.name,
session_context, session_context,
}; };
+2 -2
View File
@@ -1495,12 +1495,12 @@ mod tests {
let buffer_len_u64 = part_buffer_len as u64; let buffer_len_u64 = part_buffer_len as u64;
let phase = match phase_variant { let phase = match phase_variant {
0 => WritePhase::Buffering { 0 => WritePhase::Buffering {
part_buffer: part_buffer.clone(), part_buffer,
}, },
1 => WritePhase::Streaming { 1 => WritePhase::Streaming {
upload_id: "UP-proptest".to_string(), upload_id: "UP-proptest".to_string(),
abort_authorized: true, abort_authorized: true,
part_buffer: part_buffer.clone(), part_buffer,
uploaded_parts: Vec::new(), uploaded_parts: Vec::new(),
next_part_number, next_part_number,
}, },
+24 -34
View File
@@ -14,11 +14,11 @@
//! Swift account operations and validation //! Swift account operations and validation
use super::metadata_update::{ACCOUNT_META_TAG_PREFIX, MetadataUpdate};
use super::storage_api::account::{BucketOperations, MakeBucketOptions}; use super::storage_api::account::{BucketOperations, MakeBucketOptions};
use super::{SwiftError, SwiftResult}; use super::{SwiftError, SwiftResult};
use super::{get_swift_bucket_metadata, resolve_swift_object_store_handle, update_swift_bucket_tagging, validate_metadata}; use super::{get_swift_bucket_metadata, resolve_swift_object_store_handle, update_swift_bucket_tagging};
use rustfs_credentials::Credentials; use rustfs_credentials::Credentials;
use s3s::dto::{Tag, Tagging};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
@@ -131,9 +131,8 @@ pub async fn get_account_metadata(account: &str, _credentials: &Option<Credentia
if let Some(tagging) = &bucket_meta.tagging_config { if let Some(tagging) = &bucket_meta.tagging_config {
for tag in &tagging.tag_set { for tag in &tagging.tag_set {
if let (Some(key), Some(value)) = (&tag.key, &tag.value) if let (Some(key), Some(value)) = (&tag.key, &tag.value)
&& let Some(meta_key) = key.strip_prefix("swift-account-meta-") && let Some(meta_key) = key.strip_prefix(ACCOUNT_META_TAG_PREFIX)
{ {
// Strip "swift-account-meta-" prefix
metadata.insert(meta_key.to_string(), value.clone()); metadata.insert(meta_key.to_string(), value.clone());
} }
} }
@@ -147,6 +146,12 @@ pub async fn get_account_metadata(account: &str, _credentials: &Option<Credentia
/// Updates account-level metadata such as TempURL keys. /// Updates account-level metadata such as TempURL keys.
/// Only updates swift-account-meta-* tags, preserving other tags. /// Only updates swift-account-meta-* tags, preserving other tags.
/// ///
/// Swift account POST is additive: `update` names the items to write and the
/// items to drop, and everything else keeps its stored value. Replacing the
/// whole set instead would make an unrelated POST — setting a quota, say —
/// delete the account's TempURL signing key, permanently invalidating every
/// outstanding TempURL and FormPost signature for the account.
///
/// The caller must own the account: this metadata holds the account's TempURL /// The caller must own the account: this metadata holds the account's TempURL
/// signing key, so writing it for someone else's account would let the writer /// signing key, so writing it for someone else's account would let the writer
/// mint valid pre-signed URLs against that account's objects. Reads /// mint valid pre-signed URLs against that account's objects. Reads
@@ -155,11 +160,11 @@ pub async fn get_account_metadata(account: &str, _credentials: &Option<Credentia
/// ///
/// # Arguments /// # Arguments
/// * `account` - Account identifier /// * `account` - Account identifier
/// * `metadata` - Metadata key-value pairs to store (keys will be prefixed with `swift-account-meta-`) /// * `update` - Metadata items to set and to remove (names are prefixed with `swift-account-meta-`)
/// * `credentials` - Keystone credentials of the caller /// * `credentials` - Keystone credentials of the caller
pub async fn update_account_metadata( pub async fn update_account_metadata(
account: &str, account: &str,
metadata: &HashMap<String, String>, update: &MetadataUpdate,
credentials: &Option<Credentials>, credentials: &Option<Credentials>,
) -> SwiftResult<()> { ) -> SwiftResult<()> {
let Some(credentials) = credentials.as_ref() else { let Some(credentials) = credentials.as_ref() else {
@@ -171,8 +176,15 @@ pub async fn update_account_metadata(
// These tags are persisted into the bucket metadata file, which every // These tags are persisted into the bucket metadata file, which every
// later config write rewrites in full — so unbounded metadata inflates // later config write rewrites in full — so unbounded metadata inflates
// the cost of unrelated writes for the life of the account. // the cost of unrelated writes for the life of the account. The item
validate_metadata(metadata)?; // count is capped against the merged result, inside the rewrite.
update.validate()?;
// An update that names no item changes nothing, so there is no reason to
// bring the account's metadata bucket into existence for it.
if update.is_empty() {
return Ok(());
}
let bucket_name = get_account_metadata_bucket_name(account); let bucket_name = get_account_metadata_bucket_name(account);
@@ -190,32 +202,10 @@ pub async fn update_account_metadata(
.map_err(|e| SwiftError::InternalServerError(format!("Failed to create account metadata bucket: {}", e)))?; .map_err(|e| SwiftError::InternalServerError(format!("Failed to create account metadata bucket: {}", e)))?;
} }
// Rewrite the persisted tags: replace swift-account-meta-* tags with the // Merge into the persisted tags: only the swift-account-meta-* items this
// new metadata while preserving other tags. An empty result clears the // update names change, and non-Swift tags are left alone. An empty result
// tagging config. // clears the tagging config.
update_swift_bucket_tagging(bucket_name, |current| { update_swift_bucket_tagging(bucket_name, |current| update.apply_to_tags(current, ACCOUNT_META_TAG_PREFIX)).await
let mut tagging = current.cloned().unwrap_or_else(|| Tagging { tag_set: vec![] });
tagging.tag_set.retain(|tag| {
if let Some(key) = &tag.key {
!key.starts_with("swift-account-meta-")
} else {
true
}
});
for (key, value) in metadata {
tagging.tag_set.push(Tag {
key: Some(format!("swift-account-meta-{}", key)),
value: Some(value.clone()),
});
}
tagging
})
.await?;
Ok(())
} }
/// Get TempURL key for account /// Get TempURL key for account
+1 -3
View File
@@ -627,9 +627,7 @@ mod tests {
#[test] #[test]
fn test_parse_paths_with_empty_lines() { fn test_parse_paths_with_empty_lines() {
let body = "/container1/file1.txt\n\n/container2/file2.txt\n \n/container1/file3.txt"; let body = "/container1/file1.txt\n\n/container2/file2.txt\n \n/container1/file3.txt";
let paths: Vec<&str> = body.lines().filter(|line| !line.trim().is_empty()).collect(); assert_eq!(body.lines().filter(|line| !line.trim().is_empty()).count(), 3);
assert_eq!(paths.len(), 3);
} }
/// Tests for the `extract_tar_entries` async function. /// Tests for the `extract_tar_entries` async function.
+97 -235
View File
@@ -17,15 +17,13 @@
//! This module implements Swift container CRUD operations and container-bucket translation. //! This module implements Swift container CRUD operations and container-bucket translation.
use super::account::validate_account_access; use super::account::validate_account_access;
use super::metadata_update::{CONTAINER_META_TAG_PREFIX, MetadataUpdate};
use super::storage_api::container::{ use super::storage_api::container::{
BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, ListOperations as _, MakeBucketOptions, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, ListOperations as _, MakeBucketOptions,
}; };
use super::types::Container; use super::types::Container;
use super::{SwiftError, SwiftResult}; use super::{SwiftError, SwiftResult};
use super::{ use super::{get_swift_bucket_metadata, get_swift_bucket_usage, resolve_swift_object_store_handle, update_swift_bucket_tagging};
get_swift_bucket_metadata, get_swift_bucket_usage, resolve_swift_object_store_handle, update_swift_bucket_tagging,
validate_metadata,
};
use rustfs_credentials::Credentials; use rustfs_credentials::Credentials;
use s3s::dto::{Tag, Tagging}; use s3s::dto::{Tag, Tagging};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -62,30 +60,6 @@ fn sanitize_storage_error<E: std::fmt::Display>(operation: &str, error: E) -> Sw
SwiftError::InternalServerError(format!("{} operation failed", operation)) SwiftError::InternalServerError(format!("{} operation failed", operation))
} }
/// Convert Swift container metadata to S3 tags
///
/// Swift container metadata uses X-Container-Meta-* headers.
/// We store these as S3 tags with "swift-meta-" prefix to distinguish from regular bucket tags.
///
/// Example: X-Container-Meta-Color: Blue → S3 Tag: swift-meta-color=Blue
fn swift_metadata_to_s3_tags(metadata: &std::collections::HashMap<String, String>) -> Option<Tagging> {
let mut tags = Vec::new();
for (key, value) in metadata {
// Store with "swift-meta-" prefix to namespace container metadata
tags.push(Tag {
key: Some(format!("swift-meta-{}", key.to_lowercase())),
value: Some(value.clone()),
});
}
if tags.is_empty() {
None
} else {
Some(Tagging { tag_set: tags })
}
}
/// Convert S3 tags back to Swift container metadata /// Convert S3 tags back to Swift container metadata
/// ///
/// Extracts only tags with "swift-meta-" prefix, which represent Swift container metadata. /// Extracts only tags with "swift-meta-" prefix, which represent Swift container metadata.
@@ -94,11 +68,9 @@ fn s3_tags_to_swift_metadata(tagging: &Tagging) -> std::collections::HashMap<Str
let mut metadata = std::collections::HashMap::new(); let mut metadata = std::collections::HashMap::new();
for tag in &tagging.tag_set { for tag in &tagging.tag_set {
// Only process tags with "swift-meta-" prefix
if let (Some(key), Some(value)) = (&tag.key, &tag.value) if let (Some(key), Some(value)) = (&tag.key, &tag.value)
&& let Some(meta_key) = key.strip_prefix("swift-meta-") && let Some(meta_key) = key.strip_prefix(CONTAINER_META_TAG_PREFIX)
{ {
// Skip "swift-meta-"
metadata.insert(meta_key.to_string(), value.clone()); metadata.insert(meta_key.to_string(), value.clone());
} }
} }
@@ -473,12 +445,15 @@ pub async fn get_container_metadata(account: &str, container: &str, credentials:
/// - Returns 204 No Content on success /// - Returns 204 No Content on success
/// - Returns 404 Not Found if container doesn't exist /// - Returns 404 Not Found if container doesn't exist
/// - Metadata is provided via X-Container-Meta-* headers /// - Metadata is provided via X-Container-Meta-* headers
/// - The update is additive: items the request does not name keep their stored
/// value, and removal is explicit, via `X-Remove-Container-Meta-{name}` or an
/// empty value
#[allow(dead_code)] // Used by handler #[allow(dead_code)] // Used by handler
pub async fn update_container_metadata( pub async fn update_container_metadata(
account: &str, account: &str,
container: &str, container: &str,
credentials: &Credentials, credentials: &Credentials,
metadata: std::collections::HashMap<String, String>, update: MetadataUpdate,
) -> SwiftResult<()> { ) -> SwiftResult<()> {
// Validate account access and extract project_id // Validate account access and extract project_id
let project_id = validate_account_access(account, credentials)?; let project_id = validate_account_access(account, credentials)?;
@@ -488,8 +463,9 @@ pub async fn update_container_metadata(
// These tags are persisted into the bucket metadata file, which every // These tags are persisted into the bucket metadata file, which every
// later config write rewrites in full — so unbounded metadata inflates // later config write rewrites in full — so unbounded metadata inflates
// the cost of unrelated writes for the life of the container. // the cost of unrelated writes for the life of the container. The item
validate_metadata(&metadata)?; // count is capped against the merged result, inside the rewrite.
update.validate()?;
// Create mapper with default config (tenant prefixing enabled) // Create mapper with default config (tenant prefixing enabled)
let mapper = ContainerMapper::default(); let mapper = ContainerMapper::default();
@@ -502,7 +478,9 @@ pub async fn update_container_metadata(
return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string())); return Err(SwiftError::InternalServerError("Storage layer not initialized".to_string()));
}; };
// Verify container exists // Verify container exists. Checked before the empty-update shortcut below,
// so a POST to a container that does not exist still answers 404 whether
// or not it carried metadata.
store store
.get_bucket_info(&bucket_name, &BucketOptions::default()) .get_bucket_info(&bucket_name, &BucketOptions::default())
.await .await
@@ -514,32 +492,19 @@ pub async fn update_container_metadata(
} }
})?; })?;
// Rewrite the persisted tags: replace swift-meta-* tags with the new // An update that names no item leaves stored metadata alone, so skip the
// metadata while preserving non-Swift tags. An empty result clears the // persisted write and the peer reload it triggers rather than rewriting
// tagging config. // the config to its current value. The handler reaches here on every
update_swift_bucket_tagging(bucket_name, |current| { // container POST, including ACL-only and versioning-only ones.
let mut tagging = current.cloned().unwrap_or_else(|| Tagging { tag_set: vec![] }); if update.is_empty() {
return Ok(());
}
tagging.tag_set.retain(|tag| { // Merge into the persisted tags: only the swift-meta-* items this update
if let Some(key) = &tag.key { // names change, so the container's other metadata — and the ACL and
!key.starts_with("swift-meta-") // versioning tags sharing this tag set — survive. An empty result clears
} else { // the tagging config.
true // Keep tags with no key (shouldn't happen, but be safe) update_swift_bucket_tagging(bucket_name, |current| update.apply_to_tags(current, CONTAINER_META_TAG_PREFIX)).await
}
});
if let Some(mut new_tagging) = swift_metadata_to_s3_tags(&metadata) {
tagging.tag_set.append(&mut new_tagging.tag_set);
}
// If metadata.is_empty() and swift_metadata_to_s3_tags returns None,
// we've already removed swift-meta-* tags above, so only non-Swift
// tags remain
tagging
})
.await?;
Ok(())
} }
/// Delete a container /// Delete a container
@@ -814,7 +779,7 @@ pub async fn enable_versioning(
value: Some(archive_container.to_string()), // Store Swift container name, not S3 bucket name value: Some(archive_container.to_string()), // Store Swift container name, not S3 bucket name
}); });
tagging Ok(tagging)
}) })
.await?; .await?;
@@ -871,7 +836,7 @@ pub async fn disable_versioning(account: &str, container: &str, credentials: &Cr
.tag_set .tag_set
.retain(|tag| tag.key.as_deref() != Some("swift-versions-location")); .retain(|tag| tag.key.as_deref() != Some("swift-versions-location"));
tagging Ok(tagging)
}) })
.await?; .await?;
@@ -1026,7 +991,7 @@ pub async fn set_container_acl(
}); });
} }
tagging Ok(tagging)
}) })
.await?; .await?;
@@ -1374,64 +1339,6 @@ mod tests {
assert!(first_char.is_ascii_lowercase() || first_char.is_ascii_digit()); assert!(first_char.is_ascii_lowercase() || first_char.is_ascii_digit());
} }
#[test]
fn test_swift_metadata_to_s3_tags() {
let mut metadata = std::collections::HashMap::new();
metadata.insert("color".to_string(), "blue".to_string());
metadata.insert("description".to_string(), "test container".to_string());
let tagging = swift_metadata_to_s3_tags(&metadata).unwrap();
assert_eq!(tagging.tag_set.len(), 2);
// Verify tags have swift-meta- prefix
let color_tag = tagging
.tag_set
.iter()
.find(|t| t.key.as_deref() == Some("swift-meta-color"))
.expect("color tag not found");
assert_eq!(color_tag.value.as_deref(), Some("blue"));
let desc_tag = tagging
.tag_set
.iter()
.find(|t| t.key.as_deref() == Some("swift-meta-description"))
.expect("description tag not found");
assert_eq!(desc_tag.value.as_deref(), Some("test container"));
}
#[test]
fn test_swift_metadata_to_s3_tags_empty() {
let metadata = std::collections::HashMap::new();
let tagging = swift_metadata_to_s3_tags(&metadata);
assert!(tagging.is_none());
}
#[test]
fn test_swift_metadata_to_s3_tags_case_normalization() {
let mut metadata = std::collections::HashMap::new();
metadata.insert("Color".to_string(), "Red".to_string());
metadata.insert("PRIORITY".to_string(), "High".to_string());
let tagging = swift_metadata_to_s3_tags(&metadata).unwrap();
// Keys should be lowercased
assert!(tagging.tag_set.iter().any(|t| t.key.as_deref() == Some("swift-meta-color")));
assert!(
tagging
.tag_set
.iter()
.any(|t| t.key.as_deref() == Some("swift-meta-priority"))
);
// Values should be preserved as-is
let color_tag = tagging
.tag_set
.iter()
.find(|t| t.key.as_deref() == Some("swift-meta-color"))
.unwrap();
assert_eq!(color_tag.value.as_deref(), Some("Red"));
}
#[test] #[test]
fn test_s3_tags_to_swift_metadata() { fn test_s3_tags_to_swift_metadata() {
let tagging = Tagging { let tagging = Tagging {
@@ -1487,91 +1394,80 @@ mod tests {
#[test] #[test]
fn test_metadata_roundtrip() { fn test_metadata_roundtrip() {
// Test that we can convert metadata -> tags -> metadata without loss // What a POST writes must be what a HEAD reads back: run the items
let mut original_metadata = std::collections::HashMap::new(); // through the tag-merge write path and the tag-parse read path.
original_metadata.insert("color".to_string(), "blue".to_string()); let update = MetadataUpdate::default()
original_metadata.insert("owner".to_string(), "alice".to_string()); .set("color", "blue")
original_metadata.insert("priority".to_string(), "high".to_string()); .set("owner", "alice")
.set("priority", "high");
let tagging = swift_metadata_to_s3_tags(&original_metadata).unwrap(); let tagging = update
let recovered_metadata = s3_tags_to_swift_metadata(&tagging); .apply_to_tags(None, CONTAINER_META_TAG_PREFIX)
.expect("merge should be accepted");
let recovered = s3_tags_to_swift_metadata(&tagging);
assert_eq!(recovered_metadata.len(), original_metadata.len()); assert_eq!(recovered.len(), 3);
for (key, value) in &original_metadata { assert_eq!(recovered.get("color").map(String::as_str), Some("blue"));
assert_eq!( assert_eq!(recovered.get("owner").map(String::as_str), Some("alice"));
recovered_metadata.get(&key.to_lowercase()), assert_eq!(recovered.get("priority").map(String::as_str), Some("high"));
Some(value),
"Metadata key {} not preserved in roundtrip",
key
);
}
} }
#[test] #[test]
fn test_tag_preservation_merge_with_existing() { fn test_tag_preservation_merge_with_existing() {
// Test merging Swift metadata with existing non-Swift tags // A container metadata POST shares its tag set with the container ACL
let mut existing_tagging = Tagging { // and versioning tags, and with whatever S3 tags the bucket carries.
// Only the swift-meta-* items the POST names may change.
let existing = Tagging {
tag_set: vec![ tag_set: vec![
Tag { Tag {
key: Some("swift-meta-color".to_string()), key: Some("swift-meta-color".to_string()),
value: Some("blue".to_string()), value: Some("blue".to_string()),
}, },
Tag {
key: Some("swift-acl-read".to_string()),
value: Some(".r:*".to_string()),
},
Tag {
key: Some("swift-versions-location".to_string()),
value: Some("archive".to_string()),
},
Tag { Tag {
key: Some("env".to_string()), key: Some("env".to_string()),
value: Some("production".to_string()), value: Some("production".to_string()),
}, },
Tag {
key: Some("team".to_string()),
value: Some("backend".to_string()),
},
], ],
}; };
// Remove old swift-meta-* tags let merged = MetadataUpdate::default()
existing_tagging.tag_set.retain(|tag| { .set("description", "test")
if let Some(key) = &tag.key { .apply_to_tags(Some(&existing), CONTAINER_META_TAG_PREFIX)
!key.starts_with("swift-meta-") .expect("merge should be accepted");
} else { let tag = |key: &str| {
true merged
} .tag_set
}); .iter()
.find(|t| t.key.as_deref() == Some(key))
.and_then(|t| t.value.as_deref())
};
// Add new Swift metadata assert_eq!(tag("swift-meta-description"), Some("test"), "the new item should be added");
let mut new_metadata = std::collections::HashMap::new(); assert_eq!(tag("swift-meta-color"), Some("blue"), "an unnamed item should be preserved");
new_metadata.insert("description".to_string(), "test".to_string()); assert_eq!(tag("swift-acl-read"), Some(".r:*"), "the ACL tag should be preserved");
let mut new_tagging = swift_metadata_to_s3_tags(&new_metadata).unwrap(); assert_eq!(tag("swift-versions-location"), Some("archive"), "the versioning tag should be preserved");
assert_eq!(tag("env"), Some("production"), "non-Swift tags should be preserved");
// Merge assert_eq!(merged.tag_set.len(), 5);
existing_tagging.tag_set.append(&mut new_tagging.tag_set);
// Verify: should have env, team, and new swift-meta-description
assert_eq!(existing_tagging.tag_set.len(), 3);
let has_env = existing_tagging.tag_set.iter().any(|t| t.key.as_deref() == Some("env"));
let has_team = existing_tagging.tag_set.iter().any(|t| t.key.as_deref() == Some("team"));
let has_description = existing_tagging
.tag_set
.iter()
.any(|t| t.key.as_deref() == Some("swift-meta-description"));
assert!(has_env, "env tag should be preserved");
assert!(has_team, "team tag should be preserved");
assert!(has_description, "swift-meta-description should be added");
} }
#[test] #[test]
fn test_tag_preservation_remove_only_swift() { fn test_tag_preservation_remove_only_swift() {
// Test that clearing Swift metadata preserves non-Swift tags // Removing the last container metadata item leaves the other tags
let mut existing_tagging = Tagging { // and so must not clear the tagging config.
let existing = Tagging {
tag_set: vec![ tag_set: vec![
Tag { Tag {
key: Some("swift-meta-color".to_string()), key: Some("swift-meta-color".to_string()),
value: Some("blue".to_string()), value: Some("blue".to_string()),
}, },
Tag {
key: Some("swift-meta-owner".to_string()),
value: Some("alice".to_string()),
},
Tag { Tag {
key: Some("env".to_string()), key: Some("env".to_string()),
value: Some("production".to_string()), value: Some("production".to_string()),
@@ -1583,37 +1479,28 @@ mod tests {
], ],
}; };
// Remove swift-meta-* tags (simulating empty metadata update) let merged = MetadataUpdate::default()
existing_tagging.tag_set.retain(|tag| { .remove("color")
if let Some(key) = &tag.key { .apply_to_tags(Some(&existing), CONTAINER_META_TAG_PREFIX)
!key.starts_with("swift-meta-") .expect("merge should be accepted");
} else {
true
}
});
// Verify: should only have env and cost-center assert_eq!(merged.tag_set.len(), 2);
assert_eq!(existing_tagging.tag_set.len(), 2); assert!(merged.tag_set.iter().any(|t| t.key.as_deref() == Some("env")));
assert!(merged.tag_set.iter().any(|t| t.key.as_deref() == Some("cost-center")));
let has_env = existing_tagging.tag_set.iter().any(|t| t.key.as_deref() == Some("env")); assert!(
let has_cost_center = existing_tagging !merged
.tag_set .tag_set
.iter() .iter()
.any(|t| t.key.as_deref() == Some("cost-center")); .any(|t| t.key.as_ref().is_some_and(|k| k.starts_with(CONTAINER_META_TAG_PREFIX))),
let has_swift_meta = existing_tagging "the removed item should be gone"
.tag_set );
.iter()
.any(|t| t.key.as_ref().is_some_and(|k| k.starts_with("swift-meta-")));
assert!(has_env, "env tag should be preserved");
assert!(has_cost_center, "cost-center tag should be preserved");
assert!(!has_swift_meta, "all swift-meta-* tags should be removed");
} }
#[test] #[test]
fn test_tag_preservation_empty_after_swift_removal() { fn test_tag_preservation_empty_after_swift_removal() {
// Test that if only Swift tags exist, clearing them results in empty tagging // Removing every item when nothing else is tagged empties the tag set,
let mut existing_tagging = Tagging { // which is how the caller knows to clear the tagging config.
let existing = Tagging {
tag_set: vec![ tag_set: vec![
Tag { Tag {
key: Some("swift-meta-color".to_string()), key: Some("swift-meta-color".to_string()),
@@ -1626,38 +1513,13 @@ mod tests {
], ],
}; };
// Remove swift-meta-* tags let merged = MetadataUpdate::default()
existing_tagging.tag_set.retain(|tag| { .remove("color")
if let Some(key) = &tag.key { .remove("owner")
!key.starts_with("swift-meta-") .apply_to_tags(Some(&existing), CONTAINER_META_TAG_PREFIX)
} else { .expect("merge should be accepted");
true
}
});
// Verify: should be empty assert!(merged.tag_set.is_empty(), "tagging should be empty after removing every swift-meta-* tag");
assert!(
existing_tagging.tag_set.is_empty(),
"tagging should be empty after removing all swift-meta-* tags"
);
}
#[test]
fn test_tag_preservation_no_existing_tags() {
// Test adding Swift metadata when no tags exist
let existing_tagging = Tagging { tag_set: vec![] };
let mut new_metadata = std::collections::HashMap::new();
new_metadata.insert("color".to_string(), "blue".to_string());
let mut new_tagging = swift_metadata_to_s3_tags(&new_metadata).unwrap();
let mut merged = existing_tagging.clone();
merged.tag_set.append(&mut new_tagging.tag_set);
// Verify: should have only the new Swift tag
assert_eq!(merged.tag_set.len(), 1);
assert_eq!(merged.tag_set[0].key.as_deref(), Some("swift-meta-color"));
assert_eq!(merged.tag_set[0].value.as_deref(), Some("blue"));
} }
// Object Versioning Tests // Object Versioning Tests
+17 -35
View File
@@ -20,6 +20,7 @@
use super::container; use super::container;
use super::dlo; use super::dlo;
use super::metadata_update::MetadataUpdate;
use super::object; use super::object;
use super::slo; use super::slo;
use super::tempurl; use super::tempurl;
@@ -321,32 +322,15 @@ async fn handle_authenticated_request(
Err(SwiftError::NotImplemented("Swift Account HEAD operation not yet implemented".to_string())) Err(SwiftError::NotImplemented("Swift Account HEAD operation not yet implemented".to_string()))
} }
Method::POST => { Method::POST => {
// Account metadata update - extract headers // Account metadata update. Additive, per Swift: the request
let mut metadata = std::collections::HashMap::new(); // names the items to set (X-Account-Meta-*) and the items to
// drop (X-Remove-Account-Meta-*, or an empty value), and
// everything it does not name keeps its stored value. The
// TempURL signing key lives here, so a replacing POST would
// invalidate every outstanding signature for the account.
let update = MetadataUpdate::from_account_headers(&headers);
// Extract X-Account-Meta-* headers super::account::update_account_metadata(&account, &update, &credentials_opt).await?;
for (key, value) in &headers {
let key_str = key.as_str();
if let Some(meta_key) = key_str.strip_prefix("x-account-meta-") {
// Strip "x-account-meta-"
if let Ok(value_str) = value.to_str() {
metadata.insert(meta_key.to_string(), value_str.to_string());
}
}
}
// Special handling for TempURL key headers
// X-Account-Meta-Temp-URL-Key or X-Account-Meta-Temp-Url-Key
if let Some(tempurl_key) = headers
.get("x-account-meta-temp-url-key")
.or_else(|| headers.get("x-account-meta-temp-Url-key"))
&& let Ok(key_str) = tempurl_key.to_str()
{
metadata.insert("temp-url-key".to_string(), key_str.to_string());
}
// Update account metadata
super::account::update_account_metadata(&account, &metadata, &credentials_opt).await?;
let trans_id = generate_trans_id(); let trans_id = generate_trans_id();
Response::builder() Response::builder()
@@ -581,17 +565,15 @@ async fn handle_authenticated_request(
container::set_container_acl(&account, &container, new_read, new_write, &credentials).await?; container::set_container_acl(&account, &container, new_read, new_write, &credentials).await?;
} }
// Update container metadata - now we have access to request headers // Update container metadata. Additive, per Swift: items the
let mut metadata = std::collections::HashMap::new(); // request does not name keep their stored value, and removal
for (name, value) in headers.iter() { // is explicit (X-Remove-Container-Meta-*, or an empty value).
if let Some(meta_key) = name.as_str().strip_prefix("x-container-meta-") // Still called when the request named no item — an ACL-only
&& let Ok(value_str) = value.to_str() // or versioning-only POST — because this is what reports 404
{ // for a container that does not exist.
metadata.insert(meta_key.to_string(), value_str.to_string()); let update = MetadataUpdate::from_container_headers(&headers);
}
}
container::update_container_metadata(&account, &container, &credentials, metadata).await?; container::update_container_metadata(&account, &container, &credentials, update).await?;
let trans_id = generate_trans_id(); let trans_id = generate_trans_id();
Response::builder() Response::builder()
@@ -0,0 +1,410 @@
// 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.
//! Account and container metadata updates
//!
//! Swift account and container POSTs are *additive*: an item the request does
//! not mention keeps its stored value, and removal is explicit — either
//! `X-Remove-{Account,Container}-Meta-{name}`, or the item sent with an empty
//! value. Object POST is the one that replaces the whole set; `swift::object`
//! handles that separately and must keep doing so.
//!
//! Getting this wrong is not a cosmetic divergence. Account metadata holds the
//! TempURL signing key, so a POST that set an unrelated item while dropping
//! the rest would invalidate every outstanding TempURL and FormPost signature
//! for the account.
//!
//! This module turns a request's headers into the items to write and the items
//! to drop, and applies that to the bucket tag set the metadata is persisted
//! in.
use super::{MAX_METADATA_COUNT, MAX_METADATA_VALUE_SIZE, SwiftError, SwiftResult};
use axum::http::HeaderMap;
use s3s::dto::{Tag, Tagging};
use std::collections::{BTreeMap, BTreeSet};
/// Request header prefix carrying an account metadata item.
const ACCOUNT_META_HEADER_PREFIX: &str = "x-account-meta-";
/// Request header prefix removing an account metadata item.
const ACCOUNT_META_REMOVE_HEADER_PREFIX: &str = "x-remove-account-meta-";
/// Request header prefix carrying a container metadata item.
const CONTAINER_META_HEADER_PREFIX: &str = "x-container-meta-";
/// Request header prefix removing a container metadata item.
const CONTAINER_META_REMOVE_HEADER_PREFIX: &str = "x-remove-container-meta-";
/// Bucket-tag namespace holding account metadata items.
pub(crate) const ACCOUNT_META_TAG_PREFIX: &str = "swift-account-meta-";
/// Bucket-tag namespace holding container metadata items.
///
/// Deliberately narrower than the `swift-` tags around it: the container ACL
/// (`swift-acl-*`) and versioning (`swift-versions-location`) tags share this
/// tag set and must survive a metadata POST.
pub(crate) const CONTAINER_META_TAG_PREFIX: &str = "swift-meta-";
/// The metadata changes carried by one account or container POST.
///
/// Item names are held lowercased. Swift metadata names are case-insensitive,
/// HTTP header names arrive lowercased anyway, and a removal has to match the
/// name a previous POST stored — so normalizing once here is what makes
/// `X-Remove-Container-Meta-Color` find a stored `color`.
///
/// Ordered rather than hashed so the persisted tag set — and therefore the
/// serialized XML — comes out in a stable order for a given update.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MetadataUpdate {
/// Items to write, by name.
items: BTreeMap<String, String>,
/// Names to drop.
removals: BTreeSet<String>,
}
impl MetadataUpdate {
/// Write `name` = `value`.
pub fn set(mut self, name: &str, value: &str) -> Self {
let name = name.to_lowercase();
self.removals.remove(&name);
self.items.insert(name, value.to_string());
self
}
/// Drop `name`, if it is stored.
pub fn remove(mut self, name: &str) -> Self {
let name = name.to_lowercase();
self.items.remove(&name);
self.removals.insert(name);
self
}
/// Parse the metadata headers of an account POST.
pub(crate) fn from_account_headers(headers: &HeaderMap) -> Self {
Self::from_headers(headers, ACCOUNT_META_HEADER_PREFIX, ACCOUNT_META_REMOVE_HEADER_PREFIX)
}
/// Parse the metadata headers of a container POST.
pub(crate) fn from_container_headers(headers: &HeaderMap) -> Self {
Self::from_headers(headers, CONTAINER_META_HEADER_PREFIX, CONTAINER_META_REMOVE_HEADER_PREFIX)
}
/// Parse metadata headers under `item_prefix`, and removals under
/// `remove_prefix`. Both prefixes are lowercase, matching how `http`
/// normalizes header names.
///
/// An item sent with an empty value is a removal — the deletion path
/// Swift clients use when they do not send a dedicated remove header.
/// A name carried by both header forms is removed: the explicit removal
/// wins, as it does in Swift, where a remove header is rewritten into an
/// empty-valued item header.
fn from_headers(headers: &HeaderMap, item_prefix: &str, remove_prefix: &str) -> Self {
let mut update = Self::default();
for (name, value) in headers {
let Some(item) = name.as_str().strip_prefix(item_prefix) else {
continue;
};
// A value that is not valid UTF-8 cannot be stored as a tag.
// Skipping it matches how the rest of the Swift handlers treat
// unreadable header values.
let Ok(value) = value.to_str() else {
continue;
};
update = if value.is_empty() {
update.remove(item)
} else {
update.set(item, value)
};
}
for name in headers.keys() {
if let Some(item) = name.as_str().strip_prefix(remove_prefix) {
update = update.remove(item);
}
}
update
}
/// Whether this update changes anything.
///
/// An ACL-only or versioning-only container POST produces an empty update:
/// it names no metadata item, so it must leave stored metadata alone.
pub(crate) fn is_empty(&self) -> bool {
self.items.is_empty() && self.removals.is_empty()
}
/// Reject oversized values before anything is persisted.
///
/// The item *count* is not checked here — an additive POST has to be
/// measured against the merged result, which only [`Self::apply_to_tags`]
/// can see.
pub(crate) fn validate(&self) -> SwiftResult<()> {
for (name, value) in &self.items {
if value.len() > MAX_METADATA_VALUE_SIZE {
return Err(SwiftError::BadRequest(format!(
"Metadata value for '{}' too large: {} bytes (max: {} bytes)",
name,
value.len(),
MAX_METADATA_VALUE_SIZE
)));
}
}
Ok(())
}
/// Merge this update into the persisted tag set.
///
/// `prefix` is the tag namespace holding the items. Tags outside it — the
/// container ACL and versioning tags, plus any S3 tags the bucket carries
/// — are untouched, and so are the namespaced items this update does not
/// name.
///
/// The item-count cap is checked against the merged result rather than the
/// request: because POSTs are additive, a client could otherwise walk past
/// it one header at a time. This runs inside the bucket metadata write
/// guard, so the count it checks is the one about to be persisted.
pub(crate) fn apply_to_tags(&self, current: Option<&Tagging>, prefix: &str) -> SwiftResult<Tagging> {
let mut tagging = current.cloned().unwrap_or_else(|| Tagging { tag_set: vec![] });
tagging.tag_set.retain(|tag| match item_name(tag, prefix) {
Some(name) => !self.items.contains_key(name) && !self.removals.contains(name),
None => true,
});
let merged = tagging.tag_set.iter().filter(|tag| item_name(tag, prefix).is_some()).count() + self.items.len();
if merged > MAX_METADATA_COUNT {
return Err(SwiftError::BadRequest(format!(
"Too many metadata headers: {} (max: {})",
merged, MAX_METADATA_COUNT
)));
}
for (name, value) in &self.items {
tagging.tag_set.push(Tag {
key: Some(format!("{}{}", prefix, name)),
value: Some(value.clone()),
});
}
Ok(tagging)
}
}
/// The metadata item name a tag carries, or `None` if the tag does not belong
/// to this namespace.
fn item_name<'a>(tag: &'a Tag, prefix: &str) -> Option<&'a str> {
tag.key.as_deref()?.strip_prefix(prefix)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderName, HeaderValue};
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(
HeaderName::from_bytes(name.as_bytes()).expect("test header name should parse"),
HeaderValue::from_str(value).expect("test header value should parse"),
);
}
map
}
fn tags(pairs: &[(&str, &str)]) -> Tagging {
Tagging {
tag_set: pairs
.iter()
.map(|(key, value)| Tag {
key: Some((*key).to_string()),
value: Some((*value).to_string()),
})
.collect(),
}
}
fn tag_value<'a>(tagging: &'a Tagging, key: &str) -> Option<&'a str> {
tagging
.tag_set
.iter()
.find(|tag| tag.key.as_deref() == Some(key))
.and_then(|tag| tag.value.as_deref())
}
#[test]
fn from_headers_collects_items_and_ignores_unrelated_headers() {
let update = MetadataUpdate::from_container_headers(&headers(&[
("x-container-meta-color", "blue"),
("x-container-read", ".r:*"),
("content-type", "text/plain"),
]));
assert_eq!(update, MetadataUpdate::default().set("color", "blue"));
}
#[test]
fn from_headers_lowercases_item_names() {
// `http` normalizes header names on the way in, so the mixed case a
// client sends is already gone by the time a handler sees it; the
// lowercasing here is what makes a directly built update match.
let update = MetadataUpdate::from_container_headers(&headers(&[("X-Container-Meta-Color", "Blue")]));
assert_eq!(update, MetadataUpdate::default().set("COLOR", "Blue"));
}
#[test]
fn empty_value_is_a_removal() {
let update = MetadataUpdate::from_container_headers(&headers(&[("x-container-meta-color", "")]));
assert_eq!(update, MetadataUpdate::default().remove("color"));
}
#[test]
fn remove_header_drops_the_item() {
let update = MetadataUpdate::from_account_headers(&headers(&[("x-remove-account-meta-temp-url-key", "x")]));
assert_eq!(update, MetadataUpdate::default().remove("temp-url-key"));
}
#[test]
fn remove_header_wins_over_a_value_for_the_same_item() {
let update = MetadataUpdate::from_container_headers(&headers(&[
("x-container-meta-color", "blue"),
("x-remove-container-meta-color", "x"),
]));
assert_eq!(update, MetadataUpdate::default().remove("color"));
}
#[test]
fn a_removal_header_is_not_mistaken_for_an_item() {
// "x-remove-container-meta-color" must not also parse as the item
// "remove-container-meta-color" or similar.
let update = MetadataUpdate::from_container_headers(&headers(&[("x-remove-container-meta-color", "x")]));
assert!(update.items.is_empty(), "a removal header must not set an item");
}
#[test]
fn a_request_with_no_metadata_headers_is_empty() {
assert!(MetadataUpdate::from_container_headers(&headers(&[("x-container-read", ".r:*")])).is_empty());
assert!(MetadataUpdate::default().is_empty());
assert!(!MetadataUpdate::default().remove("color").is_empty());
}
#[test]
fn apply_preserves_items_the_update_does_not_name() {
let current = tags(&[
("swift-meta-color", "blue"),
("swift-meta-season", "summer"),
("swift-acl-read", ".r:*"),
("swift-versions-location", "archive"),
("unrelated-s3-tag", "keep"),
]);
let merged = MetadataUpdate::default()
.set("mood", "calm")
.apply_to_tags(Some(&current), CONTAINER_META_TAG_PREFIX)
.expect("merge should be accepted");
assert_eq!(tag_value(&merged, "swift-meta-color"), Some("blue"));
assert_eq!(tag_value(&merged, "swift-meta-season"), Some("summer"));
assert_eq!(tag_value(&merged, "swift-meta-mood"), Some("calm"));
assert_eq!(tag_value(&merged, "swift-acl-read"), Some(".r:*"));
assert_eq!(tag_value(&merged, "swift-versions-location"), Some("archive"));
assert_eq!(tag_value(&merged, "unrelated-s3-tag"), Some("keep"));
}
#[test]
fn apply_overwrites_a_named_item_exactly_once() {
let current = tags(&[("swift-meta-color", "blue")]);
let merged = MetadataUpdate::default()
.set("color", "red")
.apply_to_tags(Some(&current), CONTAINER_META_TAG_PREFIX)
.expect("merge should be accepted");
assert_eq!(merged.tag_set.len(), 1, "overwriting must not duplicate the tag");
assert_eq!(tag_value(&merged, "swift-meta-color"), Some("red"));
}
#[test]
fn apply_drops_only_the_removed_item() {
let current = tags(&[
("swift-meta-color", "blue"),
("swift-meta-season", "summer"),
("swift-acl-read", ".r:*"),
]);
let merged = MetadataUpdate::default()
.remove("color")
.apply_to_tags(Some(&current), CONTAINER_META_TAG_PREFIX)
.expect("merge should be accepted");
assert_eq!(tag_value(&merged, "swift-meta-color"), None);
assert_eq!(tag_value(&merged, "swift-meta-season"), Some("summer"));
assert_eq!(tag_value(&merged, "swift-acl-read"), Some(".r:*"));
}
#[test]
fn apply_to_an_untagged_bucket_starts_from_nothing() {
let merged = MetadataUpdate::default()
.set("color", "blue")
.apply_to_tags(None, ACCOUNT_META_TAG_PREFIX)
.expect("merge should be accepted");
assert_eq!(tag_value(&merged, "swift-account-meta-color"), Some("blue"));
}
#[test]
fn apply_caps_the_merged_item_count_not_the_request() {
let current = Tagging {
tag_set: (0..MAX_METADATA_COUNT)
.map(|i| Tag {
key: Some(format!("{}item{}", CONTAINER_META_TAG_PREFIX, i)),
value: Some("v".to_string()),
})
.collect(),
};
// One more item than the container already stores: rejected, even
// though the request itself carries a single header.
let err = MetadataUpdate::default()
.set("overflow", "v")
.apply_to_tags(Some(&current), CONTAINER_META_TAG_PREFIX)
.expect_err("exceeding the item cap must be rejected");
assert!(matches!(err, SwiftError::BadRequest(_)), "expected BadRequest, got {err:?}");
// Overwriting an item already counted stays at the cap.
MetadataUpdate::default()
.set("item0", "v2")
.apply_to_tags(Some(&current), CONTAINER_META_TAG_PREFIX)
.expect("overwriting an existing item must not trip the cap");
}
#[test]
fn validate_rejects_oversized_values() {
let err = MetadataUpdate::default()
.set("color", &"b".repeat(MAX_METADATA_VALUE_SIZE + 1))
.validate()
.expect_err("an oversized value must be rejected");
assert!(matches!(err, SwiftError::BadRequest(_)), "expected BadRequest, got {err:?}");
MetadataUpdate::default()
.set("color", &"b".repeat(MAX_METADATA_VALUE_SIZE))
.validate()
.expect("a value at the limit must be accepted");
}
}
+6 -3
View File
@@ -44,6 +44,7 @@ pub mod expiration;
pub mod expiration_worker; pub mod expiration_worker;
pub mod formpost; pub mod formpost;
pub mod handler; pub mod handler;
pub mod metadata_update;
pub mod object; pub mod object;
pub mod quota; pub mod quota;
pub mod router; pub mod router;
@@ -57,6 +58,7 @@ pub mod types;
pub mod versioning; pub mod versioning;
pub use errors::{SwiftError, SwiftResult}; pub use errors::{SwiftError, SwiftResult};
pub use metadata_update::MetadataUpdate;
pub use router::{SwiftRoute, SwiftRouter}; pub use router::{SwiftRoute, SwiftRouter};
/// Maximum number of metadata headers allowed per resource (Swift standard) /// Maximum number of metadata headers allowed per resource (Swift standard)
@@ -71,9 +73,10 @@ pub(crate) const MAX_METADATA_VALUE_SIZE: usize = 256;
/// - Total number of metadata entries doesn't exceed MAX_METADATA_COUNT /// - Total number of metadata entries doesn't exceed MAX_METADATA_COUNT
/// - Individual metadata values don't exceed MAX_METADATA_VALUE_SIZE /// - Individual metadata values don't exceed MAX_METADATA_VALUE_SIZE
/// ///
/// Applies to object, container and account metadata alike: all three are /// This is the object-metadata form, where a POST replaces the whole set and
/// persisted, and container/account metadata additionally lands in the /// the request is therefore the complete result. Account and container POSTs
/// bucket metadata file that every later config write rewrites in full. /// are additive, so their count has to be measured against the merged state
/// instead — see [`MetadataUpdate::apply_to_tags`].
/// ///
/// Returns error if limits are exceeded. /// Returns error if limits are exceeded.
pub(crate) fn validate_metadata(metadata: &std::collections::HashMap<String, String>) -> SwiftResult<()> { pub(crate) fn validate_metadata(metadata: &std::collections::HashMap<String, String>) -> SwiftResult<()> {
+1 -2
View File
@@ -206,10 +206,9 @@ impl ObjectKeyMapper {
#[allow(dead_code)] // Used in: object operations #[allow(dead_code)] // Used in: object operations
pub fn normalize_path(object: &str) -> String { pub fn normalize_path(object: &str) -> String {
// Split by '/', filter out empty segments (except if it's the end) // Split by '/', filter out empty segments (except if it's the end)
let segments: Vec<&str> = object.split('/').collect();
let has_trailing_slash = object.ends_with('/'); let has_trailing_slash = object.ends_with('/');
let normalized_segments: Vec<&str> = segments.into_iter().filter(|s| !s.is_empty()).collect(); let normalized_segments: Vec<&str> = object.split('/').filter(|s| !s.is_empty()).collect();
let mut result = normalized_segments.join("/"); let mut result = normalized_segments.join("/");
+4 -3
View File
@@ -21,6 +21,7 @@
use super::storage_api::large_object::HTTPRangeSpec; use super::storage_api::large_object::HTTPRangeSpec;
use super::{SwiftError, object}; use super::{SwiftError, object};
use axum::http::{HeaderMap, Response, StatusCode}; use axum::http::{HeaderMap, Response, StatusCode};
use md5::{Digest as Md5Digest, Md5};
use rustfs_credentials::Credentials; use rustfs_credentials::Credentials;
use s3s::Body; use s3s::Body;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -81,9 +82,9 @@ impl SLOManifest {
etag_concat.push_str(etag); etag_concat.push_str(etag);
} }
// Calculate MD5 hash let mut hasher = Md5::new();
let hash = md5::compute(etag_concat.as_bytes()); hasher.update(etag_concat.as_bytes());
format!("\"{:x}-{}\"", hash, self.segments.len()) format!("\"{}-{}\"", hex::encode(hasher.finalize()), self.segments.len())
} }
/// Validate manifest against actual segments /// Validate manifest against actual segments
+23 -2
View File
@@ -88,6 +88,10 @@ pub(crate) async fn get_swift_bucket_metadata(bucket: &str) -> SwiftStorageResul
/// disk-truth reloads. Peers are then told to reload, matching what the S3 /// disk-truth reloads. Peers are then told to reload, matching what the S3
/// handlers do after a config write. /// handlers do after a config write.
/// ///
/// A rewrite may also refuse the update outright, for limits that can only be
/// judged against the state being merged into. That verdict is the client's
/// answer, so it is returned as-is rather than folded into a storage error.
///
/// Storage failures are logged in full and reported to the client as a /// Storage failures are logged in full and reported to the client as a
/// generic error: these now carry real disk and quorum detail, which does not /// generic error: these now carry real disk and quorum detail, which does not
/// belong in a Swift response body. The one exception is an unreadable /// belong in a Swift response body. The one exception is an unreadable
@@ -95,8 +99,12 @@ pub(crate) async fn get_swift_bucket_metadata(bucket: &str) -> SwiftStorageResul
/// to act on it. /// to act on it.
pub(crate) async fn update_swift_bucket_tagging<F>(bucket: String, rewrite: F) -> SwiftResult<()> pub(crate) async fn update_swift_bucket_tagging<F>(bucket: String, rewrite: F) -> SwiftResult<()>
where where
F: FnOnce(Option<&Tagging>) -> Tagging + Send, F: FnOnce(Option<&Tagging>) -> SwiftResult<Tagging> + Send,
{ {
// Carries a rewrite's refusal back out past the storage error the config
// write has to fail with to abort the transaction.
let mut rejected = None;
let result = update_config_with(&bucket, BUCKET_TAGGING_CONFIG, |bm| { let result = update_config_with(&bucket, BUCKET_TAGGING_CONFIG, |bm| {
// Merging onto an unparseable tag set would silently drop every tag // Merging onto an unparseable tag set would silently drop every tag
// the bucket has — including the container ACL and versioning tags — // the bucket has — including the container ACL and versioning tags —
@@ -106,7 +114,14 @@ where
return Err(SwiftStorageError::other(UNREADABLE_TAGGING_SENTINEL)); return Err(SwiftStorageError::other(UNREADABLE_TAGGING_SENTINEL));
} }
let tagging = rewrite(bm.tagging_config.as_ref()); let tagging = match rewrite(bm.tagging_config.as_ref()) {
Ok(tagging) => tagging,
Err(err) => {
rejected = Some(err);
return Err(SwiftStorageError::other("swift: tagging rewrite rejected the update"));
}
};
if tagging.tag_set.is_empty() { if tagging.tag_set.is_empty() {
Ok(Vec::new()) Ok(Vec::new())
} else { } else {
@@ -118,6 +133,12 @@ where
}) })
.await; .await;
// Nothing was written, but that is the rewrite's own decision about the
// request rather than a storage fault, so it is not logged as one.
if let Some(err) = rejected {
return Err(err);
}
if let Err(err) = result { if let Err(err) = result {
let unreadable = err.to_string().contains(UNREADABLE_TAGGING_SENTINEL); let unreadable = err.to_string().contains(UNREADABLE_TAGGING_SENTINEL);
tracing::error!( tracing::error!(
+4 -4
View File
@@ -222,7 +222,7 @@ where
created: modified, created: modified,
is_dir: false, is_dir: false,
etag: output.e_tag.as_ref().map(etag_to_string), etag: output.e_tag.as_ref().map(etag_to_string),
content_type: output.content_type.map(|c| c.to_string()), content_type: output.content_type,
}) as Box<dyn DavMetaData>) }) as Box<dyn DavMetaData>)
} }
Err(e) => { Err(e) => {
@@ -1185,7 +1185,7 @@ where
created: modified, created: modified,
is_dir: false, is_dir: false,
etag: output.e_tag.as_ref().map(etag_to_string), etag: output.e_tag.as_ref().map(etag_to_string),
content_type: output.content_type.map(|c| c.to_string()), content_type: output.content_type,
}) as Box<dyn DavMetaData>) }) as Box<dyn DavMetaData>)
} }
ResolvedPath::Directory { metadata, .. } => { ResolvedPath::Directory { metadata, .. } => {
@@ -1204,7 +1204,7 @@ where
created: modified, created: modified,
is_dir: true, is_dir: true,
etag: metadata.as_ref().and_then(|output| output.e_tag.as_ref().map(etag_to_string)), etag: metadata.as_ref().and_then(|output| output.e_tag.as_ref().map(etag_to_string)),
content_type: metadata.and_then(|output| output.content_type.map(|c| c.to_string())), content_type: metadata.and_then(|output| output.content_type),
}) as Box<dyn DavMetaData>) }) as Box<dyn DavMetaData>)
} }
}; };
@@ -2035,7 +2035,7 @@ mod tests {
_access_key: &str, _access_key: &str,
_secret_key: &str, _secret_key: &str,
) -> Result<ListObjectsV2Output, Self::Error> { ) -> Result<ListObjectsV2Output, Self::Error> {
let prefix = input.prefix.map(|p| p.to_string()).unwrap_or_default(); let prefix = input.prefix.unwrap_or_default();
let mut keys: Vec<String> = self let mut keys: Vec<String> = self
.state .state
.lock() .lock()
@@ -436,12 +436,10 @@ fn test_symlink_to_nested_object() {
fn test_listing_empty_container() { fn test_listing_empty_container() {
let objects: Vec<&str> = vec![]; let objects: Vec<&str> = vec![];
let filtered: Vec<_> = objects.iter().collect(); assert!(objects.is_empty());
assert_eq!(filtered.len(), 0);
// With prefix // With prefix
let with_prefix: Vec<_> = objects.iter().filter(|o| o.starts_with("prefix/")).collect(); assert!(!objects.iter().any(|o| o.starts_with("prefix/")));
assert_eq!(with_prefix.len(), 0);
} }
/// Test listing lexicographic ordering /// Test listing lexicographic ordering
@@ -16,15 +16,20 @@
//! metadata file, not just the in-memory cache. The metadata has to survive //! metadata file, not just the in-memory cache. The metadata has to survive
//! the disk-truth reloads performed by peer LoadBucketMetadata notifications //! the disk-truth reloads performed by peer LoadBucketMetadata notifications
//! and the periodic refresh loop — and, transitively, a process restart. //! and the periodic refresh loop — and, transitively, a process restart.
//!
//! Durability is what makes the *content* of those writes matter, so this file
//! also covers Swift's additive account/container POST semantics: an item the
//! request does not name keeps its stored value, and removal is explicit. The
//! two are tested together because a reload is the only way to tell a real
//! merge from one that happened to look right in the cache.
#![cfg(feature = "swift")] #![cfg(feature = "swift")]
use std::collections::HashMap; use std::collections::HashMap;
use rustfs_credentials::Credentials; use rustfs_credentials::Credentials;
use rustfs_protocols::swift::SwiftError;
use rustfs_protocols::swift::container::{ContainerMapper, update_container_metadata}; use rustfs_protocols::swift::container::{ContainerMapper, update_container_metadata};
use rustfs_protocols::swift::{account, container}; use rustfs_protocols::swift::{MetadataUpdate, SwiftError, account, container};
use rustfs_test_utils::TestECStoreEnv; use rustfs_test_utils::TestECStoreEnv;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -97,6 +102,8 @@ async fn swift_metadata_writes_are_durable() {
posts_survive_disk_truth_reload(&env).await; posts_survive_disk_truth_reload(&env).await;
tag_writers_preserve_each_others_state(&env).await; tag_writers_preserve_each_others_state(&env).await;
unrelated_account_posts_preserve_the_tempurl_key().await;
acl_only_posts_leave_container_metadata_alone(&env).await;
versioning_writes_reject_missing_containers().await; versioning_writes_reject_missing_containers().await;
} }
@@ -109,11 +116,14 @@ async fn posts_survive_disk_truth_reload(env: &TestECStoreEnv) {
let bucket = ContainerMapper::default().swift_to_s3_bucket(swift_container, project_id); let bucket = ContainerMapper::default().swift_to_s3_bucket(swift_container, project_id);
env.make_bucket(&bucket, false).await; env.make_bucket(&bucket, false).await;
let mut metadata = HashMap::new(); update_container_metadata(
metadata.insert("color".to_string(), "blue".to_string()); &swift_account,
update_container_metadata(&swift_account, swift_container, &credentials, metadata) swift_container,
.await &credentials,
.expect("container metadata POST should succeed"); MetadataUpdate::default().set("color", "blue"),
)
.await
.expect("container metadata POST should succeed");
reload_bucket_metadata_from_disk(&bucket).await; reload_bucket_metadata_from_disk(&bucket).await;
@@ -124,22 +134,47 @@ async fn posts_survive_disk_truth_reload(env: &TestECStoreEnv) {
"container metadata POST must survive a disk-truth metadata reload" "container metadata POST must survive a disk-truth metadata reload"
); );
// A follow-up POST replaces the Swift metadata and that replacement must // A follow-up POST names a different item. Swift POSTs are additive, so
// survive a reload too (the rewrite merges against disk state, so the // the new item lands and the first one stays — and both are still there
// previous value must actually be gone). // after a reload, which is what proves the merge ran against disk state
let mut metadata = HashMap::new(); // rather than looking right in the cache.
metadata.insert("season".to_string(), "summer".to_string()); update_container_metadata(
update_container_metadata(&swift_account, swift_container, &credentials, metadata) &swift_account,
.await swift_container,
.expect("second container metadata POST should succeed"); &credentials,
MetadataUpdate::default().set("season", "summer"),
)
.await
.expect("second container metadata POST should succeed");
reload_bucket_metadata_from_disk(&bucket).await; reload_bucket_metadata_from_disk(&bucket).await;
let container_meta = persisted_container_metadata(&bucket).await; let container_meta = persisted_container_metadata(&bucket).await;
assert_eq!(container_meta.get("season").map(String::as_str), Some("summer")); assert_eq!(container_meta.get("season").map(String::as_str), Some("summer"));
assert_eq!(
container_meta.get("color").map(String::as_str),
Some("blue"),
"a POST that does not name an item must not delete it"
);
// X-Remove-Container-Meta-Color is the deletion path, and it has to be
// durable in the other direction: the removed item must not come back
// from the persisted tags on reload.
update_container_metadata(&swift_account, swift_container, &credentials, MetadataUpdate::default().remove("color"))
.await
.expect("container metadata removal should succeed");
reload_bucket_metadata_from_disk(&bucket).await;
let container_meta = persisted_container_metadata(&bucket).await;
assert!( assert!(
!container_meta.contains_key("color"), !container_meta.contains_key("color"),
"replaced container metadata must not resurrect on reload" "an explicitly removed item must not resurrect on reload"
);
assert_eq!(
container_meta.get("season").map(String::as_str),
Some("summer"),
"removing one item must not disturb the others"
); );
// --- Container versioning POST (X-Versions-Location) --- // --- Container versioning POST (X-Versions-Location) ---
@@ -163,11 +198,13 @@ async fn posts_survive_disk_truth_reload(env: &TestECStoreEnv) {
); );
// --- Account metadata POST (TempURL keys etc.) --- // --- Account metadata POST (TempURL keys etc.) ---
let mut account_meta = HashMap::new(); account::update_account_metadata(
account_meta.insert("temp-url-key".to_string(), "s3cr3t".to_string()); &swift_account,
account::update_account_metadata(&swift_account, &account_meta, &Some(credentials.clone())) &MetadataUpdate::default().set("temp-url-key", "s3cr3t"),
.await &Some(credentials.clone()),
.expect("account metadata POST should succeed"); )
.await
.expect("account metadata POST should succeed");
reload_bucket_metadata_from_disk(&account_metadata_bucket_name(&swift_account)).await; reload_bucket_metadata_from_disk(&account_metadata_bucket_name(&swift_account)).await;
@@ -196,9 +233,7 @@ async fn tag_writers_preserve_each_others_state(env: &TestECStoreEnv) {
env.make_bucket(&ContainerMapper::default().swift_to_s3_bucket(archive, project_id), false) env.make_bucket(&ContainerMapper::default().swift_to_s3_bucket(archive, project_id), false)
.await; .await;
let mut metadata = HashMap::new(); update_container_metadata(&swift_account, container, &credentials, MetadataUpdate::default().set("color", "blue"))
metadata.insert("color".to_string(), "blue".to_string());
update_container_metadata(&swift_account, container, &credentials, metadata)
.await .await
.expect("container metadata POST should succeed"); .expect("container metadata POST should succeed");
container::enable_versioning(&swift_account, container, archive, &credentials) container::enable_versioning(&swift_account, container, archive, &credentials)
@@ -252,6 +287,111 @@ async fn tag_writers_preserve_each_others_state(env: &TestECStoreEnv) {
assert!(!acl.read.is_empty(), "disable_versioning must not disturb the ACL"); assert!(!acl.read.is_empty(), "disable_versioning must not disturb the ACL");
} }
/// The failure additive POSTs exist to prevent. Account metadata holds the
/// TempURL signing key, so a POST that replaced the whole set — setting a
/// quota, say — would delete that key and permanently invalidate every
/// outstanding TempURL and FormPost signature for the account. Durable
/// storage made that loss permanent instead of a cache blip, so the check
/// runs against reloaded disk state.
///
/// Relies on the ambient store the caller's `TestECStoreEnv` published; the
/// account metadata bucket is created by the write path itself.
async fn unrelated_account_posts_preserve_the_tempurl_key() {
let project_id = "swiftmergeproj";
let swift_account = format!("AUTH_{project_id}");
let credentials = Some(keystone_credentials(project_id));
let account_bucket = account_metadata_bucket_name(&swift_account);
account::update_account_metadata(&swift_account, &MetadataUpdate::default().set("temp-url-key", "s3cr3t"), &credentials)
.await
.expect("TempURL key POST should succeed");
// A later POST that has nothing to do with the TempURL key.
account::update_account_metadata(&swift_account, &MetadataUpdate::default().set("quota-bytes", "100"), &credentials)
.await
.expect("quota POST should succeed");
reload_bucket_metadata_from_disk(&account_bucket).await;
let loaded = account::get_account_metadata(&swift_account, &None)
.await
.expect("account metadata should load");
assert_eq!(
loaded.get("temp-url-key").map(String::as_str),
Some("s3cr3t"),
"an unrelated account POST must not delete the TempURL signing key"
);
assert_eq!(loaded.get("quota-bytes").map(String::as_str), Some("100"));
// And the lookup that actually breaks when the key is lost still resolves.
assert_eq!(
account::get_tempurl_key(&swift_account, &None)
.await
.expect("TempURL key should load")
.as_deref(),
Some("s3cr3t"),
"TempURL signature validation must still find the key"
);
// X-Remove-Account-Meta-Quota-Bytes drops that item and nothing else.
account::update_account_metadata(&swift_account, &MetadataUpdate::default().remove("quota-bytes"), &credentials)
.await
.expect("account metadata removal should succeed");
reload_bucket_metadata_from_disk(&account_bucket).await;
let loaded = account::get_account_metadata(&swift_account, &None)
.await
.expect("account metadata should load");
assert!(
!loaded.contains_key("quota-bytes"),
"an explicitly removed item must not resurrect on reload"
);
assert_eq!(
loaded.get("temp-url-key").map(String::as_str),
Some("s3cr3t"),
"removing one item must not disturb the TempURL key"
);
}
/// An ACL-only or versioning-only container POST carries no `X-Container-Meta-*`
/// header, but the handler still runs the metadata step on every POST. That
/// step must leave stored metadata alone rather than treating "named nothing"
/// as "wants everything gone".
async fn acl_only_posts_leave_container_metadata_alone(env: &TestECStoreEnv) {
let project_id = "swiftaclonlyproj";
let swift_account = format!("AUTH_{project_id}");
let credentials = keystone_credentials(project_id);
let container = "gallery";
let bucket = ContainerMapper::default().swift_to_s3_bucket(container, project_id);
env.make_bucket(&bucket, false).await;
update_container_metadata(&swift_account, container, &credentials, MetadataUpdate::default().set("color", "blue"))
.await
.expect("container metadata POST should succeed");
// What the handler does for `POST … -H 'X-Container-Read: .r:*'`: set the
// ACL, then run the metadata step with an update that names no item.
container::set_container_acl(&swift_account, container, Some(".r:*"), None, &credentials)
.await
.expect("ACL-only POST should succeed");
update_container_metadata(&swift_account, container, &credentials, MetadataUpdate::default())
.await
.expect("the metadata step of an ACL-only POST should succeed");
reload_bucket_metadata_from_disk(&bucket).await;
assert_eq!(
persisted_container_metadata(&bucket).await.get("color").map(String::as_str),
Some("blue"),
"an ACL-only POST must not wipe X-Container-Meta-*"
);
let acl = container::get_container_acl(&swift_account, container, &credentials)
.await
.expect("container ACL should load");
assert!(!acl.read.is_empty(), "the ACL that POST set must be stored");
}
/// A container that does not exist must not get metadata persisted for it: /// A container that does not exist must not get metadata persisted for it:
/// the metadata loader turns "nothing on disk" into a fresh default, so an /// the metadata loader turns "nothing on disk" into a fresh default, so an
/// unguarded rewrite would create an orphan metadata file and cache a /// unguarded rewrite would create an orphan metadata file and cache a
@@ -270,6 +410,16 @@ async fn versioning_writes_reject_missing_containers() {
"expected NotFound for a missing container, got {err:?}" "expected NotFound for a missing container, got {err:?}"
); );
// Naming no item skips the persisted write, but must not skip the
// existence check that turns a POST to a missing container into a 404.
let err = update_container_metadata(&swift_account, missing, &credentials, MetadataUpdate::default())
.await
.expect_err("a metadata POST to a missing container must fail");
assert!(
matches!(err, SwiftError::NotFound(_)),
"expected NotFound for a missing container, got {err:?}"
);
let bucket = ContainerMapper::default().swift_to_s3_bucket(missing, project_id); let bucket = ContainerMapper::default().swift_to_s3_bucket(missing, project_id);
assert!( assert!(
get_bucket_metadata(&bucket).await.is_err(), get_bucket_metadata(&bucket).await.is_err(),
@@ -291,8 +441,7 @@ async fn account_metadata_write_rejects_foreign_and_anonymous_callers() {
let victim_account = "AUTH_victimproject"; let victim_account = "AUTH_victimproject";
let attacker_credentials = keystone_credentials("attackerproject"); let attacker_credentials = keystone_credentials("attackerproject");
let mut poisoned = HashMap::new(); let poisoned = MetadataUpdate::default().set("temp-url-key", "attacker-key");
poisoned.insert("temp-url-key".to_string(), "attacker-key".to_string());
let err = account::update_account_metadata(victim_account, &poisoned, &Some(attacker_credentials)) let err = account::update_account_metadata(victim_account, &poisoned, &Some(attacker_credentials))
.await .await
@@ -484,6 +484,59 @@ pub struct DeletePathsResponse {
pub error: ::core::option::Option<Error>, pub error: ::core::option::Option<Error>,
} }
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SnapshotLeaseRequest {
#[prost(string, tag = "1")]
pub disk: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub volume: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub path: ::prost::alloc::string::String,
#[prost(uint64, tag = "4")]
pub ttl_ms: u64,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SnapshotLeaseRenewRequest {
#[prost(string, tag = "1")]
pub disk: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub volume: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub path: ::prost::alloc::string::String,
#[prost(bytes = "bytes", tag = "4")]
pub token: ::prost::bytes::Bytes,
#[prost(uint64, tag = "5")]
pub ttl_ms: u64,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SnapshotLeaseReleaseRequest {
#[prost(string, tag = "1")]
pub disk: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub volume: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub path: ::prost::alloc::string::String,
#[prost(bytes = "bytes", tag = "4")]
pub token: ::prost::bytes::Bytes,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SnapshotLeaseResponse {
#[prost(bool, tag = "1")]
pub success: bool,
#[prost(bytes = "bytes", tag = "2")]
pub token: ::prost::bytes::Bytes,
#[prost(uint32, tag = "3")]
pub protocol_version: u32,
#[prost(message, optional, tag = "4")]
pub error: ::core::option::Option<Error>,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SnapshotLeaseMutationResponse {
#[prost(bool, tag = "1")]
pub success: bool,
#[prost(message, optional, tag = "2")]
pub error: ::core::option::Option<Error>,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct ReadMetadataRequest { pub struct ReadMetadataRequest {
#[prost(string, tag = "1")] #[prost(string, tag = "1")]
pub disk: ::prost::alloc::string::String, pub disk: ::prost::alloc::string::String,
@@ -1595,6 +1648,51 @@ pub mod node_service_client {
.insert(GrpcMethod::new("node_service.NodeService", "Delete")); .insert(GrpcMethod::new("node_service.NodeService", "Delete"));
self.inner.unary(req, path, codec).await self.inner.unary(req, path, codec).await
} }
pub async fn acquire_snapshot_lease(
&mut self,
request: impl tonic::IntoRequest<super::SnapshotLeaseRequest>,
) -> std::result::Result<tonic::Response<super::SnapshotLeaseResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/node_service.NodeService/AcquireSnapshotLease");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("node_service.NodeService", "AcquireSnapshotLease"));
self.inner.unary(req, path, codec).await
}
pub async fn renew_snapshot_lease(
&mut self,
request: impl tonic::IntoRequest<super::SnapshotLeaseRenewRequest>,
) -> std::result::Result<tonic::Response<super::SnapshotLeaseResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/node_service.NodeService/RenewSnapshotLease");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("node_service.NodeService", "RenewSnapshotLease"));
self.inner.unary(req, path, codec).await
}
pub async fn release_snapshot_lease(
&mut self,
request: impl tonic::IntoRequest<super::SnapshotLeaseReleaseRequest>,
) -> std::result::Result<tonic::Response<super::SnapshotLeaseMutationResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/node_service.NodeService/ReleaseSnapshotLease");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("node_service.NodeService", "ReleaseSnapshotLease"));
self.inner.unary(req, path, codec).await
}
pub async fn verify_file( pub async fn verify_file(
&mut self, &mut self,
request: impl tonic::IntoRequest<super::VerifyFileRequest>, request: impl tonic::IntoRequest<super::VerifyFileRequest>,
@@ -2784,6 +2882,18 @@ pub mod node_service_server {
&self, &self,
request: tonic::Request<super::DeleteRequest>, request: tonic::Request<super::DeleteRequest>,
) -> std::result::Result<tonic::Response<super::DeleteResponse>, tonic::Status>; ) -> std::result::Result<tonic::Response<super::DeleteResponse>, tonic::Status>;
async fn acquire_snapshot_lease(
&self,
request: tonic::Request<super::SnapshotLeaseRequest>,
) -> std::result::Result<tonic::Response<super::SnapshotLeaseResponse>, tonic::Status>;
async fn renew_snapshot_lease(
&self,
request: tonic::Request<super::SnapshotLeaseRenewRequest>,
) -> std::result::Result<tonic::Response<super::SnapshotLeaseResponse>, tonic::Status>;
async fn release_snapshot_lease(
&self,
request: tonic::Request<super::SnapshotLeaseReleaseRequest>,
) -> std::result::Result<tonic::Response<super::SnapshotLeaseMutationResponse>, tonic::Status>;
async fn verify_file( async fn verify_file(
&self, &self,
request: tonic::Request<super::VerifyFileRequest>, request: tonic::Request<super::VerifyFileRequest>,
@@ -3426,6 +3536,90 @@ pub mod node_service_server {
}; };
Box::pin(fut) Box::pin(fut)
} }
"/node_service.NodeService/AcquireSnapshotLease" => {
#[allow(non_camel_case_types)]
struct AcquireSnapshotLeaseSvc<T: NodeService>(pub Arc<T>);
impl<T: NodeService> tonic::server::UnaryService<super::SnapshotLeaseRequest> for AcquireSnapshotLeaseSvc<T> {
type Response = super::SnapshotLeaseResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<super::SnapshotLeaseRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move { <T as NodeService>::acquire_snapshot_lease(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = AcquireSnapshotLeaseSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/node_service.NodeService/RenewSnapshotLease" => {
#[allow(non_camel_case_types)]
struct RenewSnapshotLeaseSvc<T: NodeService>(pub Arc<T>);
impl<T: NodeService> tonic::server::UnaryService<super::SnapshotLeaseRenewRequest> for RenewSnapshotLeaseSvc<T> {
type Response = super::SnapshotLeaseResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<super::SnapshotLeaseRenewRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move { <T as NodeService>::renew_snapshot_lease(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = RenewSnapshotLeaseSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/node_service.NodeService/ReleaseSnapshotLease" => {
#[allow(non_camel_case_types)]
struct ReleaseSnapshotLeaseSvc<T: NodeService>(pub Arc<T>);
impl<T: NodeService> tonic::server::UnaryService<super::SnapshotLeaseReleaseRequest> for ReleaseSnapshotLeaseSvc<T> {
type Response = super::SnapshotLeaseMutationResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<super::SnapshotLeaseReleaseRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move { <T as NodeService>::release_snapshot_lease(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = ReleaseSnapshotLeaseSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/node_service.NodeService/VerifyFile" => { "/node_service.NodeService/VerifyFile" => {
#[allow(non_camel_case_types)] #[allow(non_camel_case_types)]
struct VerifyFileSvc<T: NodeService>(pub Arc<T>); struct VerifyFileSvc<T: NodeService>(pub Arc<T>);
+454 -1
View File
@@ -447,10 +447,23 @@ impl CanonicalBodyBuilder {
self.push_bytes(field.as_bytes()) self.push_bytes(field.as_bytes())
} }
fn push_optional_str(&mut self, field: Option<&str>) -> Result<(), std::num::TryFromIntError> {
self.body.push(u8::from(field.is_some()));
self.push_str(field.unwrap_or_default())
}
fn push_bool(&mut self, field: bool) { fn push_bool(&mut self, field: bool) {
self.body.push(u8::from(field)); self.body.push(u8::from(field));
} }
fn push_u32(&mut self, field: u32) {
self.body.extend_from_slice(&field.to_be_bytes());
}
fn push_u64(&mut self, field: u64) {
self.body.extend_from_slice(&field.to_be_bytes());
}
fn push_count(&mut self, count: usize) -> Result<(), std::num::TryFromIntError> { fn push_count(&mut self, count: usize) -> Result<(), std::num::TryFromIntError> {
self.body.extend_from_slice(&u64::try_from(count)?.to_be_bytes()); self.body.extend_from_slice(&u64::try_from(count)?.to_be_bytes());
Ok(()) Ok(())
@@ -461,6 +474,211 @@ impl CanonicalBodyBuilder {
} }
} }
pub const PEER_RESTSIGNAL: &str = "signal";
pub const PEER_RESTSUB_SYS: &str = "sub-sys";
pub const PEER_RESTDRY_RUN: &str = "dry-run";
/// A stable semantic body for a side-effecting unary RPC.
///
/// Implementations deliberately enumerate handler-consumed fields instead of re-encoding the
/// protobuf message, whose unknown fields and map order are not a mixed-version contract.
pub trait CanonicalMutationBody {
fn canonical_body(&self) -> Result<Vec<u8>, std::num::TryFromIntError>;
}
macro_rules! impl_canonical_mutation_body {
($request:ty, $domain:expr, |$value:ident, $body:ident| $fields:block) => {
impl CanonicalMutationBody for $request {
fn canonical_body(&self) -> Result<Vec<u8>, std::num::TryFromIntError> {
let $value = self;
let mut $body = CanonicalBodyBuilder::new($domain);
$fields
Ok($body.finish())
}
}
};
($request:ty, $domain:expr) => {
impl CanonicalMutationBody for $request {
fn canonical_body(&self) -> Result<Vec<u8>, std::num::TryFromIntError> {
Ok(CanonicalBodyBuilder::new($domain).finish())
}
}
};
}
impl_canonical_mutation_body!(
proto_gen::node_service::SignalServiceRequest,
b"rustfs-signal-service-request-v1\0",
|request, body| {
let vars = request.vars.as_ref().map(|vars| &vars.value);
body.push_optional_str(vars.and_then(|vars| vars.get(PEER_RESTSIGNAL).map(String::as_str)))?;
body.push_optional_str(vars.and_then(|vars| vars.get(PEER_RESTSUB_SYS).map(String::as_str)))?;
body.push_optional_str(vars.and_then(|vars| vars.get(PEER_RESTDRY_RUN).map(String::as_str)))?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::HealBucketRequest,
b"rustfs-heal-bucket-request-v1\0",
|request, body| {
body.push_str(&request.bucket)?;
body.push_str(&request.options)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::MakeBucketRequest,
b"rustfs-make-bucket-request-v1\0",
|request, body| {
body.push_str(&request.name)?;
body.push_str(&request.options)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::DeleteBucketRequest,
b"rustfs-delete-bucket-request-v1\0",
|request, body| {
body.push_str(&request.bucket)?;
body.push_str(&request.options)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::GenerallyLockRequest,
b"rustfs-lock-request-v1\0",
|request, body| {
body.push_str(&request.args)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::BatchGenerallyLockRequest,
b"rustfs-lock-batch-request-v1\0",
|request, body| {
body.push_count(request.args.len())?;
for arg in &request.args {
body.push_str(arg)?;
}
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::LoadBucketMetadataRequest,
b"rustfs-load-bucket-metadata-request-v1\0",
|request, body| {
body.push_str(&request.bucket)?;
body.push_bool(request.scanner_maintenance_change);
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::DeleteBucketMetadataRequest,
b"rustfs-delete-bucket-metadata-request-v1\0",
|request, body| {
body.push_str(&request.bucket)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::DeletePolicyRequest,
b"rustfs-delete-policy-request-v1\0",
|request, body| {
body.push_str(&request.policy_name)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::LoadPolicyRequest,
b"rustfs-load-policy-request-v1\0",
|request, body| {
body.push_str(&request.policy_name)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::LoadPolicyMappingRequest,
b"rustfs-load-policy-mapping-request-v1\0",
|request, body| {
body.push_str(&request.user_or_group)?;
body.push_u64(request.user_type);
body.push_bool(request.is_group);
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::DeleteUserRequest,
b"rustfs-delete-user-request-v1\0",
|request, body| {
body.push_str(&request.access_key)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::DeleteServiceAccountRequest,
b"rustfs-delete-service-account-request-v1\0",
|request, body| {
body.push_str(&request.access_key)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::LoadUserRequest,
b"rustfs-load-user-request-v1\0",
|request, body| {
body.push_str(&request.access_key)?;
body.push_bool(request.temp);
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::LoadServiceAccountRequest,
b"rustfs-load-service-account-request-v1\0",
|request, body| {
body.push_str(&request.access_key)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::LoadGroupRequest,
b"rustfs-load-group-request-v1\0",
|request, body| {
body.push_str(&request.group)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::ReloadSiteReplicationConfigRequest,
b"rustfs-reload-site-replication-config-request-v1\0"
);
impl_canonical_mutation_body!(proto_gen::node_service::ReloadPoolMetaRequest, b"rustfs-reload-pool-meta-request-v1\0");
impl_canonical_mutation_body!(
proto_gen::node_service::StopRebalanceRequest,
b"rustfs-stop-rebalance-request-v1\0",
|request, body| {
body.push_str(&request.expected_rebalance_id)?;
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::LoadRebalanceMetaRequest,
b"rustfs-load-rebalance-meta-request-v1\0",
|request, body| {
body.push_bool(request.start_rebalance);
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::StartDecommissionRequest,
b"rustfs-start-decommission-request-v1\0",
|request, body| {
body.push_count(request.pool_indices.len())?;
for pool_index in &request.pool_indices {
body.push_u32(*pool_index);
}
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::CancelDecommissionRequest,
b"rustfs-cancel-decommission-request-v1\0",
|request, body| {
body.push_u32(request.pool_index);
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::ClearDecommissionRequest,
b"rustfs-clear-decommission-request-v1\0",
|request, body| {
body.push_u32(request.pool_index);
}
);
impl_canonical_mutation_body!(
proto_gen::node_service::LoadTransitionTierConfigRequest,
b"rustfs-load-transition-tier-config-request-v1\0"
);
// Canonical request bodies for the mutating NodeService disk RPCs (backlog#1327 body-digest // Canonical request bodies for the mutating NodeService disk RPCs (backlog#1327 body-digest
// binding). Each covers every semantic wire field — including both the msgpack `_bin` payload and // binding). Each covers every semantic wire field — including both the msgpack `_bin` payload and
// its JSON compatibility copy — so tampering with either encoding, or stripping `_bin` to force // its JSON compatibility copy — so tampering with either encoding, or stripping `_bin` to force
@@ -575,6 +793,40 @@ pub fn canonical_delete_paths_request_body(
Ok(body.finish()) Ok(body.finish())
} }
pub fn canonical_snapshot_lease_request_body(
request: &proto_gen::node_service::SnapshotLeaseRequest,
) -> Result<Vec<u8>, std::num::TryFromIntError> {
let mut body = CanonicalBodyBuilder::new(b"rustfs-snapshot-lease-request-v1\0");
body.push_str(&request.disk)?;
body.push_str(&request.volume)?;
body.push_str(&request.path)?;
body.push_u64(request.ttl_ms);
Ok(body.finish())
}
pub fn canonical_snapshot_lease_renew_request_body(
request: &proto_gen::node_service::SnapshotLeaseRenewRequest,
) -> Result<Vec<u8>, std::num::TryFromIntError> {
let mut body = CanonicalBodyBuilder::new(b"rustfs-snapshot-lease-renew-request-v1\0");
body.push_str(&request.disk)?;
body.push_str(&request.volume)?;
body.push_str(&request.path)?;
body.push_bytes(&request.token)?;
body.push_u64(request.ttl_ms);
Ok(body.finish())
}
pub fn canonical_snapshot_lease_release_request_body(
request: &proto_gen::node_service::SnapshotLeaseReleaseRequest,
) -> Result<Vec<u8>, std::num::TryFromIntError> {
let mut body = CanonicalBodyBuilder::new(b"rustfs-snapshot-lease-release-request-v1\0");
body.push_str(&request.disk)?;
body.push_str(&request.volume)?;
body.push_str(&request.path)?;
body.push_bytes(&request.token)?;
Ok(body.finish())
}
pub fn canonical_rename_file_request_body( pub fn canonical_rename_file_request_body(
request: &proto_gen::node_service::RenameFileRequest, request: &proto_gen::node_service::RenameFileRequest,
) -> Result<Vec<u8>, std::num::TryFromIntError> { ) -> Result<Vec<u8>, std::num::TryFromIntError> {
@@ -662,7 +914,8 @@ mod disk_mutation_canonical_tests {
use super::proto_gen::node_service::{ use super::proto_gen::node_service::{
DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest, DeleteVolumeRequest, MakeVolumeRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest, DeleteVolumeRequest, MakeVolumeRequest,
MakeVolumesRequest, PreparePartTransactionRequest, RenameDataRequest, RenameFileRequest, RenamePartRequest, MakeVolumesRequest, PreparePartTransactionRequest, RenameDataRequest, RenameFileRequest, RenamePartRequest,
SettlePartTransactionRequest, UpdateMetadataRequest, WriteAllRequest, WriteMetadataRequest, SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
UpdateMetadataRequest, WriteAllRequest, WriteMetadataRequest,
}; };
use super::*; use super::*;
@@ -1020,6 +1273,58 @@ mod disk_mutation_canonical_tests {
); );
} }
#[test]
fn snapshot_lease_canonical_bodies_bind_every_field() {
let acquire = SnapshotLeaseRequest {
disk: "d".into(),
volume: "v".into(),
path: "p".into(),
ttl_ms: 60_000,
};
let mut acquire_bodies = vec![canonical_snapshot_lease_request_body(&acquire).unwrap()];
for mutate in [
|r: &mut SnapshotLeaseRequest| r.disk = "d2".into(),
|r: &mut SnapshotLeaseRequest| r.volume = "v2".into(),
|r: &mut SnapshotLeaseRequest| r.path = "p2".into(),
|r: &mut SnapshotLeaseRequest| r.ttl_ms = 60_001,
] {
let mut request = acquire.clone();
mutate(&mut request);
acquire_bodies.push(canonical_snapshot_lease_request_body(&request).unwrap());
}
assert_all_distinct(&acquire_bodies);
let renew = SnapshotLeaseRenewRequest {
disk: "d".into(),
volume: "v".into(),
path: "p".into(),
token: vec![1; 16].into(),
ttl_ms: 60_000,
};
let mut changed_token = renew.clone();
changed_token.token = vec![2; 16].into();
let mut changed_ttl = renew.clone();
changed_ttl.ttl_ms += 1;
assert_all_distinct(&[
canonical_snapshot_lease_renew_request_body(&renew).unwrap(),
canonical_snapshot_lease_renew_request_body(&changed_token).unwrap(),
canonical_snapshot_lease_renew_request_body(&changed_ttl).unwrap(),
]);
let release = SnapshotLeaseReleaseRequest {
disk: "d".into(),
volume: "v".into(),
path: "p".into(),
token: vec![1; 16].into(),
};
let mut changed_release = release.clone();
changed_release.token = vec![2; 16].into();
assert_ne!(
canonical_snapshot_lease_release_request_body(&release).unwrap(),
canonical_snapshot_lease_release_request_body(&changed_release).unwrap()
);
}
#[test] #[test]
fn disk_mutation_canonical_domains_are_distinct_per_message() { fn disk_mutation_canonical_domains_are_distinct_per_message() {
// The same field values must never authenticate one RPC's request as another's. // The same field values must never authenticate one RPC's request as another's.
@@ -1045,6 +1350,154 @@ mod disk_mutation_canonical_tests {
} }
} }
#[cfg(test)]
mod non_disk_mutation_canonical_tests {
use super::*;
use proto_gen::node_service::*;
use std::collections::HashMap;
macro_rules! assert_fields_bound {
($request:ty, {$($field:ident: $value:expr),+ $(,)?}) => {{
let baseline = <$request>::default();
let expected = baseline.canonical_body().expect("baseline canonical body should encode");
$(
let mut variant = baseline.clone();
variant.$field = $value;
assert_ne!(
expected,
variant.canonical_body().expect("variant canonical body should encode"),
concat!(stringify!($request), " omitted field ", stringify!($field)),
);
)+
}};
}
fn signal_request(signal: Option<&str>, sub_system: Option<&str>, dry_run: Option<&str>) -> SignalServiceRequest {
let mut value = HashMap::new();
if let Some(signal) = signal {
value.insert(PEER_RESTSIGNAL.to_string(), signal.to_string());
}
if let Some(sub_system) = sub_system {
value.insert(PEER_RESTSUB_SYS.to_string(), sub_system.to_string());
}
if let Some(dry_run) = dry_run {
value.insert(PEER_RESTDRY_RUN.to_string(), dry_run.to_string());
}
SignalServiceRequest {
vars: Some(Mss { value }),
}
}
#[test]
fn signal_service_canonical_body_is_versioned_and_binds_every_semantic_field() {
let request = signal_request(Some("2"), Some("scanner"), Some("false"));
let baseline = request.canonical_body().expect("small signal request should encode");
assert!(baseline.starts_with(b"rustfs-signal-service-request-v1\0"));
for variant in [
signal_request(Some("1"), Some("scanner"), Some("false")),
signal_request(Some("2"), Some("heal"), Some("false")),
signal_request(Some("2"), Some("scanner"), Some("true")),
signal_request(None, Some("scanner"), Some("false")),
signal_request(Some("2"), None, Some("false")),
signal_request(Some("2"), Some("scanner"), None),
] {
assert_ne!(baseline, variant.canonical_body().expect("small signal request should encode"));
}
let mut ignored_field = request;
ignored_field
.vars
.as_mut()
.expect("signal vars should exist")
.value
.insert("future-field".to_string(), "ignored".to_string());
assert_eq!(
baseline,
ignored_field
.canonical_body()
.expect("unknown signal field should not affect the semantic body"),
);
}
#[test]
fn signal_service_canonical_body_distinguishes_missing_and_empty_fields() {
assert_ne!(
signal_request(None, Some("scanner"), Some("false"))
.canonical_body()
.expect("missing signal should encode"),
signal_request(Some(""), Some("scanner"), Some("false"))
.canonical_body()
.expect("empty signal should encode"),
);
}
#[test]
fn bucket_and_lock_canonical_bodies_bind_every_semantic_field() {
assert_fields_bound!(HealBucketRequest, { bucket: "bucket".into(), options: "opts".into() });
assert_fields_bound!(MakeBucketRequest, { name: "bucket".into(), options: "opts".into() });
assert_fields_bound!(DeleteBucketRequest, { bucket: "bucket".into(), options: "opts".into() });
assert_fields_bound!(GenerallyLockRequest, { args: "lock".into() });
assert_fields_bound!(BatchGenerallyLockRequest, { args: vec!["first".into(), "second".into()] });
let first = BatchGenerallyLockRequest {
args: vec!["first".into(), "second".into()],
};
let reversed = BatchGenerallyLockRequest {
args: vec!["second".into(), "first".into()],
};
assert_ne!(first.canonical_body().unwrap(), reversed.canonical_body().unwrap());
}
#[test]
fn metadata_and_iam_canonical_bodies_bind_every_semantic_field() {
assert_fields_bound!(LoadBucketMetadataRequest, {
bucket: "bucket".into(),
scanner_maintenance_change: true,
});
assert_fields_bound!(DeleteBucketMetadataRequest, { bucket: "bucket".into() });
assert_fields_bound!(DeletePolicyRequest, { policy_name: "policy".into() });
assert_fields_bound!(LoadPolicyRequest, { policy_name: "policy".into() });
assert_fields_bound!(LoadPolicyMappingRequest, {
user_or_group: "user".into(),
user_type: 1,
is_group: true,
});
assert_fields_bound!(DeleteUserRequest, { access_key: "user".into() });
assert_fields_bound!(DeleteServiceAccountRequest, { access_key: "service".into() });
assert_fields_bound!(LoadUserRequest, { access_key: "user".into(), temp: true });
assert_fields_bound!(LoadServiceAccountRequest, { access_key: "service".into() });
assert_fields_bound!(LoadGroupRequest, { group: "group".into() });
}
#[test]
fn control_plane_canonical_bodies_bind_every_semantic_field() {
assert_fields_bound!(StopRebalanceRequest, { expected_rebalance_id: "rebalance".into() });
assert_fields_bound!(LoadRebalanceMetaRequest, { start_rebalance: true });
assert_fields_bound!(StartDecommissionRequest, { pool_indices: vec![1, 2] });
assert_fields_bound!(CancelDecommissionRequest, { pool_index: 1 });
assert_fields_bound!(ClearDecommissionRequest, { pool_index: 1 });
let first = StartDecommissionRequest {
pool_indices: vec![1, 2],
};
let reversed = StartDecommissionRequest {
pool_indices: vec![2, 1],
};
assert_ne!(first.canonical_body().unwrap(), reversed.canonical_body().unwrap());
let empty_domains = [
ReloadSiteReplicationConfigRequest::default().canonical_body().unwrap(),
ReloadPoolMetaRequest::default().canonical_body().unwrap(),
LoadTransitionTierConfigRequest::default().canonical_body().unwrap(),
];
assert!(empty_domains.iter().all(|body| !body.is_empty()));
assert_ne!(empty_domains[0], empty_domains[1]);
assert_ne!(empty_domains[0], empty_domains[2]);
assert_ne!(empty_domains[1], empty_domains[2]);
}
}
#[cfg(test)] #[cfg(test)]
mod scanner_activity_tests { mod scanner_activity_tests {
use super::{ use super::{
+122 -85
View File
@@ -342,6 +342,40 @@ message DeletePathsResponse {
optional Error error = 2; optional Error error = 2;
} }
message SnapshotLeaseRequest {
string disk = 1;
string volume = 2;
string path = 3;
uint64 ttl_ms = 4;
}
message SnapshotLeaseRenewRequest {
string disk = 1;
string volume = 2;
string path = 3;
bytes token = 4;
uint64 ttl_ms = 5;
}
message SnapshotLeaseReleaseRequest {
string disk = 1;
string volume = 2;
string path = 3;
bytes token = 4;
}
message SnapshotLeaseResponse {
bool success = 1;
bytes token = 2;
uint32 protocol_version = 3;
optional Error error = 4;
}
message SnapshotLeaseMutationResponse {
bool success = 1;
optional Error error = 2;
}
message ReadMetadataRequest { message ReadMetadataRequest {
string disk = 1; string disk = 1;
string volume = 2; string volume = 2;
@@ -954,104 +988,107 @@ message GetLiveEventsResponse {
service NodeService { service NodeService {
/* -------------------------------meta service-------------------------- */ /* -------------------------------meta service-------------------------- */
rpc Ping(PingRequest) returns (PingResponse) {}; rpc Ping(PingRequest) returns (PingResponse) {}; // auth-policy: read-only
rpc HealBucket(HealBucketRequest) returns (HealBucketResponse) {}; rpc HealBucket(HealBucketRequest) returns (HealBucketResponse) {}; // auth-policy: body-bound
rpc ListBucket(ListBucketRequest) returns (ListBucketResponse) {}; rpc ListBucket(ListBucketRequest) returns (ListBucketResponse) {}; // auth-policy: read-only
rpc MakeBucket(MakeBucketRequest) returns (MakeBucketResponse) {}; rpc MakeBucket(MakeBucketRequest) returns (MakeBucketResponse) {}; // auth-policy: body-bound
rpc GetBucketInfo(GetBucketInfoRequest) returns (GetBucketInfoResponse) {}; rpc GetBucketInfo(GetBucketInfoRequest) returns (GetBucketInfoResponse) {}; // auth-policy: read-only
rpc DeleteBucket(DeleteBucketRequest) returns (DeleteBucketResponse) {}; rpc DeleteBucket(DeleteBucketRequest) returns (DeleteBucketResponse) {}; // auth-policy: body-bound
/* -------------------------------disk service-------------------------- */ /* -------------------------------disk service-------------------------- */
rpc ReadAll(ReadAllRequest) returns (ReadAllResponse) {}; rpc ReadAll(ReadAllRequest) returns (ReadAllResponse) {}; // auth-policy: read-only
rpc WriteAll(WriteAllRequest) returns (WriteAllResponse) {}; rpc WriteAll(WriteAllRequest) returns (WriteAllResponse) {}; // auth-policy: body-bound
rpc Delete(DeleteRequest) returns (DeleteResponse) {}; rpc Delete(DeleteRequest) returns (DeleteResponse) {}; // auth-policy: body-bound
rpc VerifyFile(VerifyFileRequest) returns (VerifyFileResponse) {}; rpc AcquireSnapshotLease(SnapshotLeaseRequest) returns (SnapshotLeaseResponse) {}; // auth-policy: body-bound
rpc ReadParts(ReadPartsRequest) returns (ReadPartsResponse) {}; rpc RenewSnapshotLease(SnapshotLeaseRenewRequest) returns (SnapshotLeaseResponse) {}; // auth-policy: body-bound
rpc CheckParts(CheckPartsRequest) returns (CheckPartsResponse) {}; rpc ReleaseSnapshotLease(SnapshotLeaseReleaseRequest) returns (SnapshotLeaseMutationResponse) {}; // auth-policy: body-bound
rpc PreparePartTransaction(PreparePartTransactionRequest) returns (PreparePartTransactionResponse) {}; rpc VerifyFile(VerifyFileRequest) returns (VerifyFileResponse) {}; // auth-policy: read-only
rpc RenamePart(RenamePartRequest) returns (RenamePartResponse) {}; rpc ReadParts(ReadPartsRequest) returns (ReadPartsResponse) {}; // auth-policy: read-only
rpc SettlePartTransaction(SettlePartTransactionRequest) returns (SettlePartTransactionResponse) {}; rpc CheckParts(CheckPartsRequest) returns (CheckPartsResponse) {}; // auth-policy: read-only
rpc RenameFile(RenameFileRequest) returns (RenameFileResponse) {}; rpc PreparePartTransaction(PreparePartTransactionRequest) returns (PreparePartTransactionResponse) {}; // auth-policy: body-bound
rpc Write(WriteRequest) returns (WriteResponse) {}; rpc RenamePart(RenamePartRequest) returns (RenamePartResponse) {}; // auth-policy: body-bound
rpc WriteStream(stream WriteRequest) returns (stream WriteResponse) {}; rpc SettlePartTransaction(SettlePartTransactionRequest) returns (SettlePartTransactionResponse) {}; // auth-policy: body-bound
rpc RenameFile(RenameFileRequest) returns (RenameFileResponse) {}; // auth-policy: body-bound
rpc Write(WriteRequest) returns (WriteResponse) {}; // auth-policy: unimplemented
rpc WriteStream(stream WriteRequest) returns (stream WriteResponse) {}; // auth-policy: streaming
// rpc Append(AppendRequest) returns (AppendResponse) {}; // rpc Append(AppendRequest) returns (AppendResponse) {};
rpc ReadAt(stream ReadAtRequest) returns (stream ReadAtResponse) {}; rpc ReadAt(stream ReadAtRequest) returns (stream ReadAtResponse) {}; // auth-policy: streaming
rpc ListDir(ListDirRequest) returns (ListDirResponse) {}; rpc ListDir(ListDirRequest) returns (ListDirResponse) {}; // auth-policy: read-only
rpc WalkDir(WalkDirRequest) returns (stream WalkDirResponse) {}; rpc WalkDir(WalkDirRequest) returns (stream WalkDirResponse) {}; // auth-policy: streaming
rpc RenameData(RenameDataRequest) returns (RenameDataResponse) {}; rpc RenameData(RenameDataRequest) returns (RenameDataResponse) {}; // auth-policy: body-bound
rpc MakeVolumes(MakeVolumesRequest) returns (MakeVolumesResponse) {}; rpc MakeVolumes(MakeVolumesRequest) returns (MakeVolumesResponse) {}; // auth-policy: body-bound
rpc MakeVolume(MakeVolumeRequest) returns (MakeVolumeResponse) {}; rpc MakeVolume(MakeVolumeRequest) returns (MakeVolumeResponse) {}; // auth-policy: body-bound
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse) {}; rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse) {}; // auth-policy: read-only
rpc StatVolume(StatVolumeRequest) returns (StatVolumeResponse) {}; rpc StatVolume(StatVolumeRequest) returns (StatVolumeResponse) {}; // auth-policy: read-only
rpc DeletePaths(DeletePathsRequest) returns (DeletePathsResponse) {}; rpc DeletePaths(DeletePathsRequest) returns (DeletePathsResponse) {}; // auth-policy: body-bound
rpc UpdateMetadata(UpdateMetadataRequest) returns (UpdateMetadataResponse) {}; rpc UpdateMetadata(UpdateMetadataRequest) returns (UpdateMetadataResponse) {}; // auth-policy: body-bound
rpc ReadMetadata(ReadMetadataRequest) returns (ReadMetadataResponse) {}; rpc ReadMetadata(ReadMetadataRequest) returns (ReadMetadataResponse) {}; // auth-policy: read-only
rpc WriteMetadata(WriteMetadataRequest) returns (WriteMetadataResponse) {}; rpc WriteMetadata(WriteMetadataRequest) returns (WriteMetadataResponse) {}; // auth-policy: body-bound
rpc ReadVersion(ReadVersionRequest) returns (ReadVersionResponse) {}; rpc ReadVersion(ReadVersionRequest) returns (ReadVersionResponse) {}; // auth-policy: read-only
rpc BatchReadVersion(BatchReadVersionRequest) returns (BatchReadVersionResponse) {}; rpc BatchReadVersion(BatchReadVersionRequest) returns (BatchReadVersionResponse) {}; // auth-policy: read-only
rpc ReadXL(ReadXLRequest) returns (ReadXLResponse) {}; rpc ReadXL(ReadXLRequest) returns (ReadXLResponse) {}; // auth-policy: read-only
rpc DeleteVersion(DeleteVersionRequest) returns (DeleteVersionResponse) {}; rpc DeleteVersion(DeleteVersionRequest) returns (DeleteVersionResponse) {}; // auth-policy: body-bound
rpc DeleteVersions(DeleteVersionsRequest) returns (DeleteVersionsResponse) {}; rpc DeleteVersions(DeleteVersionsRequest) returns (DeleteVersionsResponse) {}; // auth-policy: body-bound
rpc ReadMultiple(ReadMultipleRequest) returns (ReadMultipleResponse) {}; rpc ReadMultiple(ReadMultipleRequest) returns (ReadMultipleResponse) {}; // auth-policy: read-only
rpc DeleteVolume(DeleteVolumeRequest) returns (DeleteVolumeResponse) {}; rpc DeleteVolume(DeleteVolumeRequest) returns (DeleteVolumeResponse) {}; // auth-policy: body-bound
rpc DiskInfo(DiskInfoRequest) returns (DiskInfoResponse) {}; rpc DiskInfo(DiskInfoRequest) returns (DiskInfoResponse) {}; // auth-policy: read-only
/* -------------------------------lock service-------------------------- */ /* -------------------------------lock service-------------------------- */
rpc Lock(GenerallyLockRequest) returns (GenerallyLockResponse) {}; rpc Lock(GenerallyLockRequest) returns (GenerallyLockResponse) {}; // auth-policy: body-bound
rpc UnLock(GenerallyLockRequest) returns (GenerallyLockResponse) {}; rpc UnLock(GenerallyLockRequest) returns (GenerallyLockResponse) {}; // auth-policy: body-bound
rpc ForceUnLock(GenerallyLockRequest) returns (GenerallyLockResponse) {}; rpc ForceUnLock(GenerallyLockRequest) returns (GenerallyLockResponse) {}; // auth-policy: body-bound
rpc Refresh(GenerallyLockRequest) returns (GenerallyLockResponse) {}; rpc Refresh(GenerallyLockRequest) returns (GenerallyLockResponse) {}; // auth-policy: body-bound
rpc LockBatch(BatchGenerallyLockRequest) returns (BatchGenerallyLockResponse) {}; rpc LockBatch(BatchGenerallyLockRequest) returns (BatchGenerallyLockResponse) {}; // auth-policy: body-bound
rpc UnLockBatch(BatchGenerallyLockRequest) returns (BatchGenerallyLockResponse) {}; rpc UnLockBatch(BatchGenerallyLockRequest) returns (BatchGenerallyLockResponse) {}; // auth-policy: body-bound
/* -------------------------------peer rest service-------------------------- */ /* -------------------------------peer rest service-------------------------- */
rpc LocalStorageInfo(LocalStorageInfoRequest) returns (LocalStorageInfoResponse) {}; rpc LocalStorageInfo(LocalStorageInfoRequest) returns (LocalStorageInfoResponse) {}; // auth-policy: read-only
rpc ServerInfo(ServerInfoRequest) returns (ServerInfoResponse) {}; rpc ServerInfo(ServerInfoRequest) returns (ServerInfoResponse) {}; // auth-policy: read-only
rpc GetCpus(GetCpusRequest) returns (GetCpusResponse) {}; rpc GetCpus(GetCpusRequest) returns (GetCpusResponse) {}; // auth-policy: read-only
rpc GetNetInfo(GetNetInfoRequest) returns (GetNetInfoResponse) {}; rpc GetNetInfo(GetNetInfoRequest) returns (GetNetInfoResponse) {}; // auth-policy: read-only
rpc GetPartitions(GetPartitionsRequest) returns (GetPartitionsResponse) {}; rpc GetPartitions(GetPartitionsRequest) returns (GetPartitionsResponse) {}; // auth-policy: read-only
rpc GetOsInfo(GetOsInfoRequest) returns (GetOsInfoResponse) {}; rpc GetOsInfo(GetOsInfoRequest) returns (GetOsInfoResponse) {}; // auth-policy: read-only
rpc GetSELinuxInfo(GetSELinuxInfoRequest) returns (GetSELinuxInfoResponse) {}; rpc GetSELinuxInfo(GetSELinuxInfoRequest) returns (GetSELinuxInfoResponse) {}; // auth-policy: read-only
rpc GetSysConfig(GetSysConfigRequest) returns (GetSysConfigResponse) {}; rpc GetSysConfig(GetSysConfigRequest) returns (GetSysConfigResponse) {}; // auth-policy: read-only
rpc GetSysErrors(GetSysErrorsRequest) returns (GetSysErrorsResponse) {}; rpc GetSysErrors(GetSysErrorsRequest) returns (GetSysErrorsResponse) {}; // auth-policy: read-only
rpc GetMemInfo(GetMemInfoRequest) returns (GetMemInfoResponse) {}; rpc GetMemInfo(GetMemInfoRequest) returns (GetMemInfoResponse) {}; // auth-policy: read-only
rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse) {}; rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse) {}; // auth-policy: read-only
rpc GetProcInfo(GetProcInfoRequest) returns (GetProcInfoResponse) {}; rpc GetProcInfo(GetProcInfoRequest) returns (GetProcInfoResponse) {}; // auth-policy: read-only
rpc StartProfiling(StartProfilingRequest) returns (StartProfilingResponse) {}; rpc StartProfiling(StartProfilingRequest) returns (StartProfilingResponse) {}; // auth-policy: unimplemented
rpc DownloadProfileData(DownloadProfileDataRequest) returns (DownloadProfileDataResponse) {}; rpc DownloadProfileData(DownloadProfileDataRequest) returns (DownloadProfileDataResponse) {}; // auth-policy: unimplemented
rpc GetBucketStats(GetBucketStatsDataRequest) returns (GetBucketStatsDataResponse) {}; rpc GetBucketStats(GetBucketStatsDataRequest) returns (GetBucketStatsDataResponse) {}; // auth-policy: read-only
rpc GetSRMetrics(GetSRMetricsDataRequest) returns (GetSRMetricsDataResponse) {}; rpc GetSRMetrics(GetSRMetricsDataRequest) returns (GetSRMetricsDataResponse) {}; // auth-policy: unimplemented
rpc GetAllBucketStats(GetAllBucketStatsRequest) returns (GetAllBucketStatsResponse) {}; rpc GetAllBucketStats(GetAllBucketStatsRequest) returns (GetAllBucketStatsResponse) {}; // auth-policy: unimplemented
rpc LoadBucketMetadata(LoadBucketMetadataRequest) returns (LoadBucketMetadataResponse) {}; rpc LoadBucketMetadata(LoadBucketMetadataRequest) returns (LoadBucketMetadataResponse) {}; // auth-policy: body-bound
rpc DeleteBucketMetadata(DeleteBucketMetadataRequest) returns (DeleteBucketMetadataResponse) {}; rpc DeleteBucketMetadata(DeleteBucketMetadataRequest) returns (DeleteBucketMetadataResponse) {}; // auth-policy: body-bound
rpc DeletePolicy(DeletePolicyRequest) returns (DeletePolicyResponse) {}; rpc DeletePolicy(DeletePolicyRequest) returns (DeletePolicyResponse) {}; // auth-policy: body-bound
rpc LoadPolicy(LoadPolicyRequest) returns (LoadPolicyResponse) {}; rpc LoadPolicy(LoadPolicyRequest) returns (LoadPolicyResponse) {}; // auth-policy: body-bound
rpc LoadPolicyMapping(LoadPolicyMappingRequest) returns (LoadPolicyMappingResponse) {}; rpc LoadPolicyMapping(LoadPolicyMappingRequest) returns (LoadPolicyMappingResponse) {}; // auth-policy: body-bound
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) {}; rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) {}; // auth-policy: body-bound
rpc DeleteServiceAccount(DeleteServiceAccountRequest) returns (DeleteServiceAccountResponse) {}; rpc DeleteServiceAccount(DeleteServiceAccountRequest) returns (DeleteServiceAccountResponse) {}; // auth-policy: body-bound
rpc LoadUser(LoadUserRequest) returns (LoadUserResponse) {}; rpc LoadUser(LoadUserRequest) returns (LoadUserResponse) {}; // auth-policy: body-bound
rpc LoadServiceAccount(LoadServiceAccountRequest) returns (LoadServiceAccountResponse) {}; rpc LoadServiceAccount(LoadServiceAccountRequest) returns (LoadServiceAccountResponse) {}; // auth-policy: body-bound
rpc LoadGroup(LoadGroupRequest) returns (LoadGroupResponse) {}; rpc LoadGroup(LoadGroupRequest) returns (LoadGroupResponse) {}; // auth-policy: body-bound
rpc ReloadSiteReplicationConfig(ReloadSiteReplicationConfigRequest) returns (ReloadSiteReplicationConfigResponse) {}; rpc ReloadSiteReplicationConfig(ReloadSiteReplicationConfigRequest) returns (ReloadSiteReplicationConfigResponse) {}; // auth-policy: body-bound
// rpc VerifyBinary() returns () {}; // rpc VerifyBinary() returns () {};
// rpc CommitBinary() returns () {}; // rpc CommitBinary() returns () {};
rpc SignalService(SignalServiceRequest) returns (SignalServiceResponse) {}; rpc SignalService(SignalServiceRequest) returns (SignalServiceResponse) {}; // auth-policy: body-bound
rpc ScannerActivity(ScannerActivityRequest) returns (ScannerActivityResponse) {}; rpc ScannerActivity(ScannerActivityRequest) returns (ScannerActivityResponse) {}; // auth-policy: body-bound
rpc BackgroundHealStatus(BackgroundHealStatusRequest) returns (BackgroundHealStatusResponse) {}; rpc BackgroundHealStatus(BackgroundHealStatusRequest) returns (BackgroundHealStatusResponse) {}; // auth-policy: read-only
rpc GetMetacacheListing(GetMetacacheListingRequest) returns (GetMetacacheListingResponse) {}; rpc GetMetacacheListing(GetMetacacheListingRequest) returns (GetMetacacheListingResponse) {}; // auth-policy: unimplemented
rpc UpdateMetacacheListing(UpdateMetacacheListingRequest) returns (UpdateMetacacheListingResponse) {}; rpc UpdateMetacacheListing(UpdateMetacacheListingRequest) returns (UpdateMetacacheListingResponse) {}; // auth-policy: unimplemented
rpc ReloadPoolMeta(ReloadPoolMetaRequest) returns (ReloadPoolMetaResponse) {}; rpc ReloadPoolMeta(ReloadPoolMetaRequest) returns (ReloadPoolMetaResponse) {}; // auth-policy: body-bound
rpc StopRebalance(StopRebalanceRequest) returns (StopRebalanceResponse) {}; rpc StopRebalance(StopRebalanceRequest) returns (StopRebalanceResponse) {}; // auth-policy: body-bound
rpc LoadRebalanceMeta(LoadRebalanceMetaRequest) returns (LoadRebalanceMetaResponse) {}; rpc LoadRebalanceMeta(LoadRebalanceMetaRequest) returns (LoadRebalanceMetaResponse) {}; // auth-policy: body-bound
rpc StartDecommission(StartDecommissionRequest) returns (StartDecommissionResponse) {}; rpc StartDecommission(StartDecommissionRequest) returns (StartDecommissionResponse) {}; // auth-policy: body-bound
rpc CancelDecommission(CancelDecommissionRequest) returns (CancelDecommissionResponse) {}; rpc CancelDecommission(CancelDecommissionRequest) returns (CancelDecommissionResponse) {}; // auth-policy: body-bound
rpc ClearDecommission(ClearDecommissionRequest) returns (ClearDecommissionResponse) {}; rpc ClearDecommission(ClearDecommissionRequest) returns (ClearDecommissionResponse) {}; // auth-policy: body-bound
rpc LoadTransitionTierConfig(LoadTransitionTierConfigRequest) returns (LoadTransitionTierConfigResponse) {}; rpc LoadTransitionTierConfig(LoadTransitionTierConfigRequest) returns (LoadTransitionTierConfigResponse) {}; // auth-policy: body-bound
rpc GetLiveEvents(GetLiveEventsRequest) returns (GetLiveEventsResponse) {}; rpc GetLiveEvents(GetLiveEventsRequest) returns (GetLiveEventsResponse) {}; // auth-policy: read-only
} }
service HealControlService { service HealControlService {
+1 -1
View File
@@ -47,7 +47,7 @@ rmp-serde = { workspace = true }
hmac = { workspace = true } hmac = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
rustfs-filemeta = { workspace = true } rustfs-filemeta = { workspace = true }
tokio-util = { workspace = true, features = ["io", "compat"] } tokio-util = { workspace = true, features = ["io", "compat", "rt"] }
rustfs-ecstore = { workspace = true } rustfs-ecstore = { workspace = true }
rustfs-storage-api = { workspace = true } rustfs-storage-api = { workspace = true }
http = { workspace = true } http = { workspace = true }
+51 -1
View File
@@ -698,7 +698,7 @@ impl DataUsageCache {
let mut visited = HashSet::new(); let mut visited = HashSet::new();
visited.insert(hash_path(path).key()); visited.insert(hash_path(path).key());
let mut flat = self.flatten_with_guard(root, &mut visited, 0); let mut flat = self.flatten_with_guard(root, &mut visited, 0);
if flat.replication_stats.as_ref().is_some_and(|stats| stats.empty()) { if flat.replication_stats.as_ref().is_some_and(|stats| stats.is_empty()) {
flat.replication_stats = None; flat.replication_stats = None;
} }
Some(flat) Some(flat)
@@ -1574,6 +1574,7 @@ mod tests {
use super::*; use super::*;
use crate::storage_api::scanner_io::{HTTPRangeSpec, ObjectIO}; use crate::storage_api::scanner_io::{HTTPRangeSpec, ObjectIO};
use crate::{ScannerGetObjectReader, ScannerPutObjReader}; use crate::{ScannerGetObjectReader, ScannerPutObjReader};
use rustfs_data_usage::{ReplicationAllStats, ReplicationStats};
use serde_json::Value; use serde_json::Value;
use std::io::Cursor; use std::io::Cursor;
use std::pin::Pin; use std::pin::Pin;
@@ -2767,6 +2768,55 @@ mod tests {
assert!(flat.children.is_empty()); assert!(flat.children.is_empty());
} }
#[test]
fn size_recursive_prunes_empty_and_preserves_threshold_replication_stats() {
let root = hash_path("bucket");
let child = hash_path("bucket/child");
let mut cache = DataUsageCache::default();
cache.replace_hashed(&root, &None, &DataUsageEntry::default());
cache.replace_hashed(
&child,
&Some(root.clone()),
&DataUsageEntry {
replication_stats: Some(ReplicationAllStats::default()),
..Default::default()
},
);
assert!(
cache
.size_recursive("bucket")
.expect("scanner bucket usage should flatten")
.replication_stats
.is_none()
);
cache.replace_hashed(
&child,
&Some(root.clone()),
&DataUsageEntry {
replication_stats: Some(ReplicationAllStats {
targets: HashMap::from([(
"arn:test:threshold".to_string(),
ReplicationStats {
after_threshold_count: 1,
..Default::default()
},
)]),
..Default::default()
}),
..Default::default()
},
);
let flattened = cache.size_recursive("bucket").expect("scanner bucket usage should flatten");
let replication = flattened
.replication_stats
.expect("threshold-only replication stats must survive pruning");
assert_eq!(replication.targets["arn:test:threshold"].after_threshold_count, 1);
}
#[test] #[test]
fn checked_flatten_rejects_dangling_child() { fn checked_flatten_rejects_dangling_child() {
let root_key = hash_path("bucket").key(); let root_key = hash_path("bucket").key();
+342 -47
View File
@@ -14,6 +14,8 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::future::Future; use std::future::Future;
#[cfg(test)]
use std::sync::Mutex as StdMutex;
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
use crate::ScannerObjectIO; use crate::ScannerObjectIO;
@@ -38,8 +40,8 @@ use bytes::Bytes;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use rustfs_common::heal_channel::HealScanMode; use rustfs_common::heal_channel::HealScanMode;
use rustfs_common::metrics::{ use rustfs_common::metrics::{
CurrentCycle, Metric, Metrics, ScanCyclePartialReason, ScannerUsageSaveResult, ScannerWorkSource, emit_scan_cycle_complete, CurrentCycle, Metric, Metrics, ScanCyclePartialReason, ScanCycleWorkSnapshot, ScannerUsageSaveResult, ScannerWorkSource,
emit_scan_cycle_partial_with_source, emit_scan_cycle_superseded, global_metrics, emit_scan_cycle_complete, emit_scan_cycle_partial_with_source, emit_scan_cycle_superseded, global_metrics,
}; };
use rustfs_config::ScannerSpeed; use rustfs_config::ScannerSpeed;
#[cfg(test)] #[cfg(test)]
@@ -50,9 +52,12 @@ use rustfs_config::{
use rustfs_config::{ENV_SCANNER_CYCLE, ENV_SCANNER_SPEED, ENV_SCANNER_START_DELAY_SECS}; use rustfs_config::{ENV_SCANNER_CYCLE, ENV_SCANNER_SPEED, ENV_SCANNER_START_DELAY_SECS};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256}; use sha2::{Digest as _, Sha256};
#[cfg(test)]
use tokio::sync::Notify;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::time::{Duration, Instant}; use tokio::time::{Duration, Instant};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
use tracing::{debug, error, info, instrument, warn}; use tracing::{debug, error, info, instrument, warn};
use crate::storage_api::scan::{ use crate::storage_api::scan::{
@@ -93,6 +98,44 @@ const SCANNER_CYCLE_STATE_MAGIC: &[u8; 8] = b"RSCYC001";
const SCANNER_CYCLE_STATE_HEADER_LEN: usize = 24; const SCANNER_CYCLE_STATE_HEADER_LEN: usize = 24;
#[cfg(test)] #[cfg(test)]
const ENV_SCANNER_START_DELAY_SECS_DEPRECATED: &str = "RUSTFS_DATA_SCANNER_START_DELAY_SECS"; const ENV_SCANNER_START_DELAY_SECS_DEPRECATED: &str = "RUSTFS_DATA_SCANNER_START_DELAY_SECS";
#[cfg(test)]
type ScannerCycleStatePersistTestHook = (u64, Arc<Notify>);
#[cfg(test)]
static SCANNER_CYCLE_STATE_PERSIST_TEST_HOOK: LazyLock<StdMutex<Option<ScannerCycleStatePersistTestHook>>> =
LazyLock::new(|| StdMutex::new(None));
#[cfg(test)]
struct ScannerCycleStatePersistTestHookGuard;
#[cfg(test)]
impl Drop for ScannerCycleStatePersistTestHookGuard {
fn drop(&mut self) {
*SCANNER_CYCLE_STATE_PERSIST_TEST_HOOK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
}
#[cfg(test)]
fn set_scanner_cycle_state_persist_test_hook(leader_epoch: u64, reached: Arc<Notify>) -> ScannerCycleStatePersistTestHookGuard {
*SCANNER_CYCLE_STATE_PERSIST_TEST_HOOK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((leader_epoch, reached));
ScannerCycleStatePersistTestHookGuard
}
#[cfg(test)]
fn notify_scanner_cycle_state_persist_test_hook(leader_epoch: u64) {
let reached = SCANNER_CYCLE_STATE_PERSIST_TEST_HOOK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.as_ref()
.filter(|(expected_epoch, _)| *expected_epoch == leader_epoch)
.map(|(_, reached)| reached.clone());
if let Some(reached) = reached {
reached.notify_one();
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
enum ScannerCycleStateError { enum ScannerCycleStateError {
@@ -1691,10 +1734,10 @@ fn data_usage_persist_timeout() -> Duration {
DataUsageCache::persistence_timeout() DataUsageCache::persistence_timeout()
} }
async fn mark_scan_cycle_idle(cycle_info: &mut CurrentCycle) { async fn mark_scan_cycle_idle(cycle_info: &mut CurrentCycle, cycle_metrics_guard: &mut ScannerCycleMetricsGuard) {
cycle_info.current = 0; cycle_info.current = 0;
global_metrics().clear_current_scan_mode(); global_metrics().clear_current_scan_mode();
global_metrics().set_cycle(Some(cycle_info.clone())).await; cycle_metrics_guard.finish(cycle_info.clone()).await;
} }
fn encode_scanner_cycle_state(cycle_info: &CurrentCycle, leader_epoch: u64) -> Result<Vec<u8>, ScannerCycleStateError> { fn encode_scanner_cycle_state(cycle_info: &CurrentCycle, leader_epoch: u64) -> Result<Vec<u8>, ScannerCycleStateError> {
@@ -2218,6 +2261,8 @@ async fn persist_scanner_cycle_state(
return false; return false;
} }
#[cfg(test)]
notify_scanner_cycle_state_persist_test_hook(leader_epoch);
match save_config_with_preconditions(storeapi.clone(), &DATA_USAGE_BLOOM_NAME_PATH, buf.clone(), revision.preconditions()) match save_config_with_preconditions(storeapi.clone(), &DATA_USAGE_BLOOM_NAME_PATH, buf.clone(), revision.preconditions())
.await .await
{ {
@@ -2340,7 +2385,6 @@ async fn persist_scanner_cycle_state(
if persisted_cycle.next >= cycle_info.next { if persisted_cycle.next >= cycle_info.next {
*cycle_info = persisted_cycle; *cycle_info = persisted_cycle;
global_metrics().set_cycle(Some(cycle_info.clone())).await;
debug!( debug!(
target: "rustfs::scanner", target: "rustfs::scanner",
event = EVENT_SCANNER_PERSIST_STATE, event = EVENT_SCANNER_PERSIST_STATE,
@@ -2406,6 +2450,7 @@ async fn finalize_partial_scan_cycle(
cycle_info: &mut CurrentCycle, cycle_info: &mut CurrentCycle,
revision: &mut DataUsageCacheRevision, revision: &mut DataUsageCacheRevision,
leader_epoch: u64, leader_epoch: u64,
cycle_metrics_guard: &mut ScannerCycleMetricsGuard,
) -> bool { ) -> bool {
// A budget-limited cycle is deliberate pacing, not a failure. The cycle counter // A budget-limited cycle is deliberate pacing, not a failure. The cycle counter
// must still advance (and persist) because per-bucket next_cycle is stamped from // must still advance (and persist) because per-bucket next_cycle is stamped from
@@ -2422,11 +2467,14 @@ async fn finalize_partial_scan_cycle(
error = %err, error = %err,
"Scanner partial cycle could not advance" "Scanner partial cycle could not advance"
); );
mark_scan_cycle_idle(cycle_info).await; mark_scan_cycle_idle(cycle_info, cycle_metrics_guard).await;
return false; return false;
} }
mark_scan_cycle_idle(cycle_info).await; cycle_info.current = 0;
persist_scanner_cycle_state(ctx, storeapi, cycle_info, revision, leader_epoch).await global_metrics().clear_current_scan_mode();
let persisted = persist_scanner_cycle_state(ctx, storeapi, cycle_info, revision, leader_epoch).await;
cycle_metrics_guard.finish(cycle_info.clone()).await;
persisted
} }
async fn persist_required_scanner_cycle_floor( async fn persist_required_scanner_cycle_floor(
@@ -2436,6 +2484,7 @@ async fn persist_required_scanner_cycle_floor(
revision: &mut DataUsageCacheRevision, revision: &mut DataUsageCacheRevision,
leader_epoch: u64, leader_epoch: u64,
required_cycle: u64, required_cycle: u64,
cycle_metrics_guard: &mut ScannerCycleMetricsGuard,
) -> bool { ) -> bool {
if required_cycle <= cycle_info.current || required_cycle == u64::MAX { if required_cycle <= cycle_info.current || required_cycle == u64::MAX {
error!( error!(
@@ -2448,13 +2497,16 @@ async fn persist_required_scanner_cycle_floor(
state = "invalid_cache_cycle_floor", state = "invalid_cache_cycle_floor",
"Scanner cache cycle floor is invalid" "Scanner cache cycle floor is invalid"
); );
mark_scan_cycle_idle(cycle_info).await; mark_scan_cycle_idle(cycle_info, cycle_metrics_guard).await;
return false; return false;
} }
cycle_info.next = cycle_info.next.max(required_cycle); cycle_info.next = cycle_info.next.max(required_cycle);
mark_scan_cycle_idle(cycle_info).await; cycle_info.current = 0;
persist_scanner_cycle_state(ctx, storeapi, cycle_info, revision, leader_epoch).await global_metrics().clear_current_scan_mode();
let persisted = persist_scanner_cycle_state(ctx, storeapi, cycle_info, revision, leader_epoch).await;
cycle_metrics_guard.finish(cycle_info.clone()).await;
persisted
} }
async fn await_scanner_cycle_with_lock_fence<Cycle, LockLost>( async fn await_scanner_cycle_with_lock_fence<Cycle, LockLost>(
@@ -2513,7 +2565,7 @@ async fn run_data_scanner_cycle(
let now = Instant::now(); let now = Instant::now();
cycle_info.started = Utc::now(); cycle_info.started = Utc::now();
global_metrics().set_cycle(Some(cycle_info.clone())).await; let mut cycle_metrics_guard = ScannerCycleMetricsGuard::new(cycle_info.clone()).await;
let mut background_heal_info = read_background_heal_info(storeapi.clone()).await; let mut background_heal_info = read_background_heal_info(storeapi.clone()).await;
@@ -2564,14 +2616,14 @@ async fn run_data_scanner_cycle(
"Scanner cycle could not capture the data usage persistence baseline" "Scanner cycle could not capture the data usage persistence baseline"
); );
emit_scan_cycle_complete(false, cycle_start.elapsed()); emit_scan_cycle_complete(false, cycle_start.elapsed());
mark_scan_cycle_idle(cycle_info).await; mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
return ScannerCycleOutcome::Failed; return ScannerCycleOutcome::Failed;
} }
}; };
let (sender, receiver) = mpsc::channel::<DataUsageInfo>(1); let (sender, receiver) = mpsc::channel::<DataUsageInfo>(1);
let storeapi_clone = storeapi.clone(); let storeapi_clone = storeapi.clone();
let ctx_clone = ctx.clone(); let ctx_clone = ctx.clone();
let mut usage_persist_task = tokio::spawn(async move { let mut usage_persist_task = AbortOnDropHandle::new(tokio::spawn(async move {
store_data_usage_in_backend_with_outcome_for_epoch_and_baseline( store_data_usage_in_backend_with_outcome_for_epoch_and_baseline(
ctx_clone, ctx_clone,
storeapi_clone, storeapi_clone,
@@ -2580,10 +2632,9 @@ async fn run_data_scanner_cycle(
Some(usage_persist_baseline), Some(usage_persist_baseline),
) )
.await .await
}); }));
let done_cycle = Metrics::time(Metric::ScanCycle); let done_cycle = Metrics::time(Metric::ScanCycle);
let cycle_work_start = global_metrics().start_scan_cycle_work();
let cycle_budget = ScannerCycleBudget::new(ctx, cycle_budget_config); let cycle_budget = ScannerCycleBudget::new(ctx, cycle_budget_config);
let scan_result = storeapi let scan_result = storeapi
.clone() .clone()
@@ -2640,7 +2691,6 @@ async fn run_data_scanner_cycle(
} }
}; };
let unresolved_heal_work = global_metrics().current_scan_cycle_has_unresolved_heal_work(); let unresolved_heal_work = global_metrics().current_scan_cycle_has_unresolved_heal_work();
global_metrics().finish_scan_cycle_work(cycle_work_start);
let scan_cycle_result = match scan_result { let scan_cycle_result = match scan_result {
Ok(result) => result, Ok(result) => result,
@@ -2663,7 +2713,7 @@ async fn run_data_scanner_cycle(
{ {
save_background_heal_info(storeapi.clone(), new_heal_info).await; save_background_heal_info(storeapi.clone(), new_heal_info).await;
} }
mark_scan_cycle_idle(cycle_info).await; mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
return ScannerCycleOutcome::Failed; return ScannerCycleOutcome::Failed;
} }
}; };
@@ -2678,7 +2728,7 @@ async fn run_data_scanner_cycle(
"Scanner cycle stopped before committing cycle state" "Scanner cycle stopped before committing cycle state"
); );
emit_scan_cycle_complete(false, cycle_start.elapsed()); emit_scan_cycle_complete(false, cycle_start.elapsed());
mark_scan_cycle_idle(cycle_info).await; mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
return ScannerCycleOutcome::Failed; return ScannerCycleOutcome::Failed;
} }
if let Some(required_cycle) = scan_cycle_result.required_cycle_floor() { if let Some(required_cycle) = scan_cycle_result.required_cycle_floor() {
@@ -2700,6 +2750,7 @@ async fn run_data_scanner_cycle(
cycle_revision, cycle_revision,
leader_epoch, leader_epoch,
required_cycle, required_cycle,
&mut cycle_metrics_guard,
) )
.await .await
{ {
@@ -2719,7 +2770,7 @@ async fn run_data_scanner_cycle(
"Scanner cycle completed without a durable data usage snapshot" "Scanner cycle completed without a durable data usage snapshot"
); );
emit_scan_cycle_complete(false, cycle_start.elapsed()); emit_scan_cycle_complete(false, cycle_start.elapsed());
mark_scan_cycle_idle(cycle_info).await; mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
return ScannerCycleOutcome::Failed; return ScannerCycleOutcome::Failed;
} }
if budget_elapsed { if budget_elapsed {
@@ -2743,7 +2794,16 @@ async fn run_data_scanner_cycle(
scan_cycle_partial_reason(budget_reason), scan_cycle_partial_reason(budget_reason),
scan_cycle_partial_source(budget_reason), scan_cycle_partial_source(budget_reason),
); );
return if finalize_partial_scan_cycle(ctx, storeapi.clone(), cycle_info, cycle_revision, leader_epoch).await { return if finalize_partial_scan_cycle(
ctx,
storeapi.clone(),
cycle_info,
cycle_revision,
leader_epoch,
&mut cycle_metrics_guard,
)
.await
{
ScannerCycleOutcome::Partial ScannerCycleOutcome::Partial
} else { } else {
ScannerCycleOutcome::Failed ScannerCycleOutcome::Failed
@@ -2792,7 +2852,7 @@ async fn run_data_scanner_cycle(
"Scanner cycle completed without a durable data usage snapshot" "Scanner cycle completed without a durable data usage snapshot"
); );
emit_scan_cycle_complete(false, cycle_start.elapsed()); emit_scan_cycle_complete(false, cycle_start.elapsed());
mark_scan_cycle_idle(cycle_info).await; mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
return ScannerCycleOutcome::Failed; return ScannerCycleOutcome::Failed;
} }
ScannerCycleOutcome::Partial => { ScannerCycleOutcome::Partial => {
@@ -2818,7 +2878,16 @@ async fn run_data_scanner_cycle(
); );
} }
emit_scan_cycle_partial_with_source(cycle_start.elapsed(), ScanCyclePartialReason::Unknown, None); emit_scan_cycle_partial_with_source(cycle_start.elapsed(), ScanCyclePartialReason::Unknown, None);
return if finalize_partial_scan_cycle(ctx, storeapi.clone(), cycle_info, cycle_revision, leader_epoch).await { return if finalize_partial_scan_cycle(
ctx,
storeapi.clone(),
cycle_info,
cycle_revision,
leader_epoch,
&mut cycle_metrics_guard,
)
.await
{
ScannerCycleOutcome::Partial ScannerCycleOutcome::Partial
} else { } else {
ScannerCycleOutcome::Failed ScannerCycleOutcome::Failed
@@ -2834,7 +2903,16 @@ async fn run_data_scanner_cycle(
state = "superseded", state = "superseded",
"Scanner cycle usage snapshot was superseded by concurrent namespace activity" "Scanner cycle usage snapshot was superseded by concurrent namespace activity"
); );
if finalize_partial_scan_cycle(ctx, storeapi.clone(), cycle_info, cycle_revision, leader_epoch).await { if finalize_partial_scan_cycle(
ctx,
storeapi.clone(),
cycle_info,
cycle_revision,
leader_epoch,
&mut cycle_metrics_guard,
)
.await
{
emit_scan_cycle_superseded(cycle_start.elapsed()); emit_scan_cycle_superseded(cycle_start.elapsed());
return ScannerCycleOutcome::Superseded; return ScannerCycleOutcome::Superseded;
} }
@@ -2853,7 +2931,7 @@ async fn run_data_scanner_cycle(
error = %err, error = %err,
"Scanner completed cycle could not advance" "Scanner completed cycle could not advance"
); );
mark_scan_cycle_idle(cycle_info).await; mark_scan_cycle_idle(cycle_info, &mut cycle_metrics_guard).await;
emit_scan_cycle_complete(false, cycle_start.elapsed()); emit_scan_cycle_complete(false, cycle_start.elapsed());
return ScannerCycleOutcome::Failed; return ScannerCycleOutcome::Failed;
} }
@@ -2862,9 +2940,8 @@ async fn run_data_scanner_cycle(
global_metrics().clear_current_scan_mode(); global_metrics().clear_current_scan_mode();
retain_recent_cycle_completions(&mut cycle_info.cycle_completed); retain_recent_cycle_completions(&mut cycle_info.cycle_completed);
global_metrics().set_cycle(Some(cycle_info.clone())).await;
if !persist_scanner_cycle_state(ctx, storeapi.clone(), cycle_info, cycle_revision, leader_epoch).await { if !persist_scanner_cycle_state(ctx, storeapi.clone(), cycle_info, cycle_revision, leader_epoch).await {
mark_scan_cycle_idle(cycle_info).await; cycle_metrics_guard.finish(cycle_info.clone()).await;
emit_scan_cycle_complete(false, cycle_start.elapsed()); emit_scan_cycle_complete(false, cycle_start.elapsed());
return ScannerCycleOutcome::Failed; return ScannerCycleOutcome::Failed;
} }
@@ -2888,9 +2965,37 @@ async fn run_data_scanner_cycle(
"Scanner cycle completed" "Scanner cycle completed"
); );
cycle_metrics_guard.finish(cycle_info.clone()).await;
scanner_cycle_outcome_with_pending_maintenance(ScannerCycleOutcome::Completed, pending_maintenance_work) scanner_cycle_outcome_with_pending_maintenance(ScannerCycleOutcome::Completed, pending_maintenance_work)
} }
struct ScannerCycleMetricsGuard {
start: Option<ScanCycleWorkSnapshot>,
}
impl ScannerCycleMetricsGuard {
async fn new(cycle: CurrentCycle) -> Self {
Self {
start: Some(global_metrics().start_scan_cycle_work_with_cycle(cycle).await),
}
}
async fn finish(&mut self, cycle: CurrentCycle) {
if let Some(start) = self.start {
global_metrics().finish_scan_cycle_work_with_cycle(start, cycle).await;
self.start = None;
}
}
}
impl Drop for ScannerCycleMetricsGuard {
fn drop(&mut self) {
if let Some(start) = self.start.take() {
global_metrics().finish_scan_cycle_work(start);
}
}
}
async fn record_scanner_leader_lock_lost(message: &'static str) { async fn record_scanner_leader_lock_lost(message: &'static str) {
reset_scanner_cycle_schedule(); reset_scanner_cycle_schedule();
record_scanner_leader_lock_state("lost"); record_scanner_leader_lock_state("lost");
@@ -3443,7 +3548,7 @@ enum DataUsagePersistTaskResult {
async fn wait_for_data_usage_persist_task( async fn wait_for_data_usage_persist_task(
ctx: &CancellationToken, ctx: &CancellationToken,
task: &mut tokio::task::JoinHandle<DataUsagePersistOutcome>, task: &mut AbortOnDropHandle<DataUsagePersistOutcome>,
timeout: Duration, timeout: Duration,
) -> DataUsagePersistTaskResult { ) -> DataUsagePersistTaskResult {
tokio::select! { tokio::select! {
@@ -3840,8 +3945,9 @@ mod tests {
use super::*; use super::*;
use crate::EcstoreResult; use crate::EcstoreResult;
use crate::{ use crate::{
ScannerGetObjectReader as GetObjectReader, ScannerObjectInfo as ObjectInfo, ScannerObjectOptions as ObjectOptions, Endpoint, EndpointServerPools, Endpoints, InstanceContext, PoolEndpoints, ScannerGetObjectReader as GetObjectReader,
ScannerPutObjReader as PutObjReader, ScannerObjectInfo as ObjectInfo, ScannerObjectOptions as ObjectOptions, ScannerPutObjReader as PutObjReader,
init_bucket_metadata_sys_for_scanner_tests, init_ecstore_config_for_scanner_tests, init_local_disks_with_instance_ctx,
}; };
use serial_test::serial; use serial_test::serial;
use std::collections::HashMap; use std::collections::HashMap;
@@ -3853,6 +3959,47 @@ mod tests {
const TEST_DEFAULT_SCANNER_CYCLE_SECS: u64 = 24 * 60 * 60; const TEST_DEFAULT_SCANNER_CYCLE_SECS: u64 = 24 * 60 * 60;
async fn setup_scanner_cycle_store() -> (tempfile::TempDir, Arc<ECStore>) {
init_ecstore_config_for_scanner_tests();
let temp_dir = tempfile::tempdir().expect("scanner cycle test directory should be created");
let mut endpoints = Vec::new();
for disk_index in 0..4 {
let disk_path = temp_dir.path().join(format!("disk{disk_index}"));
tokio::fs::create_dir_all(&disk_path)
.await
.expect("scanner cycle test disk should be created");
let mut endpoint =
Endpoint::try_from(disk_path.to_str().expect("disk path should be utf8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_index);
endpoints.push(endpoint);
}
let endpoint_pools = EndpointServerPools::from(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
endpoints: Endpoints::from(endpoints),
cmd_line: "scanner-cycle-metrics".to_string(),
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
}]);
let instance_ctx = Arc::new(InstanceContext::new());
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
.await
.expect("scanner cycle test disks should initialize");
let store = ECStore::new_with_instance_ctx(
"127.0.0.1:0".parse().expect("test address should parse"),
endpoint_pools,
CancellationToken::new(),
instance_ctx,
)
.await
.expect("scanner cycle test ECStore should initialize");
init_bucket_metadata_sys_for_scanner_tests(store.clone()).await;
(temp_dir, store)
}
fn assert_run_data_scanner_signature<F, Fut>(_run: F) fn assert_run_data_scanner_signature<F, Fut>(_run: F)
where where
F: Fn(CancellationToken, Arc<ECStore>) -> Fut, F: Fn(CancellationToken, Arc<ECStore>) -> Fut,
@@ -4312,9 +4459,9 @@ mod tests {
}; };
global_metrics().set_current_scan_mode(HealScanMode::Deep); global_metrics().set_current_scan_mode(HealScanMode::Deep);
global_metrics().set_cycle(Some(cycle_info.clone())).await; let mut cycle_metrics_guard = ScannerCycleMetricsGuard::new(cycle_info.clone()).await;
mark_scan_cycle_idle(&mut cycle_info).await; mark_scan_cycle_idle(&mut cycle_info, &mut cycle_metrics_guard).await;
let published = global_metrics() let published = global_metrics()
.get_cycle() .get_cycle()
@@ -4330,6 +4477,123 @@ mod tests {
global_metrics().set_cycle(None).await; global_metrics().set_cycle(None).await;
} }
#[tokio::test]
#[serial]
async fn scanner_cycle_metrics_guard_covers_published_first_cycle_lifetime() {
let cycle_started = Utc::now() - chrono::Duration::seconds(5);
let mut cycle_info = CurrentCycle {
current: 0,
next: 1,
started: cycle_started,
..Default::default()
};
let mut guard = ScannerCycleMetricsGuard::new(cycle_info.clone()).await;
let setup_report = global_metrics().report().await;
assert!(setup_report.current_cycle_active);
assert_eq!(setup_report.current_cycle, 0);
assert_eq!(setup_report.current_started, cycle_started);
mark_scan_cycle_idle(&mut cycle_info, &mut guard).await;
let idle_report = global_metrics().report().await;
assert!(!idle_report.current_cycle_active);
global_metrics().set_cycle(None).await;
}
#[tokio::test]
#[serial]
async fn scanner_cycle_metrics_guard_keeps_active_cycle_published_during_finalization() {
let mut cycle_info = CurrentCycle {
current: 12,
next: 13,
started: Utc::now(),
..Default::default()
};
let mut guard = ScannerCycleMetricsGuard::new(cycle_info.clone()).await;
cycle_info.current = 0;
tokio::task::yield_now().await;
let finalizing_report = global_metrics().report().await;
assert!(finalizing_report.current_cycle_active);
assert_eq!(finalizing_report.current_cycle, 12);
guard.finish(cycle_info).await;
let idle_report = global_metrics().report().await;
assert!(!idle_report.current_cycle_active);
assert_eq!(idle_report.current_cycle, 0);
global_metrics().set_cycle(None).await;
}
#[tokio::test]
#[serial]
async fn scanner_cycle_metrics_guard_drop_clears_activity() {
let guard = ScannerCycleMetricsGuard::new(CurrentCycle {
current: 12,
next: 13,
started: Utc::now(),
..Default::default()
})
.await;
assert!(global_metrics().report().await.current_cycle_active);
drop(guard);
assert!(!global_metrics().report().await.current_cycle_active);
global_metrics().set_cycle(None).await;
}
#[tokio::test]
#[serial]
async fn run_data_scanner_cycle_publishes_activity_for_owner_lifetime() {
let (_temp_dir, store) = setup_scanner_cycle_store().await;
let ctx = CancellationToken::new();
let mut cycle_info = CurrentCycle::default();
let mut revision = DataUsageCacheRevision::Missing;
let leader_epoch = u64::MAX - 1;
let state_persist_reached = Arc::new(Notify::new());
let _state_persist_hook = set_scanner_cycle_state_persist_test_hook(leader_epoch, state_persist_reached.clone());
let state_lock = store
.new_ns_lock(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str())
.await
.expect("scanner cycle state lock should be created");
let state_guard = state_lock
.get_write_lock(Duration::from_secs(1))
.await
.expect("scanner cycle state lock should be acquired");
let mut cycle = Box::pin(run_data_scanner_cycle(&ctx, &store, &mut cycle_info, &mut revision, leader_epoch));
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
assert!(cycle.as_mut().poll(&mut context).is_pending());
let active = global_metrics().report().await;
assert!(active.current_cycle_active);
assert_eq!(active.current_cycle, 0);
tokio::time::timeout(Duration::from_secs(30), async {
tokio::select! {
outcome = &mut cycle => panic!("scanner cycle finished before state persistence was released: {outcome:?}"),
_ = state_persist_reached.notified() => {}
}
})
.await
.expect("scanner cycle should reach state persistence");
let finalizing = global_metrics().report().await;
assert!(finalizing.current_cycle_active);
drop(state_guard);
let outcome = tokio::time::timeout(Duration::from_secs(30), cycle)
.await
.expect("scanner cycle should finish");
assert!(matches!(
outcome,
ScannerCycleOutcome::Completed | ScannerCycleOutcome::CompletedWithPendingMaintenance
));
assert!(!global_metrics().report().await.current_cycle_active);
global_metrics().set_cycle(None).await;
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn test_finalize_partial_scan_cycle_advances_and_persists_counter() { async fn test_finalize_partial_scan_cycle_advances_and_persists_counter() {
@@ -4342,8 +4606,11 @@ mod tests {
cycle_completed: vec![], cycle_completed: vec![],
started: Utc::now(), started: Utc::now(),
}; };
let mut cycle_metrics_guard = ScannerCycleMetricsGuard::new(cycle_info.clone()).await;
assert!(finalize_partial_scan_cycle(&ctx, store.clone(), &mut cycle_info, &mut revision, 1).await); assert!(
finalize_partial_scan_cycle(&ctx, store.clone(), &mut cycle_info, &mut revision, 1, &mut cycle_metrics_guard,).await
);
assert_eq!(cycle_info.next, 13); assert_eq!(cycle_info.next, 13);
assert_eq!(cycle_info.current, 0); assert_eq!(cycle_info.current, 0);
@@ -4377,8 +4644,20 @@ mod tests {
cycle_completed: vec![], cycle_completed: vec![],
started: Utc::now(), started: Utc::now(),
}; };
let mut cycle_metrics_guard = ScannerCycleMetricsGuard::new(cycle_info.clone()).await;
assert!(persist_required_scanner_cycle_floor(&ctx, store.clone(), &mut cycle_info, &mut revision, 7, 19).await); assert!(
persist_required_scanner_cycle_floor(
&ctx,
store.clone(),
&mut cycle_info,
&mut revision,
7,
19,
&mut cycle_metrics_guard,
)
.await
);
assert_eq!(cycle_info.current, 0); assert_eq!(cycle_info.current, 0);
assert_eq!(cycle_info.next, 19); assert_eq!(cycle_info.next, 19);
@@ -4405,22 +4684,37 @@ mod tests {
cycle_completed: vec![], cycle_completed: vec![],
started: Utc::now(), started: Utc::now(),
}; };
let mut cycle_metrics_guard = ScannerCycleMetricsGuard::new(cycle_info.clone()).await;
assert!(!persist_required_scanner_cycle_floor(&ctx, store.clone(), &mut cycle_info, &mut revision, 7, 12).await);
assert_eq!(cycle_info.next, 12);
assert_eq!(revision, DataUsageCacheRevision::Missing);
assert!( assert!(
!persist_required_scanner_cycle_floor( !persist_required_scanner_cycle_floor(
&ctx, &ctx,
store.clone(), store.clone(),
&mut CurrentCycle { &mut cycle_info,
current: 12, &mut revision,
next: 12, 7,
..Default::default() 12,
}, &mut cycle_metrics_guard,
)
.await
);
assert_eq!(cycle_info.next, 12);
assert_eq!(revision, DataUsageCacheRevision::Missing);
let mut max_cycle_info = CurrentCycle {
current: 12,
next: 12,
..Default::default()
};
let mut max_cycle_metrics_guard = ScannerCycleMetricsGuard::new(max_cycle_info.clone()).await;
assert!(
!persist_required_scanner_cycle_floor(
&ctx,
store.clone(),
&mut max_cycle_info,
&mut revision, &mut revision,
7, 7,
u64::MAX, u64::MAX,
&mut max_cycle_metrics_guard,
) )
.await .await
); );
@@ -4685,8 +4979,9 @@ mod tests {
cycle_completed: vec![], cycle_completed: vec![],
started: Utc::now(), started: Utc::now(),
}; };
let mut cycle_metrics_guard = ScannerCycleMetricsGuard::new(cycle_info.clone()).await;
assert!(!finalize_partial_scan_cycle(&ctx, store, &mut cycle_info, &mut revision, 1).await); assert!(!finalize_partial_scan_cycle(&ctx, store, &mut cycle_info, &mut revision, 1, &mut cycle_metrics_guard,).await);
assert_eq!(cycle_info.next, 13); assert_eq!(cycle_info.next, 13);
assert_eq!(cycle_info.current, 0); assert_eq!(cycle_info.current, 0);
assert_eq!(revision, DataUsageCacheRevision::Missing); assert_eq!(revision, DataUsageCacheRevision::Missing);
@@ -5943,10 +6238,10 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn data_usage_persist_wait_aborts_when_scanner_is_cancelled() { async fn data_usage_persist_wait_aborts_when_scanner_is_cancelled() {
let ctx = CancellationToken::new(); let ctx = CancellationToken::new();
let mut task = tokio::spawn(async { let mut task = AbortOnDropHandle::new(tokio::spawn(async {
std::future::pending::<()>().await; std::future::pending::<()>().await;
DataUsagePersistOutcome::Saved DataUsagePersistOutcome::Saved
}); }));
ctx.cancel(); ctx.cancel();
let result = wait_for_data_usage_persist_task(&ctx, &mut task, Duration::from_secs(60)).await; let result = wait_for_data_usage_persist_task(&ctx, &mut task, Duration::from_secs(60)).await;
@@ -5958,10 +6253,10 @@ mod tests {
#[tokio::test(start_paused = true)] #[tokio::test(start_paused = true)]
async fn data_usage_persist_wait_aborts_after_timeout() { async fn data_usage_persist_wait_aborts_after_timeout() {
let ctx = CancellationToken::new(); let ctx = CancellationToken::new();
let mut task = tokio::spawn(async { let mut task = AbortOnDropHandle::new(tokio::spawn(async {
std::future::pending::<()>().await; std::future::pending::<()>().await;
DataUsagePersistOutcome::Saved DataUsagePersistOutcome::Saved
}); }));
let result = wait_for_data_usage_persist_task(&ctx, &mut task, Duration::from_secs(30)).await; let result = wait_for_data_usage_persist_task(&ctx, &mut task, Duration::from_secs(30)).await;
+5
View File
@@ -58,11 +58,16 @@ url = { workspace = true, optional = true }
zstd = { workspace = true, optional = true } zstd = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true, features = ["html_reports"] }
tempfile = { workspace = true } tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] } tokio = { workspace = true, features = ["macros", "rt"] }
temp-env = { workspace = true } temp-env = { workspace = true }
proptest = "1" proptest = "1"
[[bench]]
name = "hash_hotpath_benchmark"
harness = false
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { workspace = true, optional = true, features = ["Win32_Storage_FileSystem"] } windows = { workspace = true, optional = true, features = ["Win32_Storage_FileSystem"] }
@@ -0,0 +1,55 @@
// 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::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use rustfs_utils::HashAlgorithm;
use std::hint::black_box;
fn generate_payload(size: usize) -> Vec<u8> {
(0..size)
.map(|i| u8::try_from(i % 251).expect("modulo output fits in u8"))
.collect()
}
fn bench_hash_hotpaths(c: &mut Criterion) {
let payloads = [
("64KiB", generate_payload(64 * 1024)),
("1MiB", generate_payload(1024 * 1024)),
];
let algorithms = [
("md5", HashAlgorithm::Md5),
("sha256", HashAlgorithm::SHA256),
("highwayhash256s", HashAlgorithm::HighwayHash256S),
("highwayhash256s_legacy", HashAlgorithm::HighwayHash256SLegacy),
];
let mut group = c.benchmark_group("hash_hotpath");
for (payload_name, payload) in &payloads {
let payload_len = u64::try_from(payload.len()).expect("benchmark payload length fits in u64");
group.throughput(Throughput::Bytes(payload_len));
for (algo_name, algorithm) in &algorithms {
let algorithm = algorithm.clone();
group.bench_with_input(BenchmarkId::new(*algo_name, payload_name), payload.as_slice(), move |b, payload| {
b.iter(|| {
let hash = algorithm.hash_encode(black_box(payload));
black_box(hash.as_ref()[0]);
});
});
}
}
group.finish();
}
criterion_group!(benches, bench_hash_hotpaths);
criterion_main!(benches);
+20 -12
View File
@@ -29,14 +29,24 @@ const LEGACY_HIGHWAY_HASH256_KEY: [u8; 32] = [
3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
]; ];
fn highway_key_from_bytes(bytes: &[u8; 32]) -> [u64; 4] { const fn highway_key_from_bytes(bytes: &[u8; 32]) -> [u64; 4] {
let mut key = [0u64; 4]; [
for (i, chunk) in bytes.chunks_exact(8).enumerate() { u64::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]]),
key[i] = u64::from_le_bytes(chunk.try_into().unwrap()); u64::from_le_bytes([
} bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
key ]),
u64::from_le_bytes([
bytes[16], bytes[17], bytes[18], bytes[19], bytes[20], bytes[21], bytes[22], bytes[23],
]),
u64::from_le_bytes([
bytes[24], bytes[25], bytes[26], bytes[27], bytes[28], bytes[29], bytes[30], bytes[31],
]),
]
} }
const MAGIC_HIGHWAY_HASH256_PARSED_KEY: Key = Key(highway_key_from_bytes(&MAGIC_HIGHWAY_HASH256_KEY));
const LEGACY_HIGHWAY_HASH256_PARSED_KEY: Key = Key(highway_key_from_bytes(&LEGACY_HIGHWAY_HASH256_KEY));
#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone, Eq, Hash)] #[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone, Eq, Hash)]
/// Supported hash algorithms for bitrot protection. /// Supported hash algorithms for bitrot protection.
pub enum HashAlgorithm { pub enum HashAlgorithm {
@@ -100,25 +110,23 @@ impl HashAlgorithm {
/// # Returns /// # Returns
/// A byte slice containing the hash of the input data /// A byte slice containing the hash of the input data
/// ///
#[inline]
pub fn hash_encode(&self, data: &[u8]) -> impl AsRef<[u8]> { pub fn hash_encode(&self, data: &[u8]) -> impl AsRef<[u8]> {
match self { match self {
HashAlgorithm::Md5 => HashEncoded::Md5(Md5::digest(data).into()), HashAlgorithm::Md5 => HashEncoded::Md5(Md5::digest(data).into()),
HashAlgorithm::HighwayHash256 => { HashAlgorithm::HighwayHash256 => {
let key = Key(highway_key_from_bytes(&MAGIC_HIGHWAY_HASH256_KEY)); let mut hasher = HighwayHasher::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
let mut hasher = HighwayHasher::new(key);
hasher.append(data); hasher.append(data);
HashEncoded::HighwayHash256(u8x32_from_u64x4(hasher.finalize256())) HashEncoded::HighwayHash256(u8x32_from_u64x4(hasher.finalize256()))
} }
HashAlgorithm::SHA256 => HashEncoded::Sha256(Sha256::digest(data).into()), HashAlgorithm::SHA256 => HashEncoded::Sha256(Sha256::digest(data).into()),
HashAlgorithm::HighwayHash256S => { HashAlgorithm::HighwayHash256S => {
let key = Key(highway_key_from_bytes(&MAGIC_HIGHWAY_HASH256_KEY)); let mut hasher = HighwayHasher::new(MAGIC_HIGHWAY_HASH256_PARSED_KEY);
let mut hasher = HighwayHasher::new(key);
hasher.append(data); hasher.append(data);
HashEncoded::HighwayHash256S(u8x32_from_u64x4(hasher.finalize256())) HashEncoded::HighwayHash256S(u8x32_from_u64x4(hasher.finalize256()))
} }
HashAlgorithm::HighwayHash256SLegacy => { HashAlgorithm::HighwayHash256SLegacy => {
let key = Key(highway_key_from_bytes(&LEGACY_HIGHWAY_HASH256_KEY)); let mut hasher = HighwayHasher::new(LEGACY_HIGHWAY_HASH256_PARSED_KEY);
let mut hasher = HighwayHasher::new(key);
hasher.append(data); hasher.append(data);
HashEncoded::HighwayHash256SLegacy(u8x32_from_u64x4(hasher.finalize256())) HashEncoded::HighwayHash256SLegacy(u8x32_from_u64x4(hasher.finalize256()))
} }
+1
View File
@@ -37,6 +37,7 @@ pub const SUFFIX_CRC: &str = "crc";
pub const SUFFIX_TRANSITION_STATUS: &str = "transition-status"; pub const SUFFIX_TRANSITION_STATUS: &str = "transition-status";
pub const SUFFIX_TRANSITIONED_OBJECTNAME: &str = "transitioned-object"; pub const SUFFIX_TRANSITIONED_OBJECTNAME: &str = "transitioned-object";
pub const SUFFIX_TRANSITIONED_VERSION_ID: &str = "transitioned-versionID"; pub const SUFFIX_TRANSITIONED_VERSION_ID: &str = "transitioned-versionID";
pub const SUFFIX_TRANSITIONED_VERSION_STATE: &str = "transitioned-version-state";
pub const SUFFIX_TRANSITION_TIER: &str = "transition-tier"; pub const SUFFIX_TRANSITION_TIER: &str = "transition-tier";
pub const SUFFIX_TRANSITION_TIER_DESTINATION_ID: &str = "transition-tier-destination-id"; pub const SUFFIX_TRANSITION_TIER_DESTINATION_ID: &str = "transition-tier-destination-id";
pub const SUFFIX_RESTORE_OPERATION_ID: &str = "restore-operation-id"; pub const SUFFIX_RESTORE_OPERATION_ID: &str = "restore-operation-id";

Some files were not shown because too many files have changed in this diff Show More