mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-28 15:02:49 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c232f1552d | |||
| 7581a70412 | |||
| 8cb9296f20 | |||
| b1ba6cdfdc | |||
| 0bfc7612d4 | |||
| 4c997519e8 | |||
| 95c5d108d1 | |||
| f6ad79dd58 | |||
| 82947d3595 | |||
| aef67b6499 | |||
| 8e88419f3a | |||
| 0197a4b538 | |||
| 7a3bf8cdbd | |||
| 3d3957a248 | |||
| b4026b43b2 | |||
| 851c295753 | |||
| f879946ead | |||
| d88f524d9f | |||
| 54ac37ec0a | |||
| 2b56666123 | |||
| a24d305665 | |||
| c47c8f9f74 | |||
| 3a00e9bd42 | |||
| c0c60dd91d | |||
| b5c253f3ed | |||
| 63d97f0877 | |||
| 4f04338ee1 |
@@ -27,7 +27,4 @@ Risk and review shape:
|
||||
and list verified and unverified target classes in the PR Impact section.
|
||||
|
||||
Available domain lenses are security, concurrency/durability, compatibility,
|
||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
||||
explicit adversarial request, a high-risk change, or a substantial PR review;
|
||||
then read only its matching role references. A routine standard pass does not
|
||||
load the playbook unless the reviewer needs a RustFS-specific probe.
|
||||
and performance; select only those matched by the change.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: adversarial-validation
|
||||
description: Review RustFS diffs or designs for explicit adversarial requests, high-risk changes under the repository review policy, or substantial PR reviews. Skip ordinary questions, diagnosis, planning, status, routine low-risk implementation, and prose with no execution effect.
|
||||
---
|
||||
|
||||
# RustFS Adversarial Validation
|
||||
|
||||
Use the [repository risk tiers and review shape](../../references/adversarial-validation.md). This skill
|
||||
routes a review to RustFS-specific probes without loading unrelated domains.
|
||||
|
||||
## Select Lenses
|
||||
|
||||
Read only the references required by the diff:
|
||||
|
||||
| Lens | When to read |
|
||||
|---|---|
|
||||
| [Correctness](references/correctness.md) | Every non-exempt adversarial review |
|
||||
| [Simplicity](references/simplicity.md) | Mechanical/standard changes and production growth |
|
||||
| [Test coverage](references/test-coverage.md) | Behavior or test changes |
|
||||
| [Security](references/security.md) | Authn/authz, IAM, RPC trust, paths, secrets, parsing, browser, encryption |
|
||||
| [Concurrency/durability](references/concurrency-durability.md) | Async shared state, locks, storage commit, cancellation, persisted queues |
|
||||
| [Compatibility](references/compatibility.md) | S3 surface, MinIO interop, metadata, wire/disk formats, mixed versions |
|
||||
| [Performance](references/performance.md) | Request/object hot paths, allocation, blocking work, fsync, fan-out |
|
||||
|
||||
Do not read all references as a precaution. A path name alone is insufficient;
|
||||
the changed behavior must touch the lens's domain.
|
||||
|
||||
For a dedicated security audit or advisory analysis, use
|
||||
`security-advisory-lessons` instead of loading it automatically during every
|
||||
adversarial review.
|
||||
|
||||
## Review Protocol
|
||||
|
||||
1. Freeze the exact final diff/head (or the design under review) and list the
|
||||
selected lenses.
|
||||
2. Run the review shape required by the repository risk tier.
|
||||
3. For each selected lens, either report a concrete finding or a null verdict
|
||||
naming the attacks performed.
|
||||
4. Apply root `AGENTS.md`'s finding standard. Test each candidate against callers,
|
||||
existing coverage, and invariants before accepting it; an adversarial role
|
||||
does not have to produce a defect.
|
||||
5. Fix or rebut supported findings with code-path, test, or invariant evidence.
|
||||
6. After a non-trivial edit, rerun only lenses affected by that edit against the
|
||||
new exact diff.
|
||||
|
||||
Do not turn a null verdict into a long checklist. Record concise evidence that
|
||||
the relevant failure classes were attacked, then stop under the root completion
|
||||
rule. Keep the required per-lens verdicts for high-risk PRs.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Compatibility Lens
|
||||
|
||||
- Internal metadata uses `metadata_compat` helpers for dual RustFS/MinIO keys,
|
||||
including mixed casing and removal of both twins.
|
||||
- Binary UUID metadata treats absent, empty, and nil as no value. Unversioned
|
||||
remote tiers receive no `versionId`; versioned purge requests retain the real
|
||||
version ID.
|
||||
- `xl.meta` changes preserve supported header/meta versions, recompute
|
||||
signatures, decode legacy fixtures, and remain readable by old RustFS/MinIO.
|
||||
- Foreign/corrupt metadata validates parallel array lengths and missing fields;
|
||||
it returns a decode error rather than indexing, panicking, or fabricating data.
|
||||
- Do not “correct” byte-for-byte MinIO ports without legacy fixture evidence.
|
||||
Bitrot framing, shard math, distribution, and inline prefixes are contracts.
|
||||
- Client-visible metadata/events strip both internal prefixes
|
||||
case-insensitively.
|
||||
- Proto fields are appended, never reused/renumbered; FlatBuffers tables extend
|
||||
compatibly and absent new fields fail closed where authorization/quorum is
|
||||
involved.
|
||||
- Replay real client request shapes and exact pagination boundaries for S3
|
||||
handler changes.
|
||||
- Bucket metadata/IAM/config parsing remains compatible with pinned real MinIO
|
||||
fixtures and encrypted migration data.
|
||||
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
|
||||
condition, and default toward reading old data safely.
|
||||
|
||||
## Outbound targets
|
||||
|
||||
- A change to what the replication or migration client sends by default
|
||||
(checksum policy, payload framing, headers, version-id addressing) is judged
|
||||
against every target class, not the one it fixes. Name each target-side rule
|
||||
the current default satisfies — checksum required with Object Lock
|
||||
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
|
||||
MD5 — and show which cell of
|
||||
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
|
||||
- A test that asserts the fix ("no trailer header") is not evidence; the
|
||||
matrix cell that asserts the target accepted and stored the object is.
|
||||
- Every new environment escape hatch appears in
|
||||
`docs/operations/replication-outbound-transport.md` in the same diff.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Concurrency and Durability Lens
|
||||
|
||||
- For every changed lock, enumerate overlapping lock sets and construct the
|
||||
ABBA interleaving. Multiple-lock order must be documented and consistent.
|
||||
- Mark guard lifetimes and every `.await`, disk,
|
||||
and RPC call; estimate contention/timeouts. Tokio read/write guards across
|
||||
`.await` need bounded hold time; long-lived reads can wedge writers (#4195).
|
||||
Keep `std::sync::Mutex` holds brief and never across `.await`.
|
||||
- Use direct `fetch_*` for unconditional atomic read-modify-write and
|
||||
`compare_exchange` for conditional updates.
|
||||
- Object commits remain fenced if the distributed lock is lost after shard
|
||||
writes and before metadata rename.
|
||||
- For write/rename changes, trace `write tmp -> sync tmp -> rename -> sync parent
|
||||
-> sync required ancestors`; simulate a crash after each step and honor the
|
||||
configured durability gate.
|
||||
- Multi-disk fan-out counts every result. Quorum-minus-one cannot become success;
|
||||
heal remains best-effort per target where that is the established contract.
|
||||
- At every new cancellable await between mutation and cleanup/commit, drop the
|
||||
future and inspect leftover files, counters, permits, and replay state.
|
||||
- Multipart operations on the same upload ID are serialized where required;
|
||||
abort/complete/list races cannot delete parts before durable commit.
|
||||
- Post-commit cleanup is best-effort, retry-safe, and cannot fail an already
|
||||
committed write or delete the last surviving copy.
|
||||
- Persisted read-modify-write uses serialization/CAS. Queue replay is crash-safe
|
||||
and duplicate delivery has an idempotency contract.
|
||||
- Streaming reconstruction failures after partial output surface as errors, not
|
||||
successful EOF.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Correctness Lens
|
||||
|
||||
Attack the changed behavior, not every subsystem in the repository.
|
||||
|
||||
- Trace new error paths to the caller. Inject the ignored/wildcard variants and
|
||||
verify they cannot become success, not-found, or a plausible default.
|
||||
- Exercise zero/empty/missing, maximum, and exact-boundary inputs for every
|
||||
changed count, size, index, page limit, or optional value.
|
||||
- For aggregation/quorum changes, test exactly quorum and quorum-minus-one with
|
||||
mixed disk errors and nil/placeholder entries.
|
||||
- For listing/pagination, test `n == max`, `n == max + 1`, delimiter folding,
|
||||
continuation markers, and object/prefix name collisions.
|
||||
- For EC/read/streaming changes, inject failure after partial output and verify
|
||||
the client receives an error rather than a clean truncated body. Assert exact
|
||||
bytes and length.
|
||||
- For multipart/object commits, fail before/after rename and cleanup; committed
|
||||
data must remain readable and pre-commit cleanup must not destroy parts.
|
||||
- Bound recursion over untrusted or persisted input; handle corrupted/cyclic
|
||||
tree and cache traversals safely.
|
||||
- For version/index ordering, test `len - 1`, `len`, equal timestamps, missing
|
||||
versions, and deterministic tie-breaking.
|
||||
- For directory-object behavior, trace `__XLDIR__` at the store layer; branches
|
||||
below the layer that sees trailing slashes are dead.
|
||||
- For binary UUID metadata, absent, empty, and nil all mean no value. Never send
|
||||
nil/empty `versionId` to an unversioned tier.
|
||||
- For agent rules/skill routers, test a trigger matrix covering ordinary
|
||||
inquiry, low-risk implementation, explicit review, high-risk code, PR
|
||||
creation, release, and post-PR monitoring. Each case must select only the
|
||||
intended workflow and retain required safety/authorization boundaries.
|
||||
|
||||
Null verdicts name only the probes relevant to the diff.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Performance Lens
|
||||
|
||||
- For added clones/allocations on request/object/block paths, quantify copied
|
||||
data and frequency. Recommend borrowing, move, `Bytes`/`Arc`, `Cow`, or
|
||||
capacity reservation only for a concrete repeated cost.
|
||||
- Route every new sync/flush through the durability-mode and bucket override
|
||||
gates; mode `none` must not pay the new fsync.
|
||||
- Keep blocking filesystem/CPU work off async runtime threads, but do not split
|
||||
one small operation into many `spawn_blocking` round trips; coalesce `open`,
|
||||
`read`/`write`, `fsync`, and `close` into one blocking closure per disk.
|
||||
- Check for per-disk syscall amplification (`disks × parts/files`): pin a
|
||||
directory once (`openat2` on Linux) and reuse the handle across sibling reads
|
||||
instead of re-walking ancestor components per file or re-opening identical
|
||||
`source_parent == destination_parent` directories.
|
||||
- After `fstat`/`file.metadata()`, require `Vec::with_capacity(len + 1)` before
|
||||
`read_to_end` so small metadata reads do not trigger 6–7 exponential-growth
|
||||
`read()` syscalls.
|
||||
- Avoid redundant `store.load()` or `root_missing()` round-trips under the same
|
||||
exclusive lock when the state or original bytes were just loaded or persisted.
|
||||
- Measure lock hold time across I/O and compare acquisition order for ABBA.
|
||||
- Keep cleanup, extra stat/rename, and diagnostics out of the PUT commit critical
|
||||
section when they need not be there.
|
||||
- Detect per-item serial I/O/RPC in batch APIs and accidental quadratic scans;
|
||||
use a gate or bounded concurrency when the concrete fan-out warrants it.
|
||||
- Count buffer growth and byte copies in EC/bitrot paths; preserve pool gauge
|
||||
balance and avoid repeated metadata decode/fetch per object.
|
||||
- Repetitive success logs stay at `trace`; metrics/instrumentation on hot paths
|
||||
require an existing gate.
|
||||
- Claims of no impact on PUT/GET/commit/erasure paths need relevant benchmark or
|
||||
A/B evidence, especially for 4 KiB objects.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Security Lens
|
||||
|
||||
Use `security-advisory-lessons` only for a dedicated advisory/security audit.
|
||||
For an ordinary matched diff, attack these boundaries:
|
||||
|
||||
- Admin routes: route registration, whitelist, handler authn, and the exact
|
||||
`AdminAction` must agree. Read-only diagnostics still require admin authz.
|
||||
- IAM/service accounts: treat parent, claims, keys, groups, status, and policy
|
||||
names as attacker-controlled; prove ownership/root authority before writes.
|
||||
- Protocol frontends: every changed/sibling command authorizes the matching S3
|
||||
action before reaching storage.
|
||||
- Secrets/signatures: use constant-time comparison, normalize public failures,
|
||||
keep RPC/root/STS keys independent, and fail closed when secrets are absent.
|
||||
- RPC: bind signatures to the exact method/path and timestamp; reject replay,
|
||||
stale, malformed, truncated, and invalid-enum payloads without panic.
|
||||
- Paths/object/archive entries: reject traversal, absolute/platform escapes,
|
||||
and normalization differences between authz and storage.
|
||||
- Copy/multipart/presigned POST: enforce source, destination, version-aware
|
||||
actions, copy-source conditions, and every signed policy condition.
|
||||
- Logging/errors: never expose credentials, tokens, expected signatures, raw
|
||||
secret-bearing input, or merged configs—including via `Debug` and parse errors.
|
||||
- Untrusted serde: reject unknown fields where compatible and validate
|
||||
security-critical defaults/ranges before numeric conversion.
|
||||
- SSE/browser/CORS/trusted proxy: inspect stored ciphertext and wrapper order;
|
||||
isolate user content; never reflect credentialed arbitrary origins or trust
|
||||
forwarded identity from direct clients.
|
||||
- Object Lock: unreadable/fabricated/unparsable metadata fails closed across
|
||||
foreground, lifecycle, scanner, and force-delete paths.
|
||||
|
||||
Security findings distinguish unauthenticated compromise from a
|
||||
low-privileged authenticated bypass.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Simplicity Lens
|
||||
|
||||
- Compare the production diff with the smallest equivalent local edit. Fewer
|
||||
lines alone are not evidence; the replacement must preserve correctness,
|
||||
compatibility, readability, and real boundaries.
|
||||
- Search the touched crate, domain owner, `crates/utils`, `crates/common`, and
|
||||
relevant dependencies for each new helper, constant, wrapper, or fixture.
|
||||
- Reject forced reuse when normalization, error, deadline, or durability
|
||||
semantics differ.
|
||||
- Require a concrete trigger for every new defensive branch. Keep boundary
|
||||
checks for disk/RPC/version data and checks immediately before destructive
|
||||
actions.
|
||||
- Flag one-caller helpers only when they merely forward or split a short linear
|
||||
flow without adding domain naming, invariant isolation, or useful context.
|
||||
- Ensure a replacement removes the superseded in-scope path or keeps one
|
||||
canonical core behind a documented compatibility adapter.
|
||||
- Remove narration/change-history comments; preserve concise safety, lock,
|
||||
durability, and compatibility invariants.
|
||||
- Treat tests, fixtures, generated code, and documentation separately from
|
||||
production growth. Do not optimize away meaningful regression coverage.
|
||||
|
||||
A finding must include a concrete smaller design, not a style preference.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Test-Coverage Lens
|
||||
|
||||
- For every behavior claim, name the focused test/check that fails if the
|
||||
changed hunk is reverted. If none is practical, require the reason and
|
||||
residual risk.
|
||||
- Confirm tests exercise the real production path and distinguish the intended
|
||||
behavior from the named regression. A success, `is_err()`, or no-panic check
|
||||
can be sufficient when that is the actual contract; require exact values,
|
||||
bytes, state, or error variants when those distinctions matter to the change.
|
||||
- For new flags/modes, verify each branch and ask which test fails if the branch
|
||||
is inverted.
|
||||
- For new error propagation, inject the failure and assert the caller observes
|
||||
it; mentally replacing `?`/`return Err` with success must break a test.
|
||||
- Streaming GET tests assert the complete body and length under degraded reads.
|
||||
- Disk/wire-format tests use pinned foreign/legacy fixtures; same-code
|
||||
round-trips are insufficient for compatibility.
|
||||
- Concurrency tests use readiness polling, isolate global state, and avoid fixed
|
||||
sleeps or unrealistically short timeouts. Use nextest groups when process-level
|
||||
serialization is required.
|
||||
- Internal metadata tests assert both RustFS and MinIO keys, not only read-back
|
||||
through a helper that prefers one key.
|
||||
- Avoid duplicate tests of the same production path and poison-value class.
|
||||
Boundary companions are distinct coverage: `n == max` vs `max + 1`, and
|
||||
absent vs empty vs nil UUID.
|
||||
- A focused test proves only the targets/features it builds. Add compilation or
|
||||
Clippy only for uncovered changed targets.
|
||||
@@ -13,7 +13,7 @@ description: Review a GitHub PR using its exact base/head and repository risk po
|
||||
```
|
||||
|
||||
Record both SHAs; refresh if either moved during fetching. Follow relevant [implementation boundaries](../../references/implementation.md).
|
||||
2. Apply the [risk policy](../../references/adversarial-validation.md). Ordinary reviews use the root finding standard directly. For explicit adversarial, substantial, or high-risk reviews, use [the domain probes](../adversarial-validation/SKILL.md). File count does not authorize delegation; use exactly two independent reviewers only when authorized, otherwise two fresh sequential passes for that risk tier. Reviewers do not spawn further agents.
|
||||
2. Apply the [risk policy](../../references/adversarial-validation.md). Ordinary reviews use the root finding standard directly. File count does not authorize delegation; use exactly two independent reviewers only when authorized, otherwise two fresh sequential passes for that risk tier. Reviewers do not spawn further agents.
|
||||
3. Check `gh pr checks <N> --repo <owner/repo>`. Investigate failures when relevant to a finding or a requested CI/readiness diagnosis. Distinguish pending, unavailable, pre-existing, flaky, and PR-caused states using evidence; a code review does not authorize unrelated repairs.
|
||||
4. Report the reviewed SHAs, risk tier, supported findings or `No findings`, material verification limits, check state, and verdict. P0/P1 findings block approval. Severity: P0 = demonstrated data loss, security breach, remote crash, or deadlock; P1 = correctness, compatibility, or material hot-path regression; P2 = a concrete maintainability defect; P3 = optional style, only when requested.
|
||||
5. When posting is authorized, follow [Git and PR rules](../../references/pull-requests.md), recheck the remote head, and review any delta before submitting. Use `gh pr review` with `--approve`, `--request-changes`, or `--comment` and `--body-file`; use [the API example](references/posting.md) for authorized inline comments. Reuse existing authorization.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
name: security-advisory-lessons
|
||||
description: Perform a dedicated RustFS security/advisory review for authn/authz, IAM, RPC trust, paths, secrets, browser isolation, encryption, Object Lock, or other security boundaries. Use only when the user requests a security/advisory review or an adversarial review explicitly escalates to the full advisory map; do not auto-load solely because code touches a sensitive path.
|
||||
---
|
||||
|
||||
# RustFS Security Advisory Lessons
|
||||
|
||||
Use this skill as the deep security lens. For a normal adversarial review with a
|
||||
matched security surface, the concise security reference under
|
||||
`adversarial-validation` is sufficient.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Freeze the exact diff/head and identify the changed trust boundaries.
|
||||
2. Inspect the headings in [advisory-patterns.md](references/advisory-patterns.md),
|
||||
then read the matching sections. Read the full map only for a broad security
|
||||
audit. Useful headings are
|
||||
auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths,
|
||||
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
|
||||
3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed,
|
||||
and default-config cases. Security decisions must fail closed.
|
||||
4. Require a focused negative regression test for the bypass/exploit form, not
|
||||
only the intended success path. State residual risk when a test is impractical.
|
||||
5. Report proven vulnerabilities separately from defense-in-depth hardening.
|
||||
|
||||
When advisory currentness matters, fetch the live inventory instead of treating
|
||||
the reference as a status mirror:
|
||||
|
||||
```bash
|
||||
gh api repos/rustfs/rustfs/security-advisories --paginate \
|
||||
--jq '.[] | {ghsa_id,state,severity,summary,updated_at}'
|
||||
```
|
||||
|
||||
Fetch an individual advisory only when the live summary indicates a new or
|
||||
changed lesson.
|
||||
|
||||
## Finding Standard
|
||||
|
||||
Every candidate names the lower-trust principal, the boundary it crosses, the
|
||||
affected resource, and the observable result. A best-practice gap or an impact
|
||||
confined to the caller's own principal is hardening, not a candidate.
|
||||
|
||||
Report each candidate under exactly one verdict:
|
||||
|
||||
- `confirmed`: a complete `file:line` source trace and a bounded observed
|
||||
result. Only confirmed findings carry severity; each also states attacker
|
||||
prerequisites, concrete input/path, impact, smallest safe fix, and a
|
||||
regression check.
|
||||
- `needs_validation`: the trace is blocked on one exact unresolved fact, such
|
||||
as deployment or infrastructure behavior. State that fact; assign no severity
|
||||
and do not substitute an assumption.
|
||||
- `rejected`: a disproved candidate, with the caller, invariant, or test that
|
||||
disproves it.
|
||||
|
||||
Do not exaggerate unauthenticated impact when the actual issue requires
|
||||
authenticated low privilege.
|
||||
@@ -1,158 +0,0 @@
|
||||
# RustFS Advisory Pattern Map
|
||||
|
||||
This file is a lesson map, not an advisory inventory mirror. It keeps durable security patterns distilled from RustFS GitHub Security Advisories.
|
||||
|
||||
When current advisory state, severity, URLs, or full text matters, fetch it live:
|
||||
|
||||
```bash
|
||||
gh api repos/rustfs/rustfs/security-advisories --paginate \
|
||||
--jq '.[] | {ghsa_id,state,severity,summary,updated_at}'
|
||||
gh api repos/rustfs/rustfs/security-advisories/<GHSA_ID>
|
||||
```
|
||||
|
||||
Update this file only when an advisory adds or changes a reusable lesson, affected surface, validation pattern, or regression-test expectation. Do not update it for state-only, URL-only, count-only, or timestamp-only changes.
|
||||
|
||||
## Pattern Index
|
||||
|
||||
### Admin authorization and route exposure
|
||||
|
||||
- `GHSA-pfcq-4gjr-6gjm`: notification target endpoints accepted authenticated users but skipped admin authorization. Lesson: distinguish authn from authz; admin target CRUD must call the operation-specific admin authorization path.
|
||||
- `GHSA-mm2q-qcmx-gw4w`: `ListServiceAccount` used `UpdateServiceAccountAdminAction`, while update lacked target ownership checks. Lesson: exact action constants and ownership checks are both required; information disclosure can chain into secret rotation and takeover.
|
||||
- `GHSA-vcwh-pff9-64cc`: `ImportIam` checked `ExportIAMAction` for an import/write operation. Lesson: every admin handler must authorize the action it actually performs.
|
||||
- `GHSA-jqmc-mg33-v45g` and `GHSA-8784-9m7f-c6p6`: `/profile/cpu` and `/profile/memory` were whitelisted from auth and allowed expensive diagnostics plus path disclosure. Lesson: profiling/debug endpoints need admin auth, opt-in, rate limits, and non-sensitive responses.
|
||||
- `GHSA-f5cv-v44x-2xgf`: `/rustfs/admin/v3/metrics` accepted any authenticated IAM user and skipped admin authorization. Lesson: read-only metrics and diagnostic admin endpoints still require an operation-specific admin action check.
|
||||
- `GHSA-796f-j7xp-hwf4`: `/rustfs/admin/v3/list-remote-targets` checked only that credentials existed and leaked replication target credentials. Lesson: replication target reads are privileged admin operations, and stored remote credentials require strict authz plus response redaction review.
|
||||
- `GHSA-xp32-gxq2-3v52`: console license metadata endpoint was public and exposed subject and expiration fields. Lesson: management metadata endpoints should require admin auth or return only coarse public status.
|
||||
|
||||
### IAM import, service accounts, and privilege boundaries
|
||||
|
||||
- `GHSA-566f-q62r-wcr8`: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
|
||||
- `GHSA-3495-h8r9-gfqg`: `ExportIAM` wrote regular-user and service-account secret keys into exported ZIP data. Lesson: IAM export is a credential-disclosure boundary; redact, seal, or strictly justify every exported secret before treating export permission as safe.
|
||||
- `GHSA-5354-r3w2-34m8`: `AddServiceAccount` checked `CreateServiceAccountAdminAction` but trusted caller-supplied `target_user`, allowing service accounts under the root parent. Lesson: service-account create paths must validate parent ownership or root/admin authority, not only the create action.
|
||||
- `GHSA-xgr5-qc6w-vcg9`: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
|
||||
- `GHSA-mm2q-qcmx-gw4w`: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
|
||||
|
||||
### STS, OIDC, and federation flows
|
||||
|
||||
- `GHSA-5qfg-mf7r-jp3w` and `GHSA-3473-5353-xhwh`: `AssumeRoleWithWebIdentity` was reachable through unauthenticated `POST /` routing and could issue temporary credentials from crafted web identity input. Lesson: every STS route needs explicit SigV4 or trusted identity-provider validation before role assumption, and unauthenticated exemptions must be narrowed to the exact action with uniform failure responses.
|
||||
- `GHSA-jxrr-r6pv-h958`: unsigned JWT issuer data was decoded before verification to select an OIDC provider, and distinguishable failures could expose provider configuration. Lesson: web-identity routing may be unauthenticated, but pre-verification claims are untrusted routing hints; bound and rate-limit the request, normalize public errors, and verify signature, issuer, audience, and expiration before issuing credentials.
|
||||
- `GHSA-ccrv-v8v9-ch9q`, `GHSA-48rf-7j3q-3hfv`, and `GHSA-xvfh-7c9g-hpw2`: service-account-controlled material could self-sign JWT session tokens with forged policy claims, and missing `exp` was accepted for service-account tokens. Lesson: session tokens must be signed by a trusted issuer/key path, enforce required claims and expiration, and reject self-signed or principal-controlled tokens.
|
||||
- `GHSA-9pjf-w3c2-m32r`, `GHSA-4x2q-cpx9-9h26`, and `GHSA-xvpm-p3f7-34c3`: public OIDC authorize/callback flows trusted request `Host` or forwarded scheme when building credential-bearing redirects. Lesson: OIDC redirects must use configured allowlisted origins and trusted-proxy handling; never derive the post-login credential destination from direct client headers.
|
||||
- `GHSA-m479-9x88-94w6`, `GHSA-frwq-mfqx-83p8`, `GHSA-q9q8-rf9r-fg9f`, and `GHSA-j5c2-hhf7-6gf5`: OIDC validation accepted attacker-controlled discovery URLs because hostname checks rejected only literal forbidden IPs, allowing DNS rebinding SSRF. Lesson: outbound federation URL validation must resolve and classify hostnames at the connection boundary and reject loopback, private, link-local, and rebound addresses.
|
||||
|
||||
### IAM policy conditions and external policy plugins
|
||||
|
||||
- `GHSA-6r96-hmgc-726c`: request headers collided with lowercase server-derived condition keys such as `userid`, `groups`, `versionid`, and JWT/LDAP claims. Lesson: never let caller-controlled headers append to or replace server-derived policy context; reserve trusted condition keys and keep intentional request-header keys separate.
|
||||
- `GHSA-v9cp-qfw9-9pfp`: quantified negated string conditions applied negation after aggregation, transposing `ForAllValues` and `ForAnyValue` semantics. Lesson: push negation into the per-value predicate for quantified operators and test partially overlapping multi-value sets.
|
||||
- `GHSA-5w8r-p896-6vq2`: OPA policy mode skipped `ExistingObjectTag/*` loading, so tagged objects looked untagged to external policies. Lesson: external authorization plugins need the same object-tag and request context as built-in policy evaluation before they decide.
|
||||
- `GHSA-9wgh-5r68-49p7`: substituted policy-variable values could recreate `${...}` and loop inside a single resolution pass, never reaching the outer iteration limit. Lesson: bound substitutions within each pass and test cycles formed across otherwise literal resolver values; confirm whether attacker-controlled claims can reach those values before assigning remote impact.
|
||||
|
||||
### S3 object actions, copy, multipart, and upload policy validation
|
||||
|
||||
- `GHSA-g8w9-qw9q-fghr` and `GHSA-xm99-m3gq-83g8`: a valid presigned `PutObject` accepted extra `x-amz-*` headers omitted from `SignedHeaders`; `x-amz-copy-source` could turn that upload capability into a cross-bucket read performed as the signer. Lesson: a presigned URL is a bounded capability; inspect all received security-sensitive headers and reject unsigned ones before selecting the S3 operation or reaching storage.
|
||||
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
|
||||
- `GHSA-cg33-pxfp-qcj3` and `GHSA-65wq-gjw8-xr9w`: `HeadObject` and object-tagging paths authorized current-object actions while operating on an explicit historical `versionId`. Lesson: select the corresponding version action for metadata and tag reads and writes too; audit every operation that accepts a version ID.
|
||||
- `GHSA-gjph-xcgw-4p7w`: `PutObject` and browser POST accepted a caller-supplied `versionId` and replaced an unlocked historical version with only `s3:PutObject`. Lesson: only replication-authorized writes may choose an existing version ID; ordinary uploads must not rewrite version history.
|
||||
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
|
||||
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
||||
- `GHSA-wfxj-ph3v-7mjf`: `UploadPartCopy` checked source and destination independently but missed destination copy-source policy constraints. Lesson: source read and destination write checks are not sufficient when policy constrains allowed copy sources.
|
||||
- `GHSA-w5fh-f8xh-5x3p`: presigned POST accepted uploads without enforcing signed policy conditions. Lesson: parse and enforce all POST policy constraints server-side, including size, key prefix, and content type.
|
||||
|
||||
### Protocol frontends and IAM parity
|
||||
|
||||
- `GHSA-3g29-xff2-92vp`: FTP `RETR` and `SIZE`/`MDTM` read paths authenticated the user but skipped IAM before calling storage. Lesson: non-HTTP protocol frontends must enforce the same per-operation authorization as the S3 API before backend access.
|
||||
- `GHSA-g3vq-vv42-f647`: FTPS `MKD` called `create_bucket` without checking `s3:CreateBucket`. Lesson: protocol command handlers need action-specific checks even when sibling handlers already authorize correctly.
|
||||
- `GHSA-3p3x-734c-h5vx`: FTPS and WebDAV compared secret keys with early-return string equality, while FTPS also returned distinguishable invalid-user and invalid-password failures. Lesson: password-style protocol auth needs constant-time secret comparison, indistinguishable failures where practical, and rate limiting.
|
||||
- `GHSA-rhg6-jrf2-29c4`: WebDAV, FTP/FTPS, and SFTP used identity-policy checks with empty conditions and bypassed S3 bucket-policy explicit denies and condition-key denies. Lesson: alternate listeners must evaluate the same effective policy and trustworthy request context before storage access.
|
||||
|
||||
### Filesystem paths and object key traversal
|
||||
|
||||
- `GHSA-pq29-69jg-9mxc`: RPC `read_file_stream` joined untrusted paths under a volume directory without canonical boundary checks. Lesson: `PathBuf::join` plus length checks are not path security.
|
||||
- `GHSA-8r6f-hmq2-28rg`: object keys containing traversal sequences bypassed bucket/object authorization when mapped to filesystem paths. Lesson: reject traversal at object-key parsing and verify final storage paths remain under the expected bucket/key root.
|
||||
- `GHSA-f4vq-9ffr-m8m3`: Snowball auto-extract accepted archive entries such as `../victim-bucket/object`, authorized the raw attacker-bucket path, then storage path cleaning crossed bucket boundaries. Lesson: archive entries become object keys and need traversal rejection plus consistent authz/storage normalization before writes.
|
||||
- `GHSA-g7fq-6xjm-mxf4`: versioned `DeleteObjects` accepted dot-segment keys, authorized the literal prefix, then wrote delete markers under a resolved sibling prefix. Lesson: validate each batch key before authorization and storage normalization, using the same object-name rules as single delete.
|
||||
|
||||
### Secrets, defaults, and cryptographic misuse
|
||||
|
||||
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, `GHSA-9gf3-jx4p-4xxf`, `GHSA-63xc-c3w3-m2cf`, and `GHSA-ch63-6q4v-hwp5`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
|
||||
- `GHSA-h956-rh7x-ppgj`: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
|
||||
- `GHSA-r5qv-rc46-hv8q`: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
|
||||
- `GHSA-75fx-qg6f-8rm7` and `GHSA-68cw-96m3-h2cf`: internode RPC secrets were derivable from known root credentials, making raw storage RPC signatures forgeable when explicit RPC secrets were unset. Lesson: RPC auth keys must be independent random secrets, never derived from S3 root credentials, and raw storage RPC should not share the public S3 listener without an internode-only boundary.
|
||||
- `GHSA-m77q-r63m-pj89`: STS JWTs used the root secret key as the shared token signing key, allowing token forgery when the root secret was known. Lesson: STS signing keys need key separation, rotation, and key IDs; do not reuse root credentials for JWT/HMAC signing.
|
||||
- `GHSA-923g-jp7v-f97f`: license verification embedded a production RSA private key and used private-key decryption as authenticity. Lesson: ship verifying/public keys only and use real signature verification.
|
||||
|
||||
### Sensitive logging and debug output
|
||||
|
||||
- `GHSA-r54g-49rx-98cr`: STS credentials were logged at info level. Lesson: generated credentials must never be logged in plaintext.
|
||||
- `GHSA-8cm2-h255-v749`: debug logs leaked session tokens, secret keys, JWT claims, and raw STS response bodies. Lesson: redaction must cover custom `Debug` implementations and dependency response-body logging.
|
||||
- `GHSA-333v-68xh-8mmq`: invalid RPC signature logging included the shared HMAC secret and expected signature. Lesson: error paths often leak secrets; never log raw secrets or derived authenticators.
|
||||
|
||||
### RPC input validation and panic safety
|
||||
|
||||
- `GHSA-gw2x-q739-qhcr`: malformed gRPC `GetMetrics` payloads reached `unwrap()` on deserialization and caused remote DoS. Lesson: every network/RPC deserialization failure returns an error, not a panic.
|
||||
- `GHSA-h956-rh7x-ppgj` and `GHSA-r5qv-rc46-hv8q`: weak RPC auth increased reachability of otherwise internal handlers. Lesson: panic bugs become more severe when internode auth is weak or defaulted.
|
||||
- `GHSA-c667-rgrv-99vj`: NodeService authentication signed the service prefix instead of the concrete generated method path, so valid metadata for one RPC could be replayed to another method during the timestamp window. Lesson: RPC HMAC payloads must bind exact gRPC method path, HTTP method surrogate, timestamp, and secret.
|
||||
|
||||
### Browser, CORS, and console isolation
|
||||
|
||||
- `GHSA-v9fg-3cr2-277j`: object preview rendered attacker-controlled HTML in a same-origin iframe, exposing console credentials stored in `localStorage`. Lesson: user content must be origin-isolated from the console and protected with `nosniff`, CSP, and strict content-type handling.
|
||||
- `GHSA-7gcx-wg4x-q9x6`: an incomplete preview fix reintroduced extension-based PDF detection and bypassed the sandboxed fallback for attacker-controlled content. Lesson: browser-surface fixes need regression tests for alternate viewers and file-type branches, and preview trust must come from validated content type plus sandboxing rather than object names.
|
||||
- `GHSA-x5xv-223c-8vm7`: default CORS reflected arbitrary origins with credentials. Lesson: never combine reflected origins with `Access-Control-Allow-Credentials: true`; default should be fail-closed.
|
||||
|
||||
### Trusted proxy and source IP conditions
|
||||
|
||||
- `GHSA-fc6g-2gcp-2qrq`: `aws:SourceIp` trusted client-supplied `X-Forwarded-For` or `X-Real-IP`. Lesson: forwarded IP headers are valid only behind configured trusted proxies; direct clients use socket peer IP.
|
||||
|
||||
### SSE and on-disk storage invariants
|
||||
|
||||
- `GHSA-xrrf-67jm-3c2r`: SSE metadata reported encryption while reader composition bypassed `EncryptReader` and stored plaintext. Lesson: test actual bytes on disk and wrapper order, not only API metadata.
|
||||
- `GHSA-wqmc-vjgv-jrpw`: SSE-C persisted and listed the plaintext MD5 as the ETag, allowing list-only users to confirm or brute-force low-entropy content and correlate equal plaintext across keys. Lesson: encrypted-object ETags must not expose deterministic plaintext fingerprints; protect them with per-object key material and keep listing, conditional, copy, and multipart semantics consistent.
|
||||
|
||||
### Object Lock and retention invariants
|
||||
|
||||
- `GHSA-j548-9grx-fh4f`: Object Lock enforcement treated unreadable, fabricated, or unparsable bucket metadata as absent configuration and allowed retained objects to be deleted or expired. Lesson: retention must fail closed unless Object Lock absence is authoritative, and every delete, lifecycle, scanner, force-delete, and default-retention path needs the same state distinction.
|
||||
- `GHSA-28w4-rp2p-636w` and `GHSA-c7hx-vr97-45rg`: an authorized `DeleteBucket` with `x-rustfs-force-delete: true` removed active COMPLIANCE-retained versions even though ordinary version deletion was denied. Lesson: bucket force-delete must enforce retention across every version, including against root/owner callers.
|
||||
- `GHSA-q23w-fp7p-472v`: `PutObjectRetention` policy conditions read `x-amz-object-lock-*` headers while the handler applied mode and date from the XML body. Lesson: authorize the parsed retention that will be stored, or reject mismatched header/body values; compare retention dates as instants so offsets cannot change the applied bound.
|
||||
|
||||
### Serde deserialization and input validation
|
||||
|
||||
- Reject unknown fields in untrusted S3 API XML/JSON, lifecycle, policy, and replication input where compatibility permits. Check the current type and supported payload fixtures; do not infer the repository's current coverage from an older audit. Where extra fields are part of the compatibility contract, validate security-critical values explicitly.
|
||||
- `#[serde(default)]` on security-critical fields silently accepts missing values as zero/empty. Lesson: when a field has security implications (retention days, permissions, limits), validate the deserialized value explicitly rather than relying on defaults.
|
||||
- Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` with a typed error, or clamp only when the domain explicitly requires saturation.
|
||||
- XML config typos (e.g., `"NoncurentDays"` instead of `"NoncurrentDays"`) are silently accepted when `deny_unknown_fields` is absent. Lesson: strict deserialization prevents silent misconfiguration that could cause data loss or unexpected retention behavior.
|
||||
|
||||
## Useful Search Seeds
|
||||
|
||||
Use these targeted searches when a diff touches security-sensitive code:
|
||||
|
||||
```bash
|
||||
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
|
||||
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
|
||||
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|presign|SignedHeaders|x-amz-copy-source|collect_signed_headers|content-length-range|starts-with" rustfs crates
|
||||
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
|
||||
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
|
||||
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
|
||||
rg -n "TONIC_RPC_PREFIX|verify_rpc_signature|check_auth|NodeServiceServer|x-rustfs-signature" rustfs crates
|
||||
rg -n "debug!|trace!|info!|error!|\\?resp|\\?merged_config|session_token|secret_key" rustfs crates
|
||||
rg -n "HashReader|EncryptReader|try_resolve_etag|ETag|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
|
||||
rg -n "ObjectLock|object_lock|retention|COMPLIANCE|GOVERNANCE|delete_prefix|lifecycle|scanner" rustfs crates
|
||||
rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
||||
```
|
||||
|
||||
## Minimum Regression Test Expectations
|
||||
|
||||
- Authz fixes: include unauthenticated, valid low-privilege, wrong-action, correct-action, owner, non-owner, and root/admin cases as applicable.
|
||||
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases; test bucket-policy explicit denies and condition-key denies on each enabled listener against an S3 control, and assert denied paths do not reach storage.
|
||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, storage-class, and cross-bucket copy-source headers; require rejection before source or destination storage access, and verify explicitly signed equivalents still work.
|
||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, multipart-copy source, `HeadObject`, and tag read/write cases.
|
||||
- Version-write fixes: a `PutObject`-only principal must not replace an existing version through `PutObject` or browser POST `?versionId=`; verify replication-authorized writes and versioned, suspended, and unversioned buckets separately.
|
||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, cyclic variable substitutions, plugin mode, and built-in policy mode.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks; test per-key `DeleteObjects` errors and prefix-scoped denials on versioned buckets.
|
||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
||||
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
|
||||
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
|
||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
|
||||
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions. For `PutObjectRetention`, test conflicting header/body values and offset dates against policy bounds and read-back retention.
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=a67a347f26be186c598b5beaf68e6bcbe1855350a35e1423dd5b8a81bcba06a5
|
||||
sha256-linux=ad4fabe811c6cca1aba89fad34ccd404b5832a6a955875c2826f6e0fa1ea4f1f
|
||||
sha256-darwin=67251cb7d9978d38e899268cac4d7898297ff7e5b5c48c9c1546dc760b93568d
|
||||
sha256-linux=673da76c2e5710c4622fe2fa32d2b297730895769487fcf1152ee1c2366f32f4
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=f065e7071aac9d48a7c1b301f328fa7cc14f11c4e7dc46da3e55057c4ea29bd5
|
||||
sha256=e9dc896774122a304e2b8b90f28c82d4d4e0af8ded36a82b147b30945ec994e3
|
||||
|
||||
@@ -128,7 +128,7 @@ setup = 'ecstore-large-stack'
|
||||
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
|
||||
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
|
||||
[[profile.default.scripts]]
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::plugin_attempt::v2_tests::delegation_v2_persists_and_vends_with_real_parent_intersection|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
@@ -419,7 +419,7 @@ setup = 'ecstore-large-stack'
|
||||
|
||||
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
|
||||
[[profile.ci.scripts]]
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::plugin_attempt::v2_tests::delegation_v2_persists_and_vends_with_real_parent_intersection|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
|
||||
@@ -16,7 +16,7 @@ Every other label in this repository is capacity RustFS pays for. ARC pods and l
|
||||
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
|
||||
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
|
||||
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
|
||||
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
|
||||
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | Docker workloads with a measured resource or environment requirement beyond the hosted runner |
|
||||
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
|
||||
|
||||
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
|
||||
@@ -27,11 +27,15 @@ A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason
|
||||
|
||||
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
|
||||
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
|
||||
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
|
||||
3. **Hardware or environment.** Native aarch64 or access to the lab machines. Docker alone is not a reason: hosted Ubuntu ships Docker and supports the QEMU and Buildx setup actions.
|
||||
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
|
||||
|
||||
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
|
||||
|
||||
The functional chain's `prepare` and `complete-chain` jobs, the performance runner availability check, and runner hygiene use `ubuntu-latest`: they inspect APIs or artifacts, not the lab hosts. The actual functional and performance suites remain on their lab runners. Functional-chain health collection stays on the operator-managed `smoke-testing` runner as part of the automation environment. Docker image packaging also uses `ubuntu-latest` for both musl and glibc variants; it downloads release binaries rather than compiling the workspace, and still sets up QEMU and Buildx explicitly.
|
||||
|
||||
Moving these jobs off AKS reduces capacity demand, not necessarily the Azure bill immediately. VM costs fall only when nodes scale down or smaller capacity replaces them.
|
||||
|
||||
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
|
||||
|
||||
## Cache rule
|
||||
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
name: Build Docker Images
|
||||
needs: build-check
|
||||
if: needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: dind-sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
check-ephemerality:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Check runner ephemerality
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -42,7 +42,7 @@ concurrency:
|
||||
jobs:
|
||||
prepare:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
|
||||
runs-on: smoke-testing
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
manifest: ${{ steps.candidate.outputs.manifest }}
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
complete-chain:
|
||||
needs: [prepare, upgrade, s3, kms, tier, storage, heal, pool, security, replication, fault-tolerance, table, performance]
|
||||
if: ${{ always() && needs.prepare.result != 'skipped' }}
|
||||
runs-on: smoke-testing
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
|
||||
@@ -90,7 +90,7 @@ env:
|
||||
jobs:
|
||||
runner-check:
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
|
||||
@@ -158,9 +158,7 @@ requested adversarial/design reviews, and agent-instruction changes that alter
|
||||
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
|
||||
reviews, and low-risk planning do not trigger it.
|
||||
|
||||
For applicable work and substantial PR reviews, read the [risk tiers and review shape](.agents/references/adversarial-validation.md).
|
||||
Load only the matching domain probes; ordinary reviews do not become adversarial
|
||||
merely because this reference exists.
|
||||
For applicable work and substantial PR reviews, follow the [risk tiers and review shape](.agents/references/adversarial-validation.md) and check changed trust boundaries and relevant correctness, security, concurrency, durability, compatibility, performance, and negative regression coverage.
|
||||
|
||||
A review has no finding quota; `No findings` is a complete outcome. A request to
|
||||
find problems is not evidence that a defect exists. Before reporting a candidate,
|
||||
|
||||
@@ -26,7 +26,7 @@ rustfs-io-core = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread"] }
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time"] }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -100,6 +100,7 @@ when restoring `/rustfs/console` for a custom OEM build.
|
||||
## S3 API environment variables
|
||||
|
||||
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
|
||||
- `RUSTFS_VEEAM_SOSAPI_ENABLE` serves Veeam SOSAPI `system.xml` and `capacity.xml` for an authenticated GET or HEAD of those exact bucket-root keys. It defaults to `true`. Explicit version reads use the normal object path. Capacity requests return `ServiceUnavailable` until the scanner publishes a complete snapshot certified for the current bucket incarnation and authoritative quota metadata can be read. Legacy or mixed-version snapshots without an incarnation proof cannot serve capacity until a compatible scanner publishes a certified snapshot. Capacity also returns `ServiceUnavailable` when cluster or disk observations are missing, stale, or degraded. Retry after scanner convergence and storage or metadata recovery. Capacity assumes shard paths have independent backing space; shared physical storage can make the estimate optimistic. Set the flag to `false` to restore the normal object path, including deletion of a real object previously stored at that key.
|
||||
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
|
||||
|
||||
## Distributed endpoint locality
|
||||
|
||||
@@ -222,6 +222,16 @@ pub const ENV_RUSTFS_TLS_PATH: &str = "RUSTFS_TLS_PATH";
|
||||
/// Environment variable for server KMS enable.
|
||||
pub const ENV_RUSTFS_KMS_ENABLE: &str = "RUSTFS_KMS_ENABLE";
|
||||
|
||||
/// Environment variable for Veeam SOSAPI virtual system objects.
|
||||
///
|
||||
/// When true (the default), authenticated GET and HEAD of the two Veeam
|
||||
/// SOSAPI keys return generated XML. When false, those keys use the normal
|
||||
/// object path so an operator can remove a real object stored there earlier.
|
||||
pub const ENV_VEEAM_SOSAPI_ENABLE: &str = "RUSTFS_VEEAM_SOSAPI_ENABLE";
|
||||
|
||||
/// Default Veeam SOSAPI enablement.
|
||||
pub const DEFAULT_VEEAM_SOSAPI_ENABLE: bool = true;
|
||||
|
||||
/// Default KMS enable for server-side encryption
|
||||
/// This is the default value for enabling KMS encryption for server-side encryption.
|
||||
/// Default value: false
|
||||
|
||||
@@ -466,6 +466,9 @@ pub struct BucketTargetUsageInfo {
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BucketUsageInfo {
|
||||
pub size: u64,
|
||||
/// Bucket identity certified by every set contributing to this snapshot.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bucket_incarnation: Option<String>,
|
||||
// Following five fields suffixed with V1 are here for backward compatibility
|
||||
// Total Size for objects that have not yet been replicated
|
||||
pub replication_pending_size_v1: u64,
|
||||
@@ -2316,6 +2319,9 @@ impl BucketUsageInfo {
|
||||
/// Add size summary to this bucket usage
|
||||
/// Merge another BucketUsageInfo into this one
|
||||
pub fn merge(&mut self, other: &BucketUsageInfo) {
|
||||
if self.bucket_incarnation != other.bucket_incarnation {
|
||||
self.bucket_incarnation = None;
|
||||
}
|
||||
self.size += other.size;
|
||||
self.objects_count += other.objects_count;
|
||||
self.versions_count += other.versions_count;
|
||||
@@ -3299,6 +3305,31 @@ mod tests {
|
||||
assert_eq!(usage1.versions_count, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_usage_merge_discards_incompatible_incarnation_proofs() {
|
||||
let id = "00000000-0000-4000-8000-000000000001";
|
||||
let mut same = BucketUsageInfo {
|
||||
bucket_incarnation: Some(id.into()),
|
||||
..Default::default()
|
||||
};
|
||||
same.merge(&BucketUsageInfo {
|
||||
bucket_incarnation: Some(id.into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(same.bucket_incarnation.as_deref(), Some(id));
|
||||
same.merge(&BucketUsageInfo::default());
|
||||
assert!(same.bucket_incarnation.is_none());
|
||||
let mut different = BucketUsageInfo {
|
||||
bucket_incarnation: Some(id.into()),
|
||||
..Default::default()
|
||||
};
|
||||
different.merge(&BucketUsageInfo {
|
||||
bucket_incarnation: Some("00000000-0000-4000-8000-000000000002".into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(different.bucket_incarnation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_summary_add_saturates_instead_of_overflowing() {
|
||||
// The scanner folds one summary per object into a per-prefix total, so a
|
||||
|
||||
@@ -38,6 +38,8 @@ mod tests {
|
||||
use tracing::info;
|
||||
|
||||
const PAGINATION_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
// Bound queued durable commits on the single fixture disk under CI I/O pressure.
|
||||
const FIXTURE_PUT_CONCURRENCY: usize = 4;
|
||||
|
||||
// Fixture population can require thousands of durable PUTs. Bound the
|
||||
// listing traversal itself with one deadline shared by every page.
|
||||
@@ -564,7 +566,7 @@ mod tests {
|
||||
let object_count = 1002;
|
||||
// Finish every durable PUT before listing, with the same bounded fanout as the delimiter fixtures.
|
||||
stream::iter(0..object_count)
|
||||
.for_each_concurrent(16, |i| {
|
||||
.for_each_concurrent(FIXTURE_PUT_CONCURRENCY, |i| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let key = format!("object{i:04}.txt");
|
||||
@@ -575,7 +577,7 @@ mod tests {
|
||||
.body(ByteStream::from_static(b"test content"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err:?}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -822,7 +824,7 @@ mod tests {
|
||||
.flat_map(|dir| (0..10).map(move |file| format!("dir-{dir:03}/file{file:02}.txt")))
|
||||
.collect();
|
||||
stream::iter(&all_keys)
|
||||
.for_each_concurrent(16, |key| {
|
||||
.for_each_concurrent(FIXTURE_PUT_CONCURRENCY, |key| {
|
||||
let client = &client;
|
||||
async move {
|
||||
client
|
||||
@@ -832,7 +834,7 @@ mod tests {
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err:?}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -1055,7 +1057,7 @@ mod tests {
|
||||
let files_per_dir = 100;
|
||||
let fixture_started = std::time::Instant::now();
|
||||
stream::iter((0..dir_count).flat_map(|d| (0..files_per_dir).map(move |f| (d, f))))
|
||||
.for_each_concurrent(16, |(d, f)| {
|
||||
.for_each_concurrent(FIXTURE_PUT_CONCURRENCY, |(d, f)| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let key = format!("dir-{d:02}/file{f:03}.txt");
|
||||
@@ -1066,7 +1068,7 @@ mod tests {
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err:?}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -261,6 +261,61 @@ mod tests {
|
||||
archive
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_rejects_veeam_virtual_members() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_VEEAM_SOSAPI_ENABLE", "true")])
|
||||
.await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-veeam-reserved";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
let prefix = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c";
|
||||
for (member, destination, use_prefix) in [
|
||||
(format!("{prefix}/system.xml"), format!("{prefix}/system.xml"), false),
|
||||
("capacity.xml".to_string(), format!("{prefix}/capacity.xml"), true),
|
||||
] {
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
append_typed_entry(&mut builder, &member, tokio_tar::EntryType::Regular, b"blocked").await?;
|
||||
let archive = builder.into_inner().await?.into_inner();
|
||||
let mut request = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(archive));
|
||||
if use_prefix {
|
||||
request = request
|
||||
.metadata("Minio-Snowball-Prefix", prefix)
|
||||
.metadata("Minio-Snowball-Ignore-Errors", "true");
|
||||
}
|
||||
let error = request.send().await.expect_err("virtual archive members must be rejected");
|
||||
assert_eq!(error.code(), Some("MethodNotAllowed"));
|
||||
let listed = client.list_objects_v2().bucket(bucket).prefix(&destination).send().await?;
|
||||
assert!(listed.contents().is_empty(), "rejected virtual member must not be stored");
|
||||
}
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
append_typed_entry(&mut builder, "ordinary.xml", tokio_tar::EntryType::Regular, b"ordinary").await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.metadata("Minio-Snowball-Prefix", prefix)
|
||||
.body(ByteStream::from(builder.into_inner().await?.into_inner()))
|
||||
.send()
|
||||
.await?;
|
||||
let ordinary = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("{prefix}/ordinary.xml"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(ordinary.body.collect().await?.into_bytes().as_ref(), b"ordinary");
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_supports_minio_prefix_and_directory_markers() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -600,6 +600,154 @@ mod tests {
|
||||
info!("✅ PASSED: Veeam backup workflow simulation completed successfully");
|
||||
}
|
||||
|
||||
/// Veeam B&R discovers SOSAPI by GET/HEAD of two well-known keys.
|
||||
#[tokio::test]
|
||||
async fn test_veeam_sosapi_system_and_capacity() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use crate::common::local_http_client;
|
||||
|
||||
const SYSTEM_KEY: &str = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml";
|
||||
const CAPACITY_KEY: &str = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml";
|
||||
|
||||
init_logging();
|
||||
info!("🧪 TEST: Veeam SOSAPI system.xml and capacity.xml");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], crate::common::FAST_DATA_USAGE_SCANNER_ENV)
|
||||
.await?;
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "veeam-sosapi";
|
||||
create_bucket(&client, bucket).await?;
|
||||
|
||||
let system = client.get_object().bucket(bucket).key(SYSTEM_KEY).send().await?;
|
||||
let system_etag = system.e_tag().map(str::to_string);
|
||||
let system_len = system.content_length();
|
||||
assert_eq!(system.content_type(), Some("application/xml"));
|
||||
let system_body = system.body.collect().await?.into_bytes();
|
||||
let system_xml = std::str::from_utf8(&system_body)?;
|
||||
assert!(system_xml.contains("<ProtocolVersion>\"1.0\"</ProtocolVersion>"), "{system_xml}");
|
||||
assert!(
|
||||
system_xml.contains("<ModelName>\"RustFS ") && system_xml.contains("\"</ModelName>"),
|
||||
"{system_xml}"
|
||||
);
|
||||
assert!(system_xml.contains("<CapacityInfo>true</CapacityInfo>"));
|
||||
assert!(system_xml.contains("<UploadSessions>false</UploadSessions>"));
|
||||
assert!(system_xml.contains("<IAMSTS>false</IAMSTS>"));
|
||||
assert!(!system_xml.contains("SystemRecommendations"));
|
||||
assert!(!system_xml.contains("APIEndpoints"));
|
||||
assert!(!system_xml.contains("""));
|
||||
|
||||
let head = client.head_object().bucket(bucket).key(SYSTEM_KEY).send().await?;
|
||||
assert_eq!(head.content_type(), Some("application/xml"));
|
||||
assert_eq!(head.content_length(), system_len);
|
||||
assert_eq!(head.e_tag().map(str::to_string), system_etag);
|
||||
assert!(head.last_modified().is_some());
|
||||
|
||||
let ranged = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(SYSTEM_KEY)
|
||||
.range("bytes=0-3")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(ranged.content_length(), Some(4));
|
||||
let ranged_header = ranged.content_range().unwrap_or("");
|
||||
assert!(ranged_header.starts_with("bytes 0-3/"), "{ranged_header}");
|
||||
let ranged_body = ranged.body.collect().await?.into_bytes();
|
||||
assert_eq!(&ranged_body[..], &system_body[..4]);
|
||||
|
||||
let capacity = tokio::time::timeout(std::time::Duration::from_secs(180), async {
|
||||
loop {
|
||||
match client.get_object().bucket(bucket).key(CAPACITY_KEY).send().await {
|
||||
Ok(capacity) => break Ok(capacity),
|
||||
Err(err) if err.code() == Some("ServiceUnavailable") => {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert_eq!(capacity.content_type(), Some("application/xml"));
|
||||
let capacity_body = capacity.body.collect().await?.into_bytes();
|
||||
let capacity_xml = std::str::from_utf8(&capacity_body)?;
|
||||
let capacity_value = xml_u64(capacity_xml, "Capacity")?;
|
||||
let available_value = xml_u64(capacity_xml, "Available")?;
|
||||
let used_value = xml_u64(capacity_xml, "Used")?;
|
||||
assert!(used_value <= capacity_value, "{capacity_xml}");
|
||||
assert!(available_value <= capacity_value.saturating_sub(used_value), "{capacity_xml}");
|
||||
|
||||
let put_err = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(SYSTEM_KEY)
|
||||
.body(ByteStream::from_static(b"overwrite"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("SOSAPI put must fail");
|
||||
assert_eq!(put_err.code(), Some("MethodNotAllowed"));
|
||||
let delete_err = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(CAPACITY_KEY)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("SOSAPI delete must fail");
|
||||
assert_eq!(delete_err.code(), Some("MethodNotAllowed"));
|
||||
|
||||
let normal_key = "Veeam/Backup/job.dat";
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(normal_key)
|
||||
.body(ByteStream::from_static(b"backup-bytes"))
|
||||
.send()
|
||||
.await?;
|
||||
let normal = client.get_object().bucket(bucket).key(normal_key).send().await?;
|
||||
let normal_body = normal.body.collect().await?.into_bytes();
|
||||
assert_eq!(&normal_body[..], b"backup-bytes");
|
||||
|
||||
let anon = local_http_client()
|
||||
.get(format!("{}/{bucket}/{SYSTEM_KEY}", env.url))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(anon.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
let anon_body = anon.text().await?;
|
||||
assert!(!anon_body.contains("ProtocolVersion"), "{anon_body}");
|
||||
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let scope_date = format!("{:04}{:02}{:02}", now.year(), u8::from(now.month()), now.day());
|
||||
let amz_date = format!("{scope_date}T{:02}{:02}{:02}Z", now.hour(), now.minute(), now.second());
|
||||
let bad = local_http_client()
|
||||
.get(format!("{}/{bucket}/{SYSTEM_KEY}", env.url))
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
|
||||
.header(
|
||||
"authorization",
|
||||
format!(
|
||||
"AWS4-HMAC-SHA256 Credential=bad/{scope_date}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=0000000000000000000000000000000000000000000000000000000000000000"
|
||||
),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(bad.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
let bad_body = bad.text().await?;
|
||||
assert!(!bad_body.contains("ProtocolVersion"), "{bad_body}");
|
||||
|
||||
info!("✅ PASSED: Veeam SOSAPI system.xml and capacity.xml");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml_u64(xml: &str, element: &str) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let start = format!("<{element}>");
|
||||
let end = format!("</{element}>");
|
||||
let start_idx = xml.find(&start).ok_or_else(|| format!("missing {element} in {xml}"))? + start.len();
|
||||
let end_idx = xml[start_idx..]
|
||||
.find(&end)
|
||||
.ok_or_else(|| format!("unclosed {element} in {xml}"))?
|
||||
+ start_idx;
|
||||
Ok(xml[start_idx..end_idx].parse()?)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_terraform_put_after_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -12,9 +12,9 @@ Applies to `crates/ecstore/`.
|
||||
|
||||
- Be careful with allocations and locking in hot paths.
|
||||
- Keep network and disk operations async-friendly; avoid introducing unnecessary blocking.
|
||||
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single `blocking_queue` or `run_blocking_namespace_operation` closure per disk rather than chaining multiple `tokio::fs` or `spawn_blocking` dispatches.
|
||||
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned guard across sibling metadata reads (`read_optional_in_pinned`, `part.N.meta` loops) instead of re-walking ancestor components per file or re-opening when `source_parent == destination_parent`.
|
||||
- After `file.metadata()`, pre-size read buffers (`Vec::with_capacity(len + 1)`) before `read_to_end` to avoid `std::io::read_to_end`'s 32-byte exponential growth (`6–7` `read()` syscalls per small metadata file).
|
||||
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single per-disk blocking closure rather than chaining multiple async or blocking dispatches, and route any closure that performs file or directory sync to the disk's dedicated fsync lane so stalled syncs cannot starve foreground metadata reads.
|
||||
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned handle across sibling reads or same-directory renames instead of re-walking ancestor components per file.
|
||||
- When the file size is already known from `fstat` metadata, pre-size the destination buffer (`len + 1`) before reading to EOF so small metadata reads complete in a single `read()` + EOF check rather than exponential-growth reallocations and syscalls.
|
||||
- Benchmark-sensitive changes should include measurable rationale.
|
||||
|
||||
## Allocation Discipline in Hot Paths
|
||||
|
||||
@@ -252,7 +252,7 @@ pub mod bucket {
|
||||
pub mod quota {
|
||||
pub use crate::bucket::quota::{
|
||||
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V1, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError,
|
||||
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration,
|
||||
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration, QuotaType,
|
||||
};
|
||||
|
||||
pub mod checker {
|
||||
@@ -341,9 +341,9 @@ pub mod cache {
|
||||
|
||||
pub mod capacity {
|
||||
pub use crate::core::pools::{
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
||||
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, path2_bucket_object,
|
||||
path2_bucket_object_with_base_path,
|
||||
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolAvailability, PoolCapacityStatus, PoolDecommissionInfo,
|
||||
PoolDiagnosticStatus, PoolMeta, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
|
||||
};
|
||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||
}
|
||||
@@ -371,6 +371,15 @@ pub mod compression {
|
||||
|
||||
pub mod config {
|
||||
pub mod com {
|
||||
/// Preserve empty existing objects so callers can distinguish corruption from absence.
|
||||
pub async fn read_config_preserve_empty_with_metadata_opts(
|
||||
api: std::sync::Arc<crate::store::ECStore>,
|
||||
file: &str,
|
||||
opts: &crate::object_api::ObjectOptions,
|
||||
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
||||
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
|
||||
}
|
||||
|
||||
pub use crate::config::com::{
|
||||
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
||||
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config, delete_config_no_lock,
|
||||
@@ -416,9 +425,10 @@ pub mod data_usage {
|
||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
||||
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
||||
invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached,
|
||||
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached, quota_object_size,
|
||||
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
|
||||
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
|
||||
load_bucket_usage_from_backend_cached, load_compression_total_from_memory, load_data_usage_from_backend,
|
||||
load_data_usage_from_backend_cached, quota_object_size, record_bucket_delete_marker_memory,
|
||||
record_bucket_object_delete_memory, record_bucket_object_version_write_memory, record_bucket_object_write_memory,
|
||||
record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
|
||||
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
|
||||
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
|
||||
store_data_usage_in_backend,
|
||||
|
||||
@@ -1086,8 +1086,9 @@ impl ExpiryState {
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_with_unconsumed_worker_channel(capacity: usize) -> Arc<RwLock<Self>> {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[doc(hidden)]
|
||||
pub fn new_with_unconsumed_worker_channel(capacity: usize) -> Arc<RwLock<Self>> {
|
||||
let (tx, rx) = mpsc::channel(capacity);
|
||||
Arc::new(RwLock::new(Self {
|
||||
tasks_tx: vec![tx],
|
||||
@@ -3516,13 +3517,21 @@ async fn stale_upload_current_size_with_opts(
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
Some(
|
||||
parts
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| part.actual_size.max(part.size as i64).max(0) as usize)
|
||||
.sum(),
|
||||
)
|
||||
stale_upload_size_from_parts(&parts)
|
||||
}
|
||||
|
||||
fn stale_upload_size_from_parts(parts: &crate::storage_api_contracts::multipart::ListPartsInfo) -> Option<usize> {
|
||||
if parts.is_truncated {
|
||||
return None;
|
||||
}
|
||||
parts.parts.iter().try_fold(0usize, |total, part| {
|
||||
// ListParts represents a quorum-confirmed missing part with an empty ETag.
|
||||
if part.etag.as_deref().is_none_or(str::is_empty) {
|
||||
return None;
|
||||
}
|
||||
let actual_size = usize::try_from(part.actual_size.max(0)).ok()?;
|
||||
total.checked_add(part.size.max(actual_size))
|
||||
})
|
||||
}
|
||||
|
||||
async fn stale_upload_lifecycle_due(
|
||||
@@ -3539,18 +3548,43 @@ async fn stale_upload_lifecycle_due(
|
||||
let bucket = metadata.get(RUSTFS_MULTIPART_BUCKET_KEY)?;
|
||||
let object = metadata.get(RUSTFS_MULTIPART_OBJECT_KEY)?;
|
||||
|
||||
let lifecycle = match metadata_boundary::get_lifecycle_config(bucket).await {
|
||||
let mut lifecycle = match metadata_boundary::get_lifecycle_config(bucket).await {
|
||||
Ok((lifecycle, _)) => lifecycle,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let has_size_filter = |filter: &s3s::dto::LifecycleRuleFilter| {
|
||||
filter.object_size_greater_than.is_some()
|
||||
|| filter.object_size_less_than.is_some()
|
||||
|| filter
|
||||
.and
|
||||
.as_ref()
|
||||
.is_some_and(|and| and.object_size_greater_than.is_some() || and.object_size_less_than.is_some())
|
||||
};
|
||||
let needs_size = lifecycle
|
||||
.rules
|
||||
.iter()
|
||||
.any(|rule| rule.abort_incomplete_multipart_upload.is_some() && rule.filter.as_ref().is_some_and(has_size_filter));
|
||||
let size = if needs_size {
|
||||
match stale_upload_current_size_with_opts(set, metadata, upload_dir, no_lock).await {
|
||||
Some(size) => size,
|
||||
None => {
|
||||
// An unknown size cannot authorize a size-filtered abort.
|
||||
// Unrelated rules can still be evaluated independently.
|
||||
lifecycle
|
||||
.rules
|
||||
.retain(|rule| !rule.filter.as_ref().is_some_and(has_size_filter));
|
||||
0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let object_opts = ObjectOpts {
|
||||
name: object.clone(),
|
||||
user_tags: metadata.get(AMZ_OBJECT_TAGGING).cloned().unwrap_or_default(),
|
||||
mod_time: Some(initiated),
|
||||
size: stale_upload_current_size_with_opts(set, metadata, upload_dir, no_lock)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
size,
|
||||
is_latest: true,
|
||||
delete_marker: false,
|
||||
user_defined: metadata.clone(),
|
||||
@@ -3803,7 +3837,8 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
||||
for candidate in candidates.into_values() {
|
||||
let upload_dir = candidate.path.rsplit('/').next().unwrap_or_default().to_string();
|
||||
let mut due = stale_upload_default_due(candidate.initiated, default_expiry);
|
||||
if let Some(metadata) = candidate.metadata.as_ref()
|
||||
if now < due
|
||||
&& let Some(metadata) = candidate.metadata.as_ref()
|
||||
&& let Some(lifecycle_due) = stale_upload_lifecycle_due(set, metadata, candidate.initiated, &upload_dir, false).await
|
||||
&& lifecycle_due < due
|
||||
{
|
||||
@@ -3832,8 +3867,9 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
||||
let current_metadata = cleanup_guard.file_info().metadata.clone();
|
||||
let current_initiated = initiated_from_upload_dir(&upload_dir, cleanup_guard.file_info().mod_time);
|
||||
let mut current_due = stale_upload_default_due(current_initiated, default_expiry);
|
||||
if let Some(lifecycle_due) =
|
||||
stale_upload_lifecycle_due(set, ¤t_metadata, current_initiated, &upload_dir, true).await
|
||||
if now < current_due
|
||||
&& let Some(lifecycle_due) =
|
||||
stale_upload_lifecycle_due(set, ¤t_metadata, current_initiated, &upload_dir, true).await
|
||||
&& lifecycle_due < current_due
|
||||
{
|
||||
current_due = lifecycle_due;
|
||||
@@ -15868,6 +15904,261 @@ mod tests {
|
||||
assert!(ecstore.is_ok(), "fresh local ECStore boot should succeed, got {ecstore:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_preserves_partial_metadata_without_upload_part_warning() {
|
||||
use tracing::instrument::WithSubscriber as _;
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturedLogs(Arc<StdMutex<Vec<u8>>>);
|
||||
struct LogWriter(Arc<StdMutex<Vec<u8>>>);
|
||||
impl std::io::Write for LogWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
|
||||
type Writer = LogWriter;
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
LogWriter(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
let (paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("stale-partial-{}", Uuid::new_v4().simple());
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
for copies in [1, 2] {
|
||||
let object = format!("partial-{copies}.bin");
|
||||
let initiated = OffsetDateTime::now_utc() - time::Duration::hours(30);
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(
|
||||
&bucket,
|
||||
&object,
|
||||
&ObjectOptions {
|
||||
mod_time: Some(initiated),
|
||||
max_parity: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("multipart upload should be created");
|
||||
let decoded = base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(upload.upload_id.as_bytes())
|
||||
.unwrap();
|
||||
let decoded = String::from_utf8(decoded).unwrap();
|
||||
let (_, upload_dir) = decoded.split_once('.').expect("deployment-prefixed upload id");
|
||||
let upload_path = format!("{}/{upload_dir}", multipart_sha_dir(&bucket, &object));
|
||||
let physical_paths: Vec<_> = paths
|
||||
.iter()
|
||||
.map(|path| path.join(RUSTFS_META_MULTIPART_BUCKET).join(&upload_path))
|
||||
.collect();
|
||||
let original: Vec<_> = physical_paths
|
||||
.iter()
|
||||
.take(copies)
|
||||
.map(|path| std::fs::read(path.join(STORAGE_FORMAT_FILE)).unwrap())
|
||||
.collect();
|
||||
let info = super::get_file_info(
|
||||
&original[0],
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&upload_path,
|
||||
"",
|
||||
super::FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: false,
|
||||
include_part_checksums: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((info.erasure.data_blocks, info.erasure.parity_blocks), (2, 2));
|
||||
for path in physical_paths.iter().skip(copies) {
|
||||
fs::remove_dir_all(path).await.expect("remove selected metadata copies");
|
||||
}
|
||||
if copies == 2 {
|
||||
ecstore
|
||||
.get_multipart_info(&bucket, &object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("two metadata copies must meet read quorum");
|
||||
}
|
||||
|
||||
let logs = CapturedLogs::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.with_writer(logs.clone())
|
||||
.with_ansi(false)
|
||||
.without_time()
|
||||
.finish();
|
||||
cleanup_stale_multipart_uploads_once_at(
|
||||
ecstore.clone(),
|
||||
OffsetDateTime::now_utc(),
|
||||
StdDuration::from_secs(24 * 60 * 60),
|
||||
)
|
||||
.with_subscriber(subscriber)
|
||||
.await;
|
||||
|
||||
for (path, expected) in physical_paths.iter().zip(&original) {
|
||||
assert_eq!(fs::read(path.join(STORAGE_FORMAT_FILE)).await.unwrap(), *expected);
|
||||
}
|
||||
let log_output = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
|
||||
assert!(
|
||||
log_output.contains(&upload_path),
|
||||
"missing background diagnostic for {upload_path}: {log_output}"
|
||||
);
|
||||
assert!(log_output.contains("multipart_cleanup_lock_or_recheck_failed"), "{log_output}");
|
||||
assert!(!log_output.contains("op=\"upload_part\""), "unexpected upload-part warning: {log_output}");
|
||||
for path in physical_paths.iter().take(copies) {
|
||||
fs::remove_dir_all(path).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_lists_parts_only_when_size_is_needed() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tracing::instrument::WithSubscriber as _;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
struct PartListCounter(Arc<AtomicUsize>);
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for PartListCounter {
|
||||
fn on_new_span(
|
||||
&self,
|
||||
attrs: &tracing::span::Attributes<'_>,
|
||||
_id: &tracing::span::Id,
|
||||
_ctx: tracing_subscriber::layer::Context<'_, S>,
|
||||
) {
|
||||
if attrs.metadata().name() == "list_object_parts" {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (_, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("stale-list-parts-{}", Uuid::new_v4().simple());
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
set_abort_incomplete_lifecycle(&bucket, "logs/", 0).await;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
for (object, age, size_filter, should_list) in [
|
||||
("logs/default-expired", time::Duration::hours(30), false, false),
|
||||
("logs/lifecycle-only", time::Duration::minutes(5), false, false),
|
||||
("logs/size-filter", time::Duration::minutes(5), true, true),
|
||||
] {
|
||||
if size_filter {
|
||||
set_abort_incomplete_lifecycle_with_size(&bucket, "logs/", 0, 0).await;
|
||||
}
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(
|
||||
&bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
mod_time: Some(now - age),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create multipart upload");
|
||||
if size_filter {
|
||||
let mut data = PutObjReader::from_vec(vec![1, 2, 3, 4]);
|
||||
ecstore
|
||||
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut data, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("upload a part for size filtering");
|
||||
}
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let subscriber = tracing_subscriber::registry().with(PartListCounter(Arc::clone(&calls)));
|
||||
cleanup_stale_multipart_uploads_once_at(ecstore.clone(), now, StdDuration::from_secs(24 * 60 * 60))
|
||||
.with_subscriber(subscriber)
|
||||
.await;
|
||||
let listed = calls.load(Ordering::Relaxed);
|
||||
assert_eq!(listed > 0, should_list, "unexpected ListParts calls for {object}: {listed}");
|
||||
let result = ecstore
|
||||
.get_multipart_info(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(ref err) if is_err_invalid_upload_id(err)),
|
||||
"upload must be removed: {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_multipart_cleanup_rejects_incomplete_or_overflowing_part_sizes() {
|
||||
use crate::storage_api_contracts::multipart::{ListPartsInfo, PartInfo};
|
||||
|
||||
let part = |size, actual_size, etag: &str| PartInfo {
|
||||
size,
|
||||
actual_size,
|
||||
etag: Some(etag.to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut parts = ListPartsInfo {
|
||||
parts: vec![part(4, 6, "valid")],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(super::stale_upload_size_from_parts(&parts), Some(6));
|
||||
parts.is_truncated = true;
|
||||
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
|
||||
parts.is_truncated = false;
|
||||
parts.parts = vec![part(0, 0, "")];
|
||||
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
|
||||
parts.parts = vec![part(usize::MAX, 0, "valid"), part(1, 1, "valid")];
|
||||
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_handles_unknown_size_without_false_abort() {
|
||||
let (_, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("stale-unknown-size-{}", Uuid::new_v4().simple());
|
||||
let object = "logs/object";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
let size_rule = r#"<Rule><ID>size</ID><Status>Enabled</Status><Filter><ObjectSizeLessThan>5</ObjectSizeLessThan></Filter><AbortIncompleteMultipartUpload><DaysAfterInitiation>0</DaysAfterInitiation></AbortIncompleteMultipartUpload></Rule>"#;
|
||||
let independent_rule = r#"<Rule><ID>prefix</ID><Status>Enabled</Status><Filter><Prefix>logs/</Prefix></Filter><AbortIncompleteMultipartUpload><DaysAfterInitiation>0</DaysAfterInitiation></AbortIncompleteMultipartUpload></Rule>"#;
|
||||
let lifecycle = |rules: &str| format!("<LifecycleConfiguration>{rules}</LifecycleConfiguration>").into_bytes();
|
||||
metadata_sys::update(&bucket, BUCKET_LIFECYCLE_CONFIG, lifecycle(size_rule))
|
||||
.await
|
||||
.expect("install upper-size lifecycle rule");
|
||||
let metadata = HashMap::from([
|
||||
(super::RUSTFS_MULTIPART_BUCKET_KEY.to_owned(), bucket.clone()),
|
||||
(super::RUSTFS_MULTIPART_OBJECT_KEY.to_owned(), object.to_owned()),
|
||||
]);
|
||||
let initiated = OffsetDateTime::now_utc() - time::Duration::hours(1);
|
||||
let set = &ecstore.pools[0].disk_set[0];
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create empty multipart upload");
|
||||
let decoded = base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(upload.upload_id.as_bytes())
|
||||
.expect("decode upload ID");
|
||||
let decoded = String::from_utf8(decoded).expect("upload ID must be UTF-8");
|
||||
let (_, upload_dir) = decoded.split_once('.').expect("deployment-prefixed upload ID");
|
||||
assert!(
|
||||
super::stale_upload_lifecycle_due(set, &metadata, initiated, upload_dir, false)
|
||||
.await
|
||||
.is_some(),
|
||||
"a known empty upload must match the upper-size rule"
|
||||
);
|
||||
let missing_upload = format!("{}x{}", Uuid::new_v4(), initiated.unix_timestamp_nanos());
|
||||
assert!(
|
||||
super::stale_upload_lifecycle_due(set, &metadata, initiated, &missing_upload, false)
|
||||
.await
|
||||
.is_none(),
|
||||
"an unreadable size must not match the upper-size rule"
|
||||
);
|
||||
metadata_sys::update(&bucket, BUCKET_LIFECYCLE_CONFIG, lifecycle(&format!("{size_rule}{independent_rule}")))
|
||||
.await
|
||||
.expect("install size-independent lifecycle rule");
|
||||
assert!(
|
||||
super::stale_upload_lifecycle_due(set, &metadata, initiated, &missing_upload, false)
|
||||
.await
|
||||
.is_some(),
|
||||
"a size-independent rule remains eligible when size is unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_uses_default_expiry_without_lifecycle() {
|
||||
|
||||
@@ -63,14 +63,7 @@ pub(crate) async fn get_expiry_configs_for_options(
|
||||
bucket: &str,
|
||||
opts: &crate::object_api::ObjectOptions,
|
||||
) -> Result<LifecycleExpiryConfigs> {
|
||||
let bucket_incarnation_id = match opts
|
||||
.object_lock_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.validated_bucket_incarnation_for(api.id, bucket, opts.expected_bucket_incarnation_id))
|
||||
{
|
||||
Some(incarnation_id) => incarnation_id,
|
||||
None => metadata_sys::get_bucket_incarnation_id_for_options_in(&api.ctx, bucket, opts).await?,
|
||||
};
|
||||
let bucket_incarnation_id = metadata_sys::get_bucket_incarnation_id_for_options_in(&api.ctx, bucket, opts).await?;
|
||||
let metadata = get_authoritative_metadata(api, bucket, bucket_incarnation_id).await?;
|
||||
let table_bucket_enabled = metadata.table_bucket_enabled();
|
||||
|
||||
|
||||
@@ -564,12 +564,39 @@ pub(crate) async fn validate_bucket_reconstruction_in(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate against disk while retaining an already-held Object Lock fence.
|
||||
/// Resolve the authoritative bucket incarnation id for `opts`:
|
||||
/// 1. When namespace locking is enabled and `opts.object_lock_config_snapshot` is
|
||||
/// present, validate and return the snapshot's incarnation under its held
|
||||
/// lifecycle and metadata-transaction fences.
|
||||
/// 2. When namespace locking is enabled and `opts.bucket_lifecycle_lock_fence` is
|
||||
/// present and not lost, reuse the store's `BucketFenceRegistry` memo if a
|
||||
/// registered lifecycle read guard is currently held without lock loss.
|
||||
/// 3. Otherwise, acquire the bucket metadata transaction read lock (unless
|
||||
/// already held on `opts`) and read the incarnation from disk.
|
||||
pub(crate) async fn get_bucket_incarnation_id_for_options_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<Uuid> {
|
||||
if !ctx.lock_manager().is_disabled() {
|
||||
if let Some(snapshot) = opts.object_lock_config_snapshot.as_ref() {
|
||||
let store = object_store_in(ctx).await?;
|
||||
return snapshot
|
||||
.validated_bucket_incarnation_for(store.id, bucket, opts.expected_bucket_incarnation_id)
|
||||
.ok_or_else(|| {
|
||||
Error::other("Object Lock snapshot does not hold a valid metadata transaction fence for this bucket")
|
||||
});
|
||||
}
|
||||
if opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(|fence| !fence.is_lock_lost())
|
||||
&& let Some(store) = object_store_if_initialized_in(ctx).await
|
||||
&& let Some(incarnation) = store.bucket_fence_registry.validated_incarnation(bucket)
|
||||
{
|
||||
return Ok(incarnation);
|
||||
}
|
||||
}
|
||||
let sys = bucket_metadata_sys_of(ctx)?;
|
||||
let sys = sys.read().await.clone();
|
||||
let guard = acquire_bucket_metadata_transaction_read_lock_for_options_in(ctx, bucket, opts).await?;
|
||||
|
||||
@@ -488,6 +488,18 @@ where
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Read config bytes and metadata while preserving an existing empty payload.
|
||||
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
read_config_with_metadata_inner(api, file, opts, true, None).await
|
||||
}
|
||||
|
||||
pub async fn read_config_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
|
||||
where
|
||||
S: ObjectIO<
|
||||
|
||||
@@ -11745,6 +11745,61 @@ pub struct PoolSpaceInfo {
|
||||
pub used: usize,
|
||||
}
|
||||
|
||||
/// Read-only observations used by the admin pool diagnostic surface. The HTTP
|
||||
/// layer converts these stable values to strings without changing the pool
|
||||
/// lifecycle status contract.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PoolAvailability {
|
||||
Online,
|
||||
Degraded,
|
||||
Offline,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl PoolAvailability {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "online",
|
||||
Self::Degraded => "degraded",
|
||||
Self::Offline => "offline",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PoolCapacityStatus {
|
||||
Live,
|
||||
Cached,
|
||||
Partial,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl PoolCapacityStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Live => "live",
|
||||
Self::Cached => "cached",
|
||||
Self::Partial => "partial",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PoolDiagnosticStatus {
|
||||
pub availability: PoolAvailability,
|
||||
pub capacity_status: PoolCapacityStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolAdminDiagnostic {
|
||||
pub id: usize,
|
||||
pub cmd_line: String,
|
||||
pub pool: Option<PoolStatus>,
|
||||
pub diagnostic: PoolDiagnosticStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct DecommissionPoolCapacityInfo {
|
||||
pool_index: usize,
|
||||
@@ -11753,6 +11808,7 @@ pub(crate) struct DecommissionPoolCapacityInfo {
|
||||
physical_free: usize,
|
||||
physical_total: usize,
|
||||
physical_used: usize,
|
||||
diagnostic: PoolDiagnosticStatus,
|
||||
}
|
||||
|
||||
impl DecommissionPoolCapacityInfo {
|
||||
@@ -11775,6 +11831,10 @@ impl DecommissionPoolCapacityInfo {
|
||||
physical_free,
|
||||
physical_total,
|
||||
physical_used,
|
||||
diagnostic: PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Unknown,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11796,6 +11856,121 @@ where
|
||||
Ok(capacities)
|
||||
}
|
||||
|
||||
fn pool_diagnostic_status(
|
||||
disks: &[rustfs_madmin::Disk],
|
||||
pool_index: usize,
|
||||
set_count: usize,
|
||||
set_drive_count: usize,
|
||||
read_quorum: usize,
|
||||
) -> PoolDiagnosticStatus {
|
||||
if set_count == 0 || set_drive_count == 0 || read_quorum == 0 || read_quorum > set_drive_count {
|
||||
return PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Unknown,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
let mut live_capacity = true;
|
||||
let mut cached_capacity = false;
|
||||
let mut partial_capacity = false;
|
||||
let mut online_by_set = vec![0usize; set_count];
|
||||
let mut observed_disks = 0usize;
|
||||
let mut seen_slots = HashSet::new();
|
||||
|
||||
for disk in disks.iter().filter(|disk| disk.pool_index == pool_index as i32) {
|
||||
observed_disks = observed_disks.saturating_add(1);
|
||||
match disk.capacity_observation_source.as_deref() {
|
||||
Some("live_probe") => {}
|
||||
Some("snapshot") => {
|
||||
live_capacity = false;
|
||||
cached_capacity = true;
|
||||
}
|
||||
Some("missing") | None => {
|
||||
live_capacity = false;
|
||||
partial_capacity = true;
|
||||
}
|
||||
Some(_) => {
|
||||
live_capacity = false;
|
||||
partial_capacity = true;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(set_index) = usize::try_from(disk.set_index).ok() else {
|
||||
continue;
|
||||
};
|
||||
let Some(disk_index) = usize::try_from(disk.disk_index).ok() else {
|
||||
continue;
|
||||
};
|
||||
if set_index >= set_count
|
||||
|| disk_index >= set_drive_count
|
||||
|| !seen_slots.insert((set_index, disk_index))
|
||||
|| !disk_is_available_for_pool_diagnostic(disk)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
online_by_set[set_index] = online_by_set[set_index].saturating_add(1);
|
||||
}
|
||||
|
||||
let availability = if online_by_set.iter().any(|&online| online < read_quorum) {
|
||||
PoolAvailability::Offline
|
||||
} else if online_by_set.iter().all(|&online| online == set_drive_count) {
|
||||
PoolAvailability::Online
|
||||
} else {
|
||||
PoolAvailability::Degraded
|
||||
};
|
||||
let capacity_status = if observed_disks == 0 {
|
||||
PoolCapacityStatus::Unknown
|
||||
} else if partial_capacity {
|
||||
PoolCapacityStatus::Partial
|
||||
} else if cached_capacity {
|
||||
PoolCapacityStatus::Cached
|
||||
} else if live_capacity {
|
||||
PoolCapacityStatus::Live
|
||||
} else {
|
||||
PoolCapacityStatus::Unknown
|
||||
};
|
||||
|
||||
PoolDiagnosticStatus {
|
||||
availability,
|
||||
capacity_status,
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_is_available_for_pool_diagnostic(disk: &rustfs_madmin::Disk) -> bool {
|
||||
let state_ok = disk.state.eq_ignore_ascii_case("ok") || disk.state.eq_ignore_ascii_case(rustfs_madmin::ITEM_ONLINE);
|
||||
let runtime_ok = disk
|
||||
.runtime_state
|
||||
.as_deref()
|
||||
.map(|state| state.eq_ignore_ascii_case(rustfs_madmin::ITEM_ONLINE))
|
||||
.unwrap_or(state_ok);
|
||||
state_ok && runtime_ok
|
||||
}
|
||||
|
||||
fn pool_diagnostic_metadata_status(err: &Error) -> &'static str {
|
||||
if let Some(failure) = err.pool_metadata_failure() {
|
||||
return match failure.kind {
|
||||
crate::error::PoolMetadataFailure::ReadUnavailable => "stale",
|
||||
crate::error::PoolMetadataFailure::RecoveryRequired
|
||||
| crate::error::PoolMetadataFailure::TransactionUnknown
|
||||
| crate::error::PoolMetadataFailure::FenceLost => "recovery_required",
|
||||
};
|
||||
}
|
||||
|
||||
match err {
|
||||
Error::Timeout
|
||||
| Error::Lock(_)
|
||||
| Error::DiskNotFound
|
||||
| Error::FaultyDisk
|
||||
| Error::FaultyRemoteDisk
|
||||
| Error::RemoteClientUnavailable(_)
|
||||
| Error::RemoteNotInitialized
|
||||
| Error::ErasureReadQuorum
|
||||
| Error::InsufficientReadQuorum(_, _)
|
||||
| Error::NamespaceLockQuorumUnavailable { .. } => "stale",
|
||||
_ => "recovery_required",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static DECOMMISSION_START_GATE_WAIT_OVERRIDES: std::sync::OnceLock<std::sync::Mutex<HashMap<uuid::Uuid, std::time::Duration>>> =
|
||||
std::sync::OnceLock::new();
|
||||
@@ -15168,6 +15343,70 @@ impl ECStore {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Admin-only diagnostic snapshot. A transient metadata read failure is
|
||||
/// reported as stale without publishing the fallback or relaxing any
|
||||
/// mutation/recovery path.
|
||||
pub async fn admin_pool_diagnostic_snapshot(
|
||||
&self,
|
||||
target_pool_index: Option<usize>,
|
||||
) -> (Vec<PoolAdminDiagnostic>, &'static str) {
|
||||
let fallback = self.pool_meta.read().await.clone();
|
||||
let (pool_meta, mut metadata_status) = match self.pool_status_meta_snapshot().await {
|
||||
Ok(pool_meta) => (pool_meta, "fresh"),
|
||||
Err(err) => (fallback, pool_diagnostic_metadata_status(&err)),
|
||||
};
|
||||
if metadata_status == "stale" && pool_meta.pools.len() != self.pools.len() {
|
||||
metadata_status = "unknown";
|
||||
}
|
||||
|
||||
let backend = rustfs_madmin::StorageInfo {
|
||||
backend: StorageAdminApi::backend_info(self).await,
|
||||
..Default::default()
|
||||
};
|
||||
let indices = target_pool_index.map_or_else(|| (0..self.pools.len()).collect(), |idx| vec![idx]);
|
||||
let backend = &backend;
|
||||
let samples = futures::future::join_all(indices.into_iter().map(|idx| async move {
|
||||
(
|
||||
idx,
|
||||
self.get_decommission_pool_capacity_info_with_backend(idx, false, backend)
|
||||
.await,
|
||||
)
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut diagnostics = Vec::with_capacity(samples.len());
|
||||
for (idx, sample) in samples {
|
||||
let Some(sets) = self.pools.get(idx) else {
|
||||
continue;
|
||||
};
|
||||
let (pool, diagnostic) = match sample {
|
||||
Ok(capacity) => (
|
||||
pool_meta
|
||||
.pools
|
||||
.get(idx)
|
||||
.cloned()
|
||||
.map(|pool| apply_decommission_status_space_info(pool, capacity.space)),
|
||||
capacity.diagnostic,
|
||||
),
|
||||
Err(_) => (
|
||||
pool_meta.pools.get(idx).cloned(),
|
||||
PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Unknown,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
},
|
||||
),
|
||||
};
|
||||
diagnostics.push(PoolAdminDiagnostic {
|
||||
id: idx,
|
||||
cmd_line: sets.endpoints.cmd_line.clone(),
|
||||
pool,
|
||||
diagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
(diagnostics, metadata_status)
|
||||
}
|
||||
|
||||
/// Load and publish under the movement writer, so no movement read sees
|
||||
/// the transition mid-operation and no transition interleaves the load.
|
||||
async fn publish_pool_status_refresh(&self, _movement_guard: tokio::sync::OwnedRwLockWriteGuard<()>) -> Result<()> {
|
||||
@@ -15244,6 +15483,7 @@ impl ECStore {
|
||||
};
|
||||
let (physical_total, physical_free, physical_used) =
|
||||
decommission_physical_pool_capacity(&info.disks, idx, layout, space, owned_physical_used.as_ref());
|
||||
let diagnostic = pool_diagnostic_status(&info.disks, idx, sets.set_count, sets.set_drive_count, layout.data);
|
||||
|
||||
Ok(DecommissionPoolCapacityInfo {
|
||||
pool_index: idx,
|
||||
@@ -15252,6 +15492,7 @@ impl ECStore {
|
||||
physical_free,
|
||||
physical_total,
|
||||
physical_used,
|
||||
diagnostic,
|
||||
})
|
||||
} else {
|
||||
Err(invalid_decommission_pool_index_error(self.pools.len(), idx))
|
||||
@@ -21844,6 +22085,85 @@ mod tests {
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
||||
use serde::Serialize;
|
||||
|
||||
fn diagnostic_disk(set_index: i32, disk_index: i32, online: bool) -> rustfs_madmin::Disk {
|
||||
rustfs_madmin::Disk {
|
||||
pool_index: 0,
|
||||
set_index,
|
||||
disk_index,
|
||||
state: if online { "ok" } else { "offline" }.to_string(),
|
||||
runtime_state: Some(if online { "online" } else { "offline" }.to_string()),
|
||||
capacity_observation_source: Some("live_probe".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_diagnostic_status_tracks_read_quorum_and_capacity_source() {
|
||||
let online = vec![
|
||||
diagnostic_disk(0, 0, true),
|
||||
diagnostic_disk(0, 1, true),
|
||||
diagnostic_disk(1, 0, true),
|
||||
diagnostic_disk(1, 1, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pool_diagnostic_status(&online, 0, 2, 2, 1),
|
||||
PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Online,
|
||||
capacity_status: PoolCapacityStatus::Live,
|
||||
}
|
||||
);
|
||||
|
||||
let mut degraded = online.clone();
|
||||
degraded[0] = diagnostic_disk(0, 0, false);
|
||||
degraded[1].capacity_observation_source = Some("snapshot".to_string());
|
||||
assert_eq!(
|
||||
pool_diagnostic_status(°raded, 0, 2, 2, 1),
|
||||
PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Degraded,
|
||||
capacity_status: PoolCapacityStatus::Cached,
|
||||
}
|
||||
);
|
||||
|
||||
degraded[1] = diagnostic_disk(0, 1, false);
|
||||
assert_eq!(pool_diagnostic_status(°raded, 0, 2, 2, 1).availability, PoolAvailability::Offline);
|
||||
assert_eq!(
|
||||
pool_diagnostic_status(&[], 0, 2, 2, 1),
|
||||
PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Offline,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
}
|
||||
);
|
||||
assert_eq!(pool_diagnostic_status(&online, 0, 0, 2, 1).availability, PoolAvailability::Unknown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn admin_pool_diagnostic_snapshot_lists_an_unavailable_nonfirst_pool() {
|
||||
let (_temp_dirs, store, _other_store) = Box::pin(crate::services::rebalance::test_two_pool_stores(None)).await;
|
||||
for set in &store.pools[1].disk_set {
|
||||
let mut disks = set.disks.write().await;
|
||||
let disk_count = disks.len();
|
||||
*disks = vec![None; disk_count];
|
||||
}
|
||||
|
||||
let (snapshot, metadata_status) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), store.admin_pool_diagnostic_snapshot(None))
|
||||
.await
|
||||
.expect("pool diagnostics should remain bounded");
|
||||
|
||||
assert_eq!(metadata_status, "stale");
|
||||
assert_eq!(snapshot.len(), 2, "configured topology must remain visible");
|
||||
assert_eq!(snapshot[0].id, 0);
|
||||
assert_eq!(snapshot[1].id, 1);
|
||||
assert_eq!(snapshot[1].diagnostic.availability, PoolAvailability::Offline);
|
||||
store
|
||||
.pool_meta_save_gate
|
||||
.lock()
|
||||
.await
|
||||
.ensure_write_safe("ordinary object write")
|
||||
.expect("a failed diagnostic read must not latch the writer gate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_set_local_usage_cache_classification_is_exact() {
|
||||
for object in [
|
||||
@@ -27980,6 +28300,167 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// One version commits, then every in-place attempt to copy the next one
|
||||
/// times out on the target namespace lock. The entry is deferred, not
|
||||
/// failed, with both source versions retained; the resumed entry finds the
|
||||
/// committed version and moves both with their exact bytes.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn decommission_defers_target_lookup_lock_timeout_and_retries_without_losing_source() {
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
let fixture = NullVersionDecommission::new(NullVersionTop::NewerVersion).await;
|
||||
let object = fixture.object;
|
||||
let injected = Arc::new(AtomicUsize::new(0));
|
||||
let attempted = Arc::new(AtomicUsize::new(0));
|
||||
let fault = DecommissionTestFaultGuard::install_with_error(
|
||||
Arc::new({
|
||||
let injected = Arc::clone(&injected);
|
||||
let attempted = Arc::clone(&attempted);
|
||||
move |stage, _, faulted, _, success| {
|
||||
let target = stage == "before_target_commit" && faulted == object && success;
|
||||
let should_inject = target && attempted.fetch_add(1, Ordering::SeqCst) > 0;
|
||||
if should_inject {
|
||||
injected.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
should_inject
|
||||
}
|
||||
}),
|
||||
Some(Arc::new(|_, bucket, object| {
|
||||
data_movement::data_movement_stage_error_for_test(
|
||||
"decommission_object",
|
||||
"prepare_put_object",
|
||||
bucket,
|
||||
object,
|
||||
Error::Lock(rustfs_lock::LockError::timeout(
|
||||
format!("{bucket}/{object}@latest"),
|
||||
std::time::Duration::from_secs(5),
|
||||
)),
|
||||
)
|
||||
})),
|
||||
);
|
||||
let (outcome, _) = fixture.run_entry().await;
|
||||
assert_eq!(injected.load(Ordering::SeqCst), DECOMMISSION_VERSION_COPY_ATTEMPTS);
|
||||
fixture.assert_source_retained().await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.target_versions()
|
||||
.await
|
||||
.expect("the first version was copied")
|
||||
.versions
|
||||
.len(),
|
||||
1,
|
||||
"one version must commit before the next version times out"
|
||||
);
|
||||
assert!(
|
||||
matches!(&outcome, DecommissionEntryOutcome::Deferred(err) if is_decommission_copy_lock_timeout_deferral(err)),
|
||||
"{outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.store.pool_meta.read().await.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.expect("active decommission")
|
||||
.items_decommission_failed,
|
||||
0,
|
||||
"a deferred entry must not be counted as a permanent failure"
|
||||
);
|
||||
drop(fault);
|
||||
let (outcome, _) = fixture.run_entry().await;
|
||||
assert!(matches!(outcome, DecommissionEntryOutcome::Complete), "{outcome:?}");
|
||||
fixture.assert_moved().await;
|
||||
let mut reader = fixture.store.pools[1]
|
||||
.get_object_reader(
|
||||
fixture.bucket,
|
||||
fixture.object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(fixture.seeded.versions[0].version_id.expect("newer version").to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the newer version must remain readable");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read the newer version");
|
||||
assert_eq!(actual, b"written after versioning was enabled");
|
||||
}
|
||||
|
||||
/// A namespace lock still held on the target key, as by a copy whose node
|
||||
/// was killed mid-write, makes target selection fail with a typed
|
||||
/// object-lock timeout. A real held lock, not an injected error, must defer
|
||||
/// the entry rather than fail it; once the lock is released selection
|
||||
/// recovers and the resumed entry moves both versions.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn decommission_target_selection_returns_timeout_for_a_held_object_lock() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))], async {
|
||||
let fixture = NullVersionDecommission::new(NullVersionTop::NewerVersion).await;
|
||||
let target_set = fixture.store.pools[1].get_disks_by_key(fixture.object);
|
||||
let lock = target_set
|
||||
.new_ns_lock(fixture.bucket, fixture.object)
|
||||
.await
|
||||
.expect("target lock");
|
||||
let guard = lock
|
||||
.get_write_lock(std::time::Duration::from_secs(30))
|
||||
.await
|
||||
.expect("hold target lock");
|
||||
let opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
src_pool_idx: 0,
|
||||
version_id: Some(uuid::Uuid::nil().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let select = || {
|
||||
fixture
|
||||
.store
|
||||
.select_data_movement_pool_idx(fixture.bucket, fixture.object, 1, &opts, false)
|
||||
};
|
||||
let result = tokio::time::timeout(get_lock_acquire_timeout() + std::time::Duration::from_secs(15), select())
|
||||
.await
|
||||
.expect("target selection must terminate");
|
||||
match result {
|
||||
Err(Error::Lock(rustfs_lock::LockError::Timeout { resource, .. })) => {
|
||||
assert!(resource.contains(fixture.bucket) && resource.contains(fixture.object), "{resource}");
|
||||
}
|
||||
other => panic!("expected an object lock timeout, got {other:?}"),
|
||||
}
|
||||
|
||||
let (outcome, _) = fixture.run_entry().await;
|
||||
assert!(
|
||||
matches!(&outcome, DecommissionEntryOutcome::Deferred(err) if is_decommission_copy_lock_timeout_deferral(err)),
|
||||
"{outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.store.pool_meta.read().await.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.expect("active decommission")
|
||||
.items_decommission_failed,
|
||||
0,
|
||||
"a held target lock must not be counted as a permanent failure"
|
||||
);
|
||||
fixture.assert_source_retained().await;
|
||||
assert!(
|
||||
fixture.target_versions().await.is_none(),
|
||||
"no version may commit while the target lock is held"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(
|
||||
select()
|
||||
.await
|
||||
.expect("target selection must recover once the lock is released"),
|
||||
1
|
||||
);
|
||||
let (outcome, _) = fixture.run_entry().await;
|
||||
assert!(matches!(outcome, DecommissionEntryOutcome::Complete), "{outcome:?}");
|
||||
fixture.assert_moved().await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_object_attempt_metric_keeps_the_peak() {
|
||||
let mut attempts = 0;
|
||||
|
||||
@@ -1493,6 +1493,48 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
|
||||
project_data_usage_from_backend_cached(store, DataUsageInfo::clone).await
|
||||
}
|
||||
|
||||
/// Read one bucket's persisted usage without cloning the complete snapshot.
|
||||
pub async fn load_bucket_usage_from_backend_cached(
|
||||
store: Arc<ECStore>,
|
||||
bucket: &str,
|
||||
incarnation: uuid::Uuid,
|
||||
) -> Result<Option<u64>, Error> {
|
||||
project_data_usage_from_backend_cached(store, |info| certified_bucket_usage(info, bucket, incarnation)).await
|
||||
}
|
||||
|
||||
fn certified_bucket_usage(info: &DataUsageInfo, bucket: &str, incarnation: uuid::Uuid) -> Option<u64> {
|
||||
if incarnation.is_nil()
|
||||
|| !info.is_complete_bucket_usage_snapshot()
|
||||
|| info.usage_snapshot_observational
|
||||
|| info.scanner_cycle.is_none()
|
||||
|| info.scanner_epoch.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let pending_count = u64::try_from(info.usage_snapshot_pending_buckets.len()).ok()?;
|
||||
if pending_count != info.usage_snapshot_pending_bucket_count {
|
||||
return None;
|
||||
}
|
||||
match info.usage_snapshot_converged {
|
||||
Some(true) if pending_count == 0 => {}
|
||||
Some(false)
|
||||
if pending_count > 0
|
||||
&& info.usage_snapshot_pending_buckets.windows(2).all(|pair| pair[0] < pair[1])
|
||||
&& info
|
||||
.usage_snapshot_pending_buckets
|
||||
.binary_search_by(|pending| pending.as_str().cmp(bucket))
|
||||
.is_err() => {}
|
||||
_ => return None,
|
||||
}
|
||||
let usage = info.buckets_usage.get(bucket)?;
|
||||
let certified = usage
|
||||
.bucket_incarnation
|
||||
.as_deref()
|
||||
.and_then(|id| uuid::Uuid::parse_str(id).ok())
|
||||
.filter(|id| !id.is_nil())?;
|
||||
(certified == incarnation).then_some(usage.size)
|
||||
}
|
||||
|
||||
// Project under the cache guard so scalar readers never copy the snapshot's
|
||||
// bucket maps. The owned API shares the same TTL, refresh and invalidation path.
|
||||
async fn project_data_usage_from_backend_cached<T>(
|
||||
|
||||
@@ -129,6 +129,41 @@ fn projected_publication_moves_bucket_maps_into_cache() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_bucket_usage_rejects_unproven_and_pending_data() {
|
||||
let id = uuid::Uuid::from_u128(7);
|
||||
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
|
||||
info.usage_snapshot_converged = Some(true);
|
||||
info.scanner_cycle = Some(1);
|
||||
info.scanner_epoch = Some(1);
|
||||
info.buckets_usage.get_mut("known").unwrap().bucket_incarnation = Some(id.to_string());
|
||||
assert_eq!(certified_bucket_usage(&info, "known", id), Some(42));
|
||||
assert_eq!(certified_bucket_usage(&info, "known", uuid::Uuid::from_u128(8)), None);
|
||||
assert_eq!(certified_bucket_usage(&info, "known", uuid::Uuid::nil()), None);
|
||||
let mut legacy = info.clone();
|
||||
legacy.buckets_usage.get_mut("known").unwrap().bucket_incarnation = None;
|
||||
assert_eq!(certified_bucket_usage(&legacy, "known", id), None);
|
||||
legacy.buckets_usage.get_mut("known").unwrap().bucket_incarnation = Some("corrupt".into());
|
||||
assert_eq!(certified_bucket_usage(&legacy, "known", id), None);
|
||||
let mut pending = info.clone();
|
||||
pending.usage_snapshot_converged = Some(false);
|
||||
pending.usage_snapshot_pending_buckets = vec!["other".into()];
|
||||
pending.usage_snapshot_pending_bucket_count = 1;
|
||||
assert_eq!(certified_bucket_usage(&pending, "known", id), Some(42));
|
||||
pending.usage_snapshot_pending_buckets = vec!["known".into()];
|
||||
assert_eq!(certified_bucket_usage(&pending, "known", id), None);
|
||||
pending.usage_snapshot_pending_bucket_count = 0;
|
||||
assert_eq!(certified_bucket_usage(&pending, "known", id), None);
|
||||
pending.usage_snapshot_pending_buckets = vec!["z".into(), "a".into()];
|
||||
pending.usage_snapshot_pending_bucket_count = 2;
|
||||
assert_eq!(certified_bucket_usage(&pending, "known", id), None);
|
||||
let mut observational = info.clone();
|
||||
observational.usage_snapshot_observational = true;
|
||||
assert_eq!(certified_bucket_usage(&observational, "known", id), None);
|
||||
info.usage_snapshot_converged = None;
|
||||
assert_eq!(certified_bucket_usage(&info, "known", id), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
|
||||
@@ -140,6 +175,13 @@ async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
|
||||
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
|
||||
info.bucket_sizes.insert("empty".to_string(), 0);
|
||||
info.buckets_count = 2;
|
||||
info.usage_snapshot_converged = Some(true);
|
||||
info.scanner_cycle = Some(1);
|
||||
info.scanner_epoch = Some(1);
|
||||
let incarnation = uuid::Uuid::from_u128(7);
|
||||
for usage in info.buckets_usage.values_mut() {
|
||||
usage.bucket_incarnation = Some(incarnation.to_string());
|
||||
}
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
@@ -151,11 +193,29 @@ async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
|
||||
|
||||
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
|
||||
assert_eq!(persisted_bucket_usage(store.clone(), bucket).await, expected);
|
||||
assert_eq!(
|
||||
load_bucket_usage_from_backend_cached(store.clone(), bucket, incarnation)
|
||||
.await
|
||||
.expect("project bucket usage"),
|
||||
expected
|
||||
);
|
||||
let owned = load_data_usage_from_backend_cached(store.clone())
|
||||
.await
|
||||
.expect("owned cached snapshot");
|
||||
assert_eq!(expected, owned.buckets_usage.get(bucket).map(|usage| usage.size));
|
||||
}
|
||||
assert_eq!(
|
||||
load_bucket_usage_from_backend_cached(store.clone(), "known", uuid::Uuid::from_u128(8))
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
load_bucket_usage_from_backend_cached(store.clone(), "known", uuid::Uuid::nil())
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
info.buckets_usage.get_mut("known").expect("known bucket").size = 84;
|
||||
info.bucket_sizes.insert("known".to_string(), 84);
|
||||
save_config(
|
||||
|
||||
+434
-181
@@ -7762,10 +7762,6 @@ impl LocalDisk {
|
||||
// }
|
||||
|
||||
let multipart_check = self.multipart_delete_check(delete_path, true)?;
|
||||
if let Some(check) = &multipart_check {
|
||||
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
|
||||
.await?;
|
||||
}
|
||||
let err = if let Some(check) = &multipart_check {
|
||||
os::rename_all_with_precondition_and_parent_sync(
|
||||
delete_path,
|
||||
@@ -7842,13 +7838,15 @@ impl LocalDisk {
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
multipart_staging::reject_native_control_write(&self.root, self.io_root(), volume, path)?;
|
||||
let source_owner = self.acquire_multipart_delete_owner(volume, path, namespace_owner).await?;
|
||||
let mut source_owner = self.acquire_multipart_delete_owner(volume, path, namespace_owner).await?;
|
||||
let namespace_owner = source_owner.namespace_owner();
|
||||
if volume == RUSTFS_META_MULTIPART_BUCKET
|
||||
&& let Some(check) = self.multipart_delete_check(&self.io_get_object_path(volume, path)?, opt.recursive)?
|
||||
&& (opt.orphan_data_dir || (opt.recursive && opt.immediate))
|
||||
{
|
||||
self.validate_multipart_delete_admission(check, namespace_owner.clone())
|
||||
.await?;
|
||||
source_owner.mark_validated();
|
||||
}
|
||||
if pool_meta_native::reject_raw_path(&self.root, volume, path).is_err() {
|
||||
let owner = self.pool_meta_cleanup_owner(volume, path, &opt, namespace_owner).await?;
|
||||
@@ -8066,7 +8064,10 @@ impl LocalDisk {
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
let multipart_check = self.multipart_delete_check(&file_path, opt.recursive)?;
|
||||
if let Some(check) = &multipart_check {
|
||||
if !source_owner.is_validated()
|
||||
&& (opt.orphan_data_dir || opt.old_data_dir.is_some() || !opt.recursive)
|
||||
&& let Some(check) = &multipart_check
|
||||
{
|
||||
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
|
||||
.await?;
|
||||
}
|
||||
@@ -8313,8 +8314,6 @@ impl LocalDisk {
|
||||
// debug!("delete_file remove_dir done {:?}", &delete_path);
|
||||
} else if let Err(err) = {
|
||||
if let Some(check) = self.multipart_delete_check(delete_path, false)? {
|
||||
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
|
||||
.await?;
|
||||
let path = delete_path.clone();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(delete_path, namespace_owner.clone()).await;
|
||||
os::run_blocking_namespace_operation(lease, move || {
|
||||
@@ -8327,6 +8326,10 @@ impl LocalDisk {
|
||||
}
|
||||
} {
|
||||
// debug!("remove_file err {:?} when {:?}", &err, &delete_path);
|
||||
let err = match err.downcast::<DiskError>() {
|
||||
Ok(disk_err) => return Err(disk_err),
|
||||
Err(err) => err,
|
||||
};
|
||||
match err.kind() {
|
||||
ErrorKind::NotFound => (),
|
||||
_ => {
|
||||
@@ -8447,6 +8450,23 @@ impl LocalDisk {
|
||||
return Err(std::io::Error::other("metadata read worker cancelled"));
|
||||
}
|
||||
let _io_permit = io_permit;
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
{
|
||||
let opened = root.open_readonly(&path);
|
||||
if opened.is_ok() {
|
||||
os::prepared_publication_test_hooks::run(
|
||||
os::prepared_publication_test_hooks::Stage::MetadataReadAfterOpen,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
let result = read_fn(opened);
|
||||
os::prepared_publication_test_hooks::run(
|
||||
os::prepared_publication_test_hooks::Stage::MetadataReadAfterRead,
|
||||
&path,
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
#[cfg(not(all(any(test, feature = "test-util"), not(windows))))]
|
||||
Ok(read_fn(root.open_readonly(&path)))
|
||||
})
|
||||
.await
|
||||
@@ -11306,34 +11326,57 @@ impl LocalDisk {
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn batch_shard_pread(requests: Vec<(std::path::PathBuf, usize, usize)>) -> Vec<Result<Bytes>> {
|
||||
let n = requests.len();
|
||||
let metrics = rustfs_io_metrics::LocalBatchReadMetrics::capture();
|
||||
let submitted = metrics.as_ref().map(|_| std::time::Instant::now());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use rustfs_io_metrics::LocalBatchReadStage;
|
||||
use std::os::unix::fs::FileExt;
|
||||
|
||||
let execution_started = metrics.as_ref().map(|_| std::time::Instant::now());
|
||||
if let (Some(metrics), Some(submitted)) = (&metrics, submitted) {
|
||||
metrics.record_stage(LocalBatchReadStage::QueueWait, true, submitted.elapsed());
|
||||
metrics.record_batch(n);
|
||||
}
|
||||
let mut results = Vec::with_capacity(n);
|
||||
for (file_path, offset, length) in requests {
|
||||
let mut bytes_read = 0;
|
||||
let r = (|| -> Result<Bytes> {
|
||||
let meta = std::fs::metadata(&file_path).map_err(DiskError::from)?;
|
||||
let meta = measure_local_batch_stage(metrics.as_deref(), LocalBatchReadStage::Metadata, || {
|
||||
std::fs::metadata(&file_path).map_err(DiskError::from)
|
||||
})?;
|
||||
let end = offset.checked_add(length).ok_or(DiskError::FileCorrupt)?;
|
||||
if meta.len() < u64::try_from(end).unwrap_or(u64::MAX) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&file_path).map_err(DiskError::from)?;
|
||||
let file = measure_local_batch_stage(metrics.as_deref(), LocalBatchReadStage::Open, || {
|
||||
std::fs::File::open(&file_path).map_err(DiskError::from)
|
||||
})?;
|
||||
let mut buf = vec![0u8; length];
|
||||
let mut total = 0usize;
|
||||
while total < length {
|
||||
let nbytes = file
|
||||
.read_at(&mut buf[total..], u64::try_from(offset + total).unwrap_or(u64::MAX))
|
||||
.map_err(DiskError::from)?;
|
||||
if nbytes == 0 {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
total += nbytes;
|
||||
if length > 0 {
|
||||
measure_local_batch_stage(metrics.as_deref(), LocalBatchReadStage::Read, || {
|
||||
while bytes_read < length {
|
||||
let nbytes = file
|
||||
.read_at(&mut buf[bytes_read..], u64::try_from(offset + bytes_read).unwrap_or(u64::MAX))
|
||||
.map_err(DiskError::from)?;
|
||||
if nbytes == 0 {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
bytes_read += nbytes;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(Bytes::from(buf))
|
||||
})();
|
||||
if let Some(metrics) = &metrics {
|
||||
metrics.record_shard(r.is_ok(), length, bytes_read);
|
||||
}
|
||||
results.push(r);
|
||||
}
|
||||
if let (Some(metrics), Some(started)) = (&metrics, execution_started) {
|
||||
metrics.record_stage(LocalBatchReadStage::Execution, results.iter().all(Result::is_ok), started.elapsed());
|
||||
}
|
||||
results
|
||||
})
|
||||
.await
|
||||
@@ -11343,6 +11386,20 @@ pub(crate) async fn batch_shard_pread(requests: Vec<(std::path::PathBuf, usize,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn measure_local_batch_stage<T>(
|
||||
metrics: Option<&rustfs_io_metrics::LocalBatchReadMetrics>,
|
||||
stage: rustfs_io_metrics::LocalBatchReadStage,
|
||||
operation: impl FnOnce() -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let started = metrics.map(|_| std::time::Instant::now());
|
||||
let result = operation();
|
||||
if let (Some(metrics), Some(started)) = (metrics, started) {
|
||||
metrics.record_stage(stage, result.is_ok(), started.elapsed());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for LocalDisk {
|
||||
async fn supports_guarded_delete_versions(&self) -> Result<()> {
|
||||
@@ -12016,20 +12073,16 @@ impl DiskAPI for LocalDisk {
|
||||
// Publish the immutable generation before preparing the part switch.
|
||||
// Rollback retains the old generation; an unreferenced new index is
|
||||
// reclaimed with the upload directory if preparation is interrupted.
|
||||
os::rename_all_with_precondition(
|
||||
os::rename_all_with_precondition_and_parent_sync(
|
||||
&source,
|
||||
&destination,
|
||||
&dst_volume_dir,
|
||||
&self.publication_root,
|
||||
source_owner.clone(),
|
||||
admission.as_ref().map(|check| check.precondition()),
|
||||
durability.syncs_commit_metadata(),
|
||||
)
|
||||
.await?;
|
||||
if durability.syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(destination.parent().ok_or(DiskError::FileCorrupt)?, source_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
}
|
||||
let retained_owner = source_owner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -12157,17 +12210,16 @@ impl DiskAPI for LocalDisk {
|
||||
PartTransactionAction::Commit => (PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META),
|
||||
PartTransactionAction::Rollback => (PART_TRANSACTION_OLD_META, PART_TRANSACTION_NEW_META),
|
||||
};
|
||||
if let (Ok(current), Ok(retained), Ok(obsolete)) = (
|
||||
std::fs::read(¤t_meta_path),
|
||||
std::fs::read(transaction_path.join(retained_name)),
|
||||
std::fs::read(transaction_path.join(obsolete_name)),
|
||||
) && current == retained
|
||||
if let Ok(obsolete) = std::fs::read(transaction_path.join(obsolete_name))
|
||||
&& let Ok(obsolete) = ObjectPartInfo::unmarshal(&obsolete)
|
||||
&& let Some(integrity) = obsolete.integrity
|
||||
&& integrity.validate().is_ok()
|
||||
&& usize::try_from(integrity.number).ok() == Some(obsolete.number)
|
||||
&& current_data_path.file_name().and_then(|name| name.to_str())
|
||||
== Some(format!("part.{}", obsolete.number).as_str())
|
||||
&& let (Ok(current), Ok(retained)) =
|
||||
(std::fs::read(¤t_meta_path), std::fs::read(transaction_path.join(retained_name)))
|
||||
&& current == retained
|
||||
&& ObjectPartInfo::unmarshal(&retained)
|
||||
.ok()
|
||||
.and_then(|part| part.integrity)
|
||||
@@ -12208,16 +12260,6 @@ impl DiskAPI for LocalDisk {
|
||||
let source_owner = self.multipart_source_owner(dst_volume, dst_path, true, false, None).await?;
|
||||
multipart_staging::reject_reserved_anchor(dst_path)?;
|
||||
let admission = self.multipart_source_admission(dst_volume, dst_path, true, false)?;
|
||||
if let Some(check) = admission.clone() {
|
||||
let retained = source_owner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _owner = retained;
|
||||
check.validate()
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
pool_meta_native::reject_raw_path(&self.root, src_volume, src_path)?;
|
||||
pool_meta_native::reject_raw_path(&self.root, dst_volume, dst_path)?;
|
||||
let src_volume_dir = self.io_get_bucket_path(src_volume)?;
|
||||
@@ -12253,158 +12295,186 @@ impl DiskAPI for LocalDisk {
|
||||
check_path_length(src_file_path.to_string_lossy().as_ref())?;
|
||||
check_path_length(dst_file_path.to_string_lossy().as_ref())?;
|
||||
|
||||
if src_is_dir {
|
||||
let meta_op = match lstat_std(&src_file_path).map_err(|e| to_file_error(e).into()) {
|
||||
Ok(meta) => Some(meta),
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(meta) = meta_op
|
||||
&& !meta.is_dir()
|
||||
{
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "src_expected_dir_missing",
|
||||
path = ?src_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied);
|
||||
}
|
||||
|
||||
// Clear any stale destination before the directory rename. An absent
|
||||
// destination is the normal case when renaming a directory to a new
|
||||
// location, so tolerate NotFound instead of aborting the whole rename
|
||||
// (MinIO's RenameFile ignores osIsNotExist here).
|
||||
if let Err(e) = remove_std(&dst_file_path)
|
||||
&& e.kind() != ErrorKind::NotFound
|
||||
{
|
||||
return Err(to_file_error(e).into());
|
||||
}
|
||||
} else {
|
||||
let meta = lstat_std(&src_file_path).map_err(|e| -> DiskError { to_file_error(e).into() })?;
|
||||
if meta.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "src_unexpected_dir",
|
||||
path = ?src_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied);
|
||||
}
|
||||
}
|
||||
|
||||
let transaction_publish_meta = if src_is_dir {
|
||||
let dst_meta_path = if src_is_dir {
|
||||
None
|
||||
} else {
|
||||
let transaction_path = self.io_get_object_path(dst_volume, &crate::disk::part_transaction_path(dst_path))?;
|
||||
let transaction_meta_path = transaction_path.join(PART_TRANSACTION_NEW_META);
|
||||
match fs::read(&transaction_meta_path).await {
|
||||
Ok(expected_meta) => {
|
||||
if expected_meta.as_slice() != meta.as_ref() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
|
||||
let publish_meta_path = transaction_path.join(PART_TRANSACTION_PUBLISH_META);
|
||||
let source_meta_path = transaction_meta_path.clone();
|
||||
let publish_path = publish_meta_path.clone();
|
||||
let retained_owner = source_owner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _owner = retained_owner;
|
||||
remove_file_if_exists(&publish_path)?;
|
||||
std::fs::hard_link(source_meta_path, &publish_path)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?
|
||||
.map_err(to_file_error)?;
|
||||
Some(publish_meta_path)
|
||||
}
|
||||
// Old peers know only RenamePart. The new coordinator never
|
||||
// reaches rename unless prepare succeeded, so an absent
|
||||
// transaction directory identifies the rolling-upgrade legacy
|
||||
// path. A present directory without new.meta is corruption.
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => match fs::metadata(&transaction_path).await {
|
||||
Ok(_) => return Err(DiskError::FileCorrupt),
|
||||
Err(meta_err) if meta_err.kind() == ErrorKind::NotFound => None,
|
||||
Err(meta_err) => return Err(to_file_error(meta_err).into()),
|
||||
},
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
let path = self.io_get_object_path(dst_volume, &format!("{dst_path}.meta"))?;
|
||||
check_path_length(path.to_string_lossy().as_ref())?;
|
||||
Some(path)
|
||||
};
|
||||
let transaction_path = if src_is_dir {
|
||||
None
|
||||
} else {
|
||||
Some(self.io_get_object_path(dst_volume, &crate::disk::part_transaction_path(dst_path))?)
|
||||
};
|
||||
|
||||
// UploadPart is acknowledged once this rename lands, so the part data and
|
||||
// its directory entry must be durable before we return. Relaxed keeps the
|
||||
// part payload fdatasync but leaves the directory entry to the page cache.
|
||||
let durability = effective_durability(dst_volume);
|
||||
if durability.syncs_data_shards() && !src_is_dir {
|
||||
let src = src_file_path.clone();
|
||||
let retained_owner = source_owner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _owner = retained_owner;
|
||||
os::sync_file(&src)
|
||||
let sync = (durability.syncs_data_shards() && !src_is_dir) || durability.syncs_commit_metadata();
|
||||
let publication_root = self.publication_root.clone();
|
||||
let src_file_for_cleanup = src_file_path.clone();
|
||||
let src_volume_for_cleanup = src_volume_dir.clone();
|
||||
let meta_for_legacy = meta.clone();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&dst_file_path, source_owner.clone()).await;
|
||||
|
||||
let published_transaction_meta =
|
||||
os::run_blocking_namespace_operation_with_sync(lease, sync, move || -> std::io::Result<bool> {
|
||||
if let Some(check) = &admission {
|
||||
check.validate().map_err(to_file_error)?;
|
||||
}
|
||||
if src_is_dir {
|
||||
let meta_op = lstat_std(&src_file_path).map_err(to_file_error)?;
|
||||
if !meta_op.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "src_expected_dir_missing",
|
||||
path = ?src_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied.into());
|
||||
}
|
||||
|
||||
// Clear any stale destination before the directory rename. An absent
|
||||
// destination is the normal case when renaming a directory to a new
|
||||
// location, so tolerate NotFound instead of aborting the whole rename
|
||||
// (MinIO's RenameFile ignores osIsNotExist here).
|
||||
if let Err(e) = remove_std(&dst_file_path)
|
||||
&& e.kind() != ErrorKind::NotFound
|
||||
{
|
||||
return Err(to_file_error(e));
|
||||
}
|
||||
} else {
|
||||
let meta_op = lstat_std(&src_file_path).map_err(to_file_error)?;
|
||||
if meta_op.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "src_unexpected_dir",
|
||||
path = ?src_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied.into());
|
||||
}
|
||||
}
|
||||
|
||||
let transaction_publish_meta = if let Some(transaction_path) = transaction_path.as_ref() {
|
||||
let transaction_meta_path = transaction_path.join(PART_TRANSACTION_NEW_META);
|
||||
match std::fs::File::open(&transaction_meta_path) {
|
||||
Ok(mut file) => {
|
||||
let file_len = file.metadata().map_err(to_file_error)?.len();
|
||||
if file_len != meta.len() as u64 {
|
||||
return Err(DiskError::FileCorrupt.into());
|
||||
}
|
||||
let mut expected_meta = Vec::with_capacity(meta.len());
|
||||
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, file_len), &mut expected_meta)
|
||||
.map_err(to_file_error)?;
|
||||
drop(file);
|
||||
if expected_meta.as_slice() != meta.as_ref() {
|
||||
return Err(DiskError::FileCorrupt.into());
|
||||
}
|
||||
|
||||
let publish_meta_path = transaction_path.join(PART_TRANSACTION_PUBLISH_META);
|
||||
remove_file_if_exists(&publish_meta_path).map_err(to_file_error)?;
|
||||
std::fs::hard_link(&transaction_meta_path, &publish_meta_path).map_err(to_file_error)?;
|
||||
Some(publish_meta_path)
|
||||
}
|
||||
// Old peers know only RenamePart. The new coordinator never
|
||||
// reaches rename unless prepare succeeded, so an absent
|
||||
// transaction directory identifies the rolling-upgrade legacy
|
||||
// path. A present directory without new.meta is corruption.
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => match std::fs::metadata(transaction_path) {
|
||||
Ok(_) => return Err(DiskError::FileCorrupt.into()),
|
||||
Err(meta_err) if meta_err.kind() == ErrorKind::NotFound => None,
|
||||
Err(meta_err) => return Err(to_file_error(meta_err)),
|
||||
},
|
||||
Err(err) => return Err(to_file_error(err)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if durability.syncs_data_shards() && !src_is_dir {
|
||||
os::sync_file(&src_file_path).map_err(to_file_error)?;
|
||||
}
|
||||
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
let precondition = admission.as_ref().map(|check| check.precondition());
|
||||
#[cfg(not(all(any(test, feature = "test-util"), not(windows))))]
|
||||
let precondition: Option<Arc<dyn Fn() -> std::io::Result<()> + Send + Sync>> = None;
|
||||
|
||||
os::rename_all_with_precondition_and_parent_sync_std(
|
||||
&src_file_path,
|
||||
&dst_file_path,
|
||||
&dst_volume_dir,
|
||||
&publication_root,
|
||||
precondition.as_deref(),
|
||||
durability.syncs_commit_metadata() && transaction_publish_meta.is_none(),
|
||||
)
|
||||
.map_err(to_file_error)?;
|
||||
|
||||
let dst_meta = lstat_std(&dst_file_path).map_err(to_file_error)?;
|
||||
if src_is_dir != dst_meta.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "dst_type_changed_after_rename",
|
||||
path = ?dst_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied.into());
|
||||
}
|
||||
|
||||
if let Some(transaction_publish_meta) = transaction_publish_meta {
|
||||
let dst_meta_path = dst_meta_path.as_ref().ok_or(DiskError::InvalidPath)?;
|
||||
os::rename_all_with_precondition_and_parent_sync_std(
|
||||
&transaction_publish_meta,
|
||||
dst_meta_path,
|
||||
&dst_volume_dir,
|
||||
&publication_root,
|
||||
precondition.as_deref(),
|
||||
durability.syncs_commit_metadata(),
|
||||
)
|
||||
.map_err(to_file_error)?;
|
||||
|
||||
for dir in src_file_path.ancestors().skip(1) {
|
||||
if dir == src_volume_dir.as_path() || !dir.starts_with(&src_volume_dir) || is_root_path(dir) {
|
||||
break;
|
||||
}
|
||||
match std::fs::remove_dir(dir) {
|
||||
Ok(()) => {}
|
||||
Err(err) if is_dir_not_empty_error(&err) || is_benign_object_rmdir_error(&err) => break,
|
||||
Err(err) => {
|
||||
return Err(Error::other(FileAccessDeniedWithContext {
|
||||
path: dir.to_path_buf(),
|
||||
source: err,
|
||||
})
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
.map_err(DiskError::from)?;
|
||||
|
||||
os::rename_all_with_precondition(
|
||||
&src_file_path,
|
||||
&dst_file_path,
|
||||
&dst_volume_dir,
|
||||
&self.publication_root,
|
||||
source_owner.clone(),
|
||||
admission.as_ref().map(|check| check.precondition()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if durability.syncs_commit_metadata()
|
||||
&& let Some(parent) = dst_file_path.parent()
|
||||
{
|
||||
os::fsync_dir_with_owner(parent, source_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
|
||||
let dst_meta = lstat_std(&dst_file_path).map_err(|e| -> DiskError { to_file_error(e).into() })?;
|
||||
if src_is_dir != dst_meta.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "dst_type_changed_after_rename",
|
||||
path = ?dst_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied);
|
||||
}
|
||||
|
||||
if let Some(transaction_publish_meta) = transaction_publish_meta {
|
||||
let dst_meta_path = self.io_get_object_path(dst_volume, &format!("{dst_path}.meta"))?;
|
||||
os::rename_all_with_precondition(
|
||||
&transaction_publish_meta,
|
||||
&dst_meta_path,
|
||||
&dst_volume_dir,
|
||||
&self.publication_root,
|
||||
if !published_transaction_meta {
|
||||
self.write_all_meta_with_namespace_owner(
|
||||
dst_volume,
|
||||
&format!("{dst_path}.meta"),
|
||||
&meta_for_legacy,
|
||||
true,
|
||||
source_owner.clone(),
|
||||
admission.as_ref().map(|check| check.precondition()),
|
||||
)
|
||||
.await?;
|
||||
if durability.syncs_commit_metadata()
|
||||
&& let Some(parent) = dst_meta_path.parent()
|
||||
{
|
||||
os::fsync_dir_with_owner(parent, source_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
} else {
|
||||
self.write_all_meta_with_namespace_owner(dst_volume, &format!("{dst_path}.meta"), &meta, true, source_owner.clone())
|
||||
.await?;
|
||||
}
|
||||
|
||||
if src_is_dir {
|
||||
@@ -12418,8 +12488,9 @@ impl DiskAPI for LocalDisk {
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(parent) = src_file_path.parent() {
|
||||
self.delete_file(&src_volume_dir, &parent.to_path_buf(), false, false).await?;
|
||||
if !published_transaction_meta && let Some(parent) = src_file_for_cleanup.parent() {
|
||||
self.delete_file(&src_volume_for_cleanup, &parent.to_path_buf(), false, false)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -12895,12 +12966,26 @@ impl DiskAPI for LocalDisk {
|
||||
.map_err(|e| to_access_error(e, DiskError::VolumeAccessDenied))?;
|
||||
}
|
||||
|
||||
let mut cached_owner: Option<(String, Option<Arc<dyn Send + Sync>>)> = None;
|
||||
for path in paths.iter() {
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
|
||||
let owner = self.multipart_source_owner(volume, path, true, true, None).await?;
|
||||
let owner = if volume == RUSTFS_META_MULTIPART_BUCKET {
|
||||
let scope_key = multipart_staging::upload_scope_root(path, true).to_ascii_lowercase();
|
||||
if let Some((cached_key, cached)) = &cached_owner
|
||||
&& cached_key == &scope_key
|
||||
{
|
||||
cached.clone()
|
||||
} else {
|
||||
let acquired = self.multipart_source_owner(volume, path, true, true, None).await?;
|
||||
cached_owner = Some((scope_key, acquired.clone()));
|
||||
acquired
|
||||
}
|
||||
} else {
|
||||
self.multipart_source_owner(volume, path, true, true, None).await?
|
||||
};
|
||||
self.move_to_trash_with_namespace_owner(&file_path, false, false, owner)
|
||||
.await?;
|
||||
|
||||
@@ -26503,6 +26588,71 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_part_leaves_metadata_reads_on_foreground_lane() {
|
||||
let dir = tempfile::tempdir().expect("disk root");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_string_lossy().as_ref()).expect("endpoint");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
|
||||
ensure_test_volume(&disk, "tmp").await;
|
||||
ensure_test_volume(&disk, "bucket").await;
|
||||
|
||||
disk.write_metadata(
|
||||
"",
|
||||
"bucket",
|
||||
"unrelated/object",
|
||||
test_file_info("unrelated/object", Uuid::new_v4(), Some(Uuid::new_v4()), None),
|
||||
)
|
||||
.await
|
||||
.expect("write unrelated object metadata");
|
||||
|
||||
let payload = Bytes::from_static(b"part payload");
|
||||
let meta = Bytes::from(
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
size: payload.len(),
|
||||
..Default::default()
|
||||
}
|
||||
.marshal_msg()
|
||||
.expect("part metadata"),
|
||||
);
|
||||
disk.write_all("tmp", "upload/part.1", payload.clone())
|
||||
.await
|
||||
.expect("stage part");
|
||||
disk.prepare_part_transaction("tmp", "upload/part.1", "bucket", "object/part.1", meta.clone())
|
||||
.await
|
||||
.expect("prepare part transaction");
|
||||
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let queue = Arc::clone(&disk.blocking_queue);
|
||||
let blocker = tokio::spawn(async move {
|
||||
queue
|
||||
.run_fsync_cancellable(move |_| {
|
||||
let _ = started_tx.send(());
|
||||
let _ = release_rx.blocking_recv();
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
});
|
||||
started_rx.await.expect("fsync lane blocked");
|
||||
|
||||
let mut rename = std::pin::pin!(disk.rename_part("tmp", "upload/part.1", "bucket", "object/part.1", meta.clone()));
|
||||
assert!(futures::FutureExt::now_or_never(&mut rename).is_none());
|
||||
|
||||
let read = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
disk.read_version("", "bucket", "unrelated/object", "", &ReadOptions::default()),
|
||||
)
|
||||
.await
|
||||
.expect("read_version on unrelated key must not be blocked by rename_part sync")
|
||||
.expect("unrelated object metadata should be readable");
|
||||
assert_eq!(read.name, "unrelated/object");
|
||||
|
||||
release_tx.send(()).expect("release fsync lane");
|
||||
blocker.await.expect("blocker joined").expect("blocker completed");
|
||||
rename.await.expect("rename_part completed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_read_job_retains_resource_unit_until_native_completion() {
|
||||
use crate::disk::with_background_resource_unit;
|
||||
@@ -30861,6 +31011,109 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_batch_read_metrics_cover_success_and_unattempted_failure_stages() {
|
||||
use crate::test_metrics::CapturingRecorder;
|
||||
|
||||
let dir = tempfile::tempdir().expect("batch metrics directory");
|
||||
let good = dir.path().join("good.bin");
|
||||
std::fs::write(&good, b"good data").expect("write batch metrics payload");
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("batch metrics runtime");
|
||||
let recorder = CapturingRecorder::default();
|
||||
let previous = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
let results = metrics::with_local_recorder(&recorder, || {
|
||||
runtime.block_on(batch_shard_pread(vec![
|
||||
(good.clone(), 0, 9),
|
||||
(dir.path().join("missing.bin"), 0, 4),
|
||||
(good.clone(), 0, 1024),
|
||||
(good, 0, 0),
|
||||
]))
|
||||
});
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(previous);
|
||||
|
||||
assert_eq!(results[0].as_ref().expect("valid shard read").as_ref(), b"good data");
|
||||
assert!(results[1].is_err(), "missing shard must remain an error");
|
||||
assert!(matches!(results[2], Err(DiskError::FileCorrupt)), "range failure must preserve its error");
|
||||
assert!(results[3].as_ref().expect("empty range succeeds").is_empty());
|
||||
let counter = "rustfs_io_get_object_local_batch_shards_total";
|
||||
assert_eq!(recorder.counter_value(counter, &[("outcome", "success")]), 2);
|
||||
assert_eq!(recorder.counter_value(counter, &[("outcome", "error")]), 2);
|
||||
assert_eq!(recorder.counter_value("rustfs_io_get_object_local_batch_read_bytes_total", &[]), 9);
|
||||
let stages = "rustfs_io_get_object_local_batch_stage_seconds";
|
||||
for (stage, success, errors) in [("metadata", 3, 1), ("open", 2, 0), ("read", 1, 0), ("execution", 0, 1)] {
|
||||
assert_eq!(
|
||||
recorder
|
||||
.histogram_values(stages, &[("stage", stage), ("outcome", "success")])
|
||||
.len(),
|
||||
success
|
||||
);
|
||||
assert_eq!(
|
||||
recorder
|
||||
.histogram_values(stages, &[("stage", stage), ("outcome", "error")])
|
||||
.len(),
|
||||
errors
|
||||
);
|
||||
}
|
||||
assert_eq!(recorder.histogram_values(stages, &[("stage", "queue_wait")]).len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_batch_read_metrics_survive_caller_cancellation() {
|
||||
use crate::test_metrics::CapturingRecorder;
|
||||
|
||||
let dir = tempfile::tempdir().expect("cancelled batch directory");
|
||||
let path = dir.path().join("shard");
|
||||
std::fs::write(&path, b"data").expect("write cancellation fixture");
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.max_blocking_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("single blocking-worker runtime");
|
||||
let recorder = CapturingRecorder::default();
|
||||
let previous = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
runtime.block_on(async {
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let blocker = tokio::task::spawn_blocking(move || {
|
||||
started_tx.send(()).expect("signal occupied blocking worker");
|
||||
release_rx.recv().expect("release occupied blocking worker");
|
||||
});
|
||||
started_rx.await.expect("blocking worker has started");
|
||||
let mut batch = Box::pin(batch_shard_pread(vec![(path, 0, 4)]));
|
||||
let pending = futures::FutureExt::now_or_never(&mut batch).is_none();
|
||||
drop(batch);
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
|
||||
release_tx.send(()).expect("release worker after dropping caller");
|
||||
blocker.await.expect("blocking worker finishes");
|
||||
assert!(pending, "batch must have been submitted while the worker was occupied");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while recorder
|
||||
.histogram_values("rustfs_io_get_object_local_batch_stage_seconds", &[("stage", "execution")])
|
||||
.is_empty()
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("detached batch must still record completion");
|
||||
});
|
||||
});
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(previous);
|
||||
assert_eq!(
|
||||
recorder.counter_value("rustfs_io_get_object_local_batch_shards_total", &[("outcome", "success")]),
|
||||
1
|
||||
);
|
||||
assert_eq!(recorder.counter_value("rustfs_io_get_object_local_batch_read_bytes_total", &[]), 4);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn test_batch_shard_pread_basic() {
|
||||
|
||||
@@ -548,11 +548,18 @@ impl AbortDeletePermit {
|
||||
pub(super) struct MultipartDeleteOwner {
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
source_key: Option<PathBuf>,
|
||||
admission_validated: bool,
|
||||
}
|
||||
impl MultipartDeleteOwner {
|
||||
pub(super) fn namespace_owner(&self) -> Option<Arc<dyn Send + Sync>> {
|
||||
self.owner.clone()
|
||||
}
|
||||
pub(super) fn mark_validated(&mut self) {
|
||||
self.admission_validated = true;
|
||||
}
|
||||
pub(super) fn is_validated(&self) -> bool {
|
||||
self.admission_validated
|
||||
}
|
||||
pub(super) fn chain(self, owner: Option<Arc<dyn Send + Sync>>) -> Self {
|
||||
let owner = match (self.owner, owner) {
|
||||
(None, owner) | (owner, None) => owner,
|
||||
@@ -562,6 +569,7 @@ impl MultipartDeleteOwner {
|
||||
Self {
|
||||
owner,
|
||||
source_key: self.source_key,
|
||||
admission_validated: self.admission_validated,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -582,7 +590,11 @@ impl LocalDisk {
|
||||
) -> Result<MultipartDeleteOwner> {
|
||||
let source_key = self.multipart_delete_owner_key(volume, path)?;
|
||||
let owner = self.multipart_source_owner(volume, path, true, true, owner).await?;
|
||||
Ok(MultipartDeleteOwner { owner, source_key })
|
||||
Ok(MultipartDeleteOwner {
|
||||
owner,
|
||||
source_key,
|
||||
admission_validated: false,
|
||||
})
|
||||
}
|
||||
pub(super) fn validate_multipart_delete_owner(
|
||||
&self,
|
||||
|
||||
@@ -552,25 +552,33 @@ impl LocalDisk {
|
||||
let target = self.get_object_path(&publication.lineage.bucket, &publication.lineage.object)?;
|
||||
let _target_owner = os::acquire_metadata_mutation_lease(&target, store.owner.clone()).await;
|
||||
if require_target {
|
||||
let raw = store
|
||||
.read_target(publication)
|
||||
.await?
|
||||
.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if !store.target_matches(publication, &raw)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
store.sync_target(publication).await?;
|
||||
let expected_publication = publication.clone();
|
||||
let physical_slot = store.physical_slot;
|
||||
let disk_count = store.disk_ids.len();
|
||||
store
|
||||
.read_and_sync_target(publication, move |raw| {
|
||||
if !super::multipart_source_state::SourceStore::target_matches_for_slot(
|
||||
&expected_publication,
|
||||
raw,
|
||||
physical_slot,
|
||||
disk_count,
|
||||
)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
if !matches!(
|
||||
let snapshot = if !matches!(
|
||||
state.phase,
|
||||
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
|
||||
) {
|
||||
state.capability_sha256 = None;
|
||||
state.advance(MultipartSourcePhase::LocalTerminalSeal)?;
|
||||
store.persist(&state).await?;
|
||||
}
|
||||
let current = store.load().await?;
|
||||
let snapshot = current.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
store.persist_with_source_sync(&state, false).await?
|
||||
} else {
|
||||
loaded.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?
|
||||
};
|
||||
if snapshot.head_sha256.is_some()
|
||||
|| snapshot.capability_sha256.is_some()
|
||||
|| snapshot.publication.as_ref() != Some(publication)
|
||||
|
||||
@@ -335,6 +335,48 @@ fn target_backup_path(source: &Path, owner: Uuid) -> PathBuf {
|
||||
source.join(".mpu-completions").join(owner.to_string()).join("target.xl.meta")
|
||||
}
|
||||
|
||||
fn terminal_record_path(source: &Path, state: &SourceState) -> Option<PathBuf> {
|
||||
matches!(
|
||||
state.phase,
|
||||
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
|
||||
)
|
||||
.then(|| {
|
||||
source
|
||||
.join(".mpu-completions")
|
||||
.join(state.backup_owner.to_string())
|
||||
.join(format!("terminal-{}.msgpack", state.revision))
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot_for_state(
|
||||
disk_id: Uuid,
|
||||
state: &SourceState,
|
||||
state_raw: &[u8],
|
||||
head_sha256: Option<[u8; 32]>,
|
||||
) -> Result<MultipartSourceSnapshot> {
|
||||
let state_sha256 = raw_sha(state_raw);
|
||||
let inventory = raw_sha(&encode(&(
|
||||
Some(state_sha256),
|
||||
head_sha256,
|
||||
state.original_sha256,
|
||||
state.negative_certificate_sha256,
|
||||
state.ready_certificate_sha256,
|
||||
))?);
|
||||
Ok(MultipartSourceSnapshot {
|
||||
disk_id,
|
||||
physical_slot: state.physical_slot,
|
||||
lineage: state.lineage.clone(),
|
||||
generation: state.generation,
|
||||
revision: state.revision,
|
||||
phase: state.phase,
|
||||
state_sha256,
|
||||
head_sha256,
|
||||
inventory_sha256: inventory,
|
||||
capability_sha256: state.capability_sha256,
|
||||
publication: state.publication.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl SourceStore {
|
||||
pub fn validate_certificate_topology(&self, certificate: &MultipartCertificate) -> Result<()> {
|
||||
if certificate.disk_ids != self.disk_ids || certificate.disk_ids.get(self.physical_slot) != Some(&self.disk_id) {
|
||||
@@ -369,33 +411,67 @@ impl SourceStore {
|
||||
.map_err(DiskError::from)?
|
||||
}
|
||||
pub async fn persist(&self, state: &SourceState) -> Result<()> {
|
||||
self.persist_with_source_sync(state, false).await
|
||||
self.persist_with_source_sync(state, false).await.map(|_| ())
|
||||
}
|
||||
async fn persist_with_source_sync(&self, state: &SourceState, sync_source_before: bool) -> Result<()> {
|
||||
pub(super) async fn persist_with_source_sync(
|
||||
&self,
|
||||
state: &SourceState,
|
||||
sync_source_before: bool,
|
||||
) -> Result<MultipartSourceSnapshot> {
|
||||
state.validate(self.disk_id, &self.path)?;
|
||||
let raw = encode(state)?;
|
||||
let snapshot = snapshot_for_state(self.disk_id, state, &raw, None)?;
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
let terminal = terminal_record_path(&source, state);
|
||||
os::run_blocking_namespace_operation_with_sync(self.lease.clone(), true, move || {
|
||||
Self::persist_state_std(&source, &base, &root, &raw, terminal.as_deref(), sync_source_before)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
fn persist_state_std(
|
||||
source: &Path,
|
||||
base: &Path,
|
||||
root: &os::PublicationRoot,
|
||||
raw: &[u8],
|
||||
terminal: Option<&Path>,
|
||||
sync_source_before: bool,
|
||||
) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
let mut pinned = os::pin_multipart_source_directory(source, base, root)?;
|
||||
#[cfg(not(unix))]
|
||||
let _pinned = os::pin_multipart_source_directory(source, base, root)?;
|
||||
if sync_source_before {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| IoError::other("empty source directory guard"))?);
|
||||
os::fsync_directory_handle_std(source, Some(&opened))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
os::fsync_dir_std(source)?;
|
||||
}
|
||||
if let Some(path) = terminal {
|
||||
os::replace_multipart_source_metadata(path, raw, base, root)?;
|
||||
}
|
||||
os::replace_multipart_source_metadata(&source.join(STATE), raw, base, root)
|
||||
}
|
||||
async fn write_owned_backup_and_persist(&self, owner: Uuid, target: bool, bytes: &[u8], state: &SourceState) -> Result<()> {
|
||||
if owner.is_nil() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
state.validate(self.disk_id, &self.path)?;
|
||||
let raw = encode(state)?;
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
let terminal = matches!(
|
||||
state.phase,
|
||||
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
|
||||
)
|
||||
.then(|| {
|
||||
source
|
||||
.join(".mpu-completions")
|
||||
.join(state.backup_owner.to_string())
|
||||
.join(format!("terminal-{}.msgpack", state.revision))
|
||||
});
|
||||
os::run_blocking_namespace_operation(self.lease.clone(), move || {
|
||||
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
|
||||
if sync_source_before {
|
||||
os::fsync_dir_std(&source)?;
|
||||
}
|
||||
if let Some(path) = terminal {
|
||||
os::replace_multipart_source_metadata(&path, &raw, &base, &root)?;
|
||||
}
|
||||
os::replace_multipart_source_metadata(&source.join(STATE), &raw, &base, &root)
|
||||
let terminal = terminal_record_path(&source, state);
|
||||
let bytes = bytes.to_vec();
|
||||
os::run_blocking_namespace_operation_with_sync(self.lease.clone(), true, move || {
|
||||
Self::write_owned_backup_std(&source, &base, &root, owner, target, &bytes)?;
|
||||
Self::persist_state_std(&source, &base, &root, &raw, terminal.as_deref(), false)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
@@ -449,54 +525,65 @@ impl SourceStore {
|
||||
restore_from: None,
|
||||
abort: None,
|
||||
};
|
||||
self.write_owned_backup(state.backup_owner, false, original).await?;
|
||||
self.persist(&state).await?;
|
||||
self.write_owned_backup_and_persist(state.backup_owner, false, original, &state)
|
||||
.await?;
|
||||
Ok(state)
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub async fn write_owned_backup(&self, owner: Uuid, target: bool, bytes: &[u8]) -> Result<()> {
|
||||
if owner.is_nil() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let path = if target {
|
||||
target_backup_path(&self.source, owner)
|
||||
} else {
|
||||
original_path(&self.source, owner)
|
||||
};
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
let bytes = bytes.to_vec();
|
||||
os::run_blocking_namespace_operation(self.lease.clone(), move || {
|
||||
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("source backup has no parent"))?;
|
||||
let _parents = os::mkdir_all_below_existing_base_std(parent, &base, &root)?;
|
||||
let existed = if target {
|
||||
match os::observe_multipart_source_metadata(&path, &base, &root, bytes.len() as u64) {
|
||||
Ok((length, sha)) if length == bytes.len() as u64 && sha == raw_sha(&bytes) => true,
|
||||
Ok(_) => {
|
||||
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable target backup changed"));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
} else if let Some(previous) = read_optional(&path, &base, &root).map_err(IoError::other)? {
|
||||
if previous.as_ref() != bytes.as_slice() {
|
||||
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable source backup changed"));
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !existed {
|
||||
os::replace_multipart_source_metadata(&path, &bytes, &base, &root)?;
|
||||
}
|
||||
os::sync_multipart_owned_record_parents(&source, parent, existed, &base, &root)
|
||||
Self::write_owned_backup_std(&source, &base, &root, owner, target, &bytes)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
}
|
||||
fn write_owned_backup_std(
|
||||
source: &Path,
|
||||
base: &Path,
|
||||
root: &os::PublicationRoot,
|
||||
owner: Uuid,
|
||||
target: bool,
|
||||
bytes: &[u8],
|
||||
) -> std::io::Result<()> {
|
||||
let path = if target {
|
||||
target_backup_path(source, owner)
|
||||
} else {
|
||||
original_path(source, owner)
|
||||
};
|
||||
let _pin = os::pin_multipart_source_directory(source, base, root)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("source backup has no parent"))?;
|
||||
let _parents = os::mkdir_all_below_existing_base_std(parent, base, root)?;
|
||||
let existed = if target {
|
||||
match os::observe_multipart_source_metadata(&path, base, root, bytes.len() as u64) {
|
||||
Ok((length, sha)) if length == bytes.len() as u64 && sha == raw_sha(bytes) => true,
|
||||
Ok(_) => {
|
||||
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable target backup changed"));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
} else if let Some(previous) = read_optional(&path, base, root).map_err(IoError::other)? {
|
||||
if previous.as_ref() != bytes {
|
||||
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable source backup changed"));
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !existed {
|
||||
os::replace_multipart_source_metadata(&path, bytes, base, root)?;
|
||||
}
|
||||
os::sync_multipart_owned_record_parents(source, parent, existed, base, root)
|
||||
}
|
||||
pub async fn replace_head(&self, bytes: &[u8]) -> Result<()> {
|
||||
let path = self.source.join(STORAGE_FORMAT_FILE);
|
||||
let base = self.base.clone();
|
||||
@@ -677,6 +764,14 @@ impl SourceStore {
|
||||
Ok(metadata.map(|_| bytes))
|
||||
}
|
||||
pub fn target_matches(&self, publication: &PublicationIdentity, raw: &[u8]) -> Result<bool> {
|
||||
Self::target_matches_for_slot(publication, raw, self.physical_slot, self.disk_ids.len())
|
||||
}
|
||||
pub(super) fn target_matches_for_slot(
|
||||
publication: &PublicationIdentity,
|
||||
raw: &[u8],
|
||||
physical_slot: usize,
|
||||
disk_count: usize,
|
||||
) -> Result<bool> {
|
||||
let info = file_info(raw, &publication.lineage.bucket, &publication.lineage.object, publication.version_id)?;
|
||||
let slot = publication
|
||||
.lineage
|
||||
@@ -698,8 +793,8 @@ impl SourceStore {
|
||||
&& info.erasure.parity_blocks == publication.lineage.geometry.parity_blocks
|
||||
&& info.erasure.block_size == publication.lineage.geometry.block_size
|
||||
&& info.erasure.distribution == publication.lineage.geometry.distribution
|
||||
&& slot == self.physical_slot
|
||||
&& publication.lineage.geometry.disks() == self.disk_ids.len())
|
||||
&& slot == physical_slot
|
||||
&& publication.lineage.geometry.disks() == disk_count)
|
||||
}
|
||||
pub async fn restore_exact_target(&self, state: &SourceState, owner: Option<Arc<dyn Send + Sync>>) -> Result<()> {
|
||||
let publication = state.publication.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
@@ -925,26 +1020,12 @@ pub(super) fn load_source(
|
||||
}
|
||||
}
|
||||
let head_sha = head.as_ref().map(|head| head.sha256);
|
||||
let inventory = raw_sha(&encode(&(
|
||||
state_raw.as_ref().map(|bytes| raw_sha(bytes)),
|
||||
head_sha,
|
||||
state.original_sha256,
|
||||
state.negative_certificate_sha256,
|
||||
state.ready_certificate_sha256,
|
||||
))?);
|
||||
snapshot = Some(MultipartSourceSnapshot {
|
||||
snapshot = Some(snapshot_for_state(
|
||||
disk_id,
|
||||
physical_slot: state.physical_slot,
|
||||
lineage: state.lineage.clone(),
|
||||
generation: state.generation,
|
||||
revision: state.revision,
|
||||
phase: state.phase,
|
||||
state_sha256: raw_sha(state_raw.as_ref().ok_or(DiskError::FileCorrupt)?),
|
||||
head_sha256: head_sha,
|
||||
inventory_sha256: inventory,
|
||||
capability_sha256: state.capability_sha256,
|
||||
publication: state.publication.clone(),
|
||||
});
|
||||
state,
|
||||
state_raw.as_ref().ok_or(DiskError::FileCorrupt)?,
|
||||
head_sha,
|
||||
)?);
|
||||
} else if let Some(observed) = &head {
|
||||
let raw = observed.raw().ok_or(DiskError::FileCorrupt)?;
|
||||
let current = get_file_info(
|
||||
@@ -1251,11 +1332,7 @@ impl SourceStore {
|
||||
directory: &Path,
|
||||
lease: Arc<os::NamespaceMutationLease>,
|
||||
) -> Result<()> {
|
||||
let loaded = self.load().await?;
|
||||
let current = loaded.state.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if encode(current)? != encode(state)?
|
||||
|| !loaded.head_matches_state
|
||||
|| state.lineage != publication.lineage
|
||||
if state.lineage != publication.lineage
|
||||
|| state.generation != publication.source_generation
|
||||
|| state.disk_id != self.disk_id
|
||||
|| state.physical_slot != self.physical_slot
|
||||
@@ -1492,8 +1569,7 @@ impl LocalDisk {
|
||||
};
|
||||
// Copy the small source FI to an independent immutable inode. Its
|
||||
// canonical inode is never truncated to build the destination proxy.
|
||||
store.write_owned_backup(owner, false, original).await?;
|
||||
store.persist(&state).await?;
|
||||
store.write_owned_backup_and_persist(owner, false, original, &state).await?;
|
||||
let prepared_path = store.source.join(format!(".mpu-prepared-{owner}"));
|
||||
let context = NativeMultipartPublication {
|
||||
store,
|
||||
@@ -1536,14 +1612,19 @@ impl NativeMultipartPublication {
|
||||
length: new_metadata.len() as u64,
|
||||
};
|
||||
if let Some(previous) = old_metadata {
|
||||
self.store.write_owned_backup(state.backup_owner, true, previous).await?;
|
||||
state.previous_target_sha256 = Some(raw_sha(previous));
|
||||
state.previous_target_length = Some(previous.len() as u64);
|
||||
}
|
||||
state.prepared_sha256 = Some(validated.sha256);
|
||||
state.prepared_length = Some(validated.length);
|
||||
state.advance(MultipartSourcePhase::Prepared)?;
|
||||
self.store.persist(&state).await?;
|
||||
if let Some(previous) = old_metadata {
|
||||
self.store
|
||||
.write_owned_backup_and_persist(state.backup_owner, true, previous, &state)
|
||||
.await?;
|
||||
} else {
|
||||
self.store.persist(&state).await?;
|
||||
}
|
||||
*self.state.lock() = state;
|
||||
*self.validated_prepared.lock() = Some(validated);
|
||||
Ok(())
|
||||
@@ -1625,29 +1706,28 @@ impl NativeMultipartPublication {
|
||||
if current.head.is_some() || encode(current_state)? != encode(&state)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let target = self
|
||||
.store
|
||||
.read_target(&self.publication)
|
||||
.await?
|
||||
.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
{
|
||||
let (expected_sha256, expected_length) = {
|
||||
let guard = self.validated_prepared.lock();
|
||||
let validated = guard.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if validated.publication != self.publication
|
||||
|| state.prepared_sha256 != Some(validated.sha256)
|
||||
|| state.prepared_length != Some(validated.length)
|
||||
|| target.len() as u64 != validated.length
|
||||
|| raw_sha(&target) != validated.sha256
|
||||
{
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
}
|
||||
self.store.sync_target_with_lease(&self.publication, target_lease).await?;
|
||||
(validated.sha256, validated.length)
|
||||
};
|
||||
self.store
|
||||
.read_and_sync_target_with_lease(&self.publication, target_lease, move |target| {
|
||||
if target.len() as u64 != expected_length || raw_sha(target) != expected_sha256 {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
state.advance(MultipartSourcePhase::ConsumedUnsealed)?;
|
||||
self.store.persist_with_source_sync(&state, true).await?;
|
||||
let snapshot = self.store.persist_with_source_sync(&state, true).await?;
|
||||
*self.state.lock() = state;
|
||||
let loaded = self.store.load().await?;
|
||||
let snapshot = loaded.snapshot.ok_or(DiskError::FileCorrupt)?;
|
||||
let proof = self
|
||||
.store
|
||||
.issue_proof(MultipartProofKind::Published, self.publication.attempt, None, snapshot)?;
|
||||
@@ -1686,6 +1766,46 @@ impl SourceStore {
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
}
|
||||
pub async fn read_and_sync_target(
|
||||
&self,
|
||||
publication: &PublicationIdentity,
|
||||
validate: impl FnOnce(&[u8]) -> Result<()> + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let io = self.target_path(publication)?;
|
||||
let logical = self
|
||||
.root
|
||||
.join(io.strip_prefix(&self.base).map_err(|_| DiskError::InvalidPath)?);
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&logical, Some(self.lease.clone())).await;
|
||||
self.read_and_sync_target_with_lease(publication, lease, validate).await
|
||||
}
|
||||
pub async fn read_and_sync_target_with_lease(
|
||||
&self,
|
||||
publication: &PublicationIdentity,
|
||||
lease: Arc<os::NamespaceMutationLease>,
|
||||
validate: impl FnOnce(&[u8]) -> Result<()> + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let directory = self.target_path(publication)?;
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
|
||||
os::read_and_sync_multipart_target_metadata(
|
||||
&directory,
|
||||
std::ffi::OsStr::new(STORAGE_FORMAT_FILE),
|
||||
&base,
|
||||
&root,
|
||||
|bytes| validate(bytes).map_err(IoError::other),
|
||||
)
|
||||
.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
IoError::other(DiskError::ConditionalDeleteConflict)
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
}
|
||||
async fn sync_source_absence(&self) -> Result<()> {
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
|
||||
+146
-67
@@ -333,6 +333,8 @@ pub(crate) mod prepared_publication_test_hooks {
|
||||
PreparedRename,
|
||||
Rename,
|
||||
Remove,
|
||||
MetadataReadAfterOpen,
|
||||
MetadataReadAfterRead,
|
||||
#[cfg(test)]
|
||||
Rollback,
|
||||
#[cfg(test)]
|
||||
@@ -457,6 +459,8 @@ pub enum LocalPublicationStage {
|
||||
PreparedRename,
|
||||
Rename,
|
||||
Remove,
|
||||
MetadataReadAfterOpen,
|
||||
MetadataReadAfterRead,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "test-util", not(windows)))]
|
||||
@@ -469,6 +473,8 @@ impl LocalPublicationPause {
|
||||
LocalPublicationStage::PreparedRename => prepared_publication_test_hooks::Stage::PreparedRename,
|
||||
LocalPublicationStage::Rename => prepared_publication_test_hooks::Stage::Rename,
|
||||
LocalPublicationStage::Remove => prepared_publication_test_hooks::Stage::Remove,
|
||||
LocalPublicationStage::MetadataReadAfterOpen => prepared_publication_test_hooks::Stage::MetadataReadAfterOpen,
|
||||
LocalPublicationStage::MetadataReadAfterRead => prepared_publication_test_hooks::Stage::MetadataReadAfterRead,
|
||||
};
|
||||
let (entered_tx, entered) = oneshot::channel();
|
||||
let (release, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
@@ -3047,17 +3053,6 @@ pub(crate) async fn rename_all_with_lease(
|
||||
|
||||
/// A source admission must run before destination preparation can mkdir and
|
||||
/// again before the consuming rename, inside the actual owned callback.
|
||||
pub(crate) async fn rename_all_with_precondition(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
base: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
precondition: Option<Arc<dyn Fn() -> io::Result<()> + Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
rename_all_with_precondition_and_parent_sync(source, destination, base, publication_root, owner, precondition, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn rename_all_with_precondition_and_parent_sync(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
@@ -3075,45 +3070,63 @@ pub(crate) async fn rename_all_with_precondition_and_parent_sync(
|
||||
let base = base.to_path_buf();
|
||||
let root = publication_root.clone();
|
||||
let lease = acquire_namespace_mutation_lease_with_owner(&destination, owner).await;
|
||||
run_blocking_namespace_operation(lease, move || {
|
||||
if let Some(check) = precondition.as_ref() {
|
||||
check()?;
|
||||
}
|
||||
let preparation = prepare_rename_with_retry(&source, &destination, &base, &root)?;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
prepared_publication_test_hooks::run_rename_destination(&source, &destination);
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
{
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &source);
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &destination);
|
||||
}
|
||||
if let Some(check) = precondition.as_ref() {
|
||||
check()?;
|
||||
}
|
||||
rename_prepared(&source, &destination, &preparation)?;
|
||||
if sync_parent {
|
||||
let parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("source metadata parent is missing"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pinned = preparation
|
||||
.parent_guard
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.last())
|
||||
.ok_or_else(|| io::Error::other("source metadata parent was not pinned"))?;
|
||||
let opened = std::fs::File::from(pinned.try_clone()?);
|
||||
fsync_directory_handle_std(parent, Some(&opened))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fsync_dir_std(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
run_blocking_namespace_operation_with_sync(lease, sync_parent, move || {
|
||||
rename_all_with_precondition_and_parent_sync_std(
|
||||
&source,
|
||||
&destination,
|
||||
&base,
|
||||
&root,
|
||||
precondition.as_deref(),
|
||||
sync_parent,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| to_file_error(error).into())
|
||||
}
|
||||
|
||||
pub(crate) fn rename_all_with_precondition_and_parent_sync_std(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
base: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
precondition: Option<&(dyn Fn() -> io::Result<()> + Send + Sync)>,
|
||||
sync_parent: bool,
|
||||
) -> io::Result<()> {
|
||||
if let Some(check) = precondition {
|
||||
check()?;
|
||||
}
|
||||
let preparation = prepare_rename_with_retry(source, destination, base, publication_root)?;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
prepared_publication_test_hooks::run_rename_destination(source, destination);
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
{
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, source);
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, destination);
|
||||
if let Some(check) = precondition {
|
||||
check()?;
|
||||
}
|
||||
}
|
||||
rename_prepared(source, destination, &preparation)?;
|
||||
if sync_parent {
|
||||
let parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("source metadata parent is missing"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pinned = preparation
|
||||
.parent_guard
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.last())
|
||||
.ok_or_else(|| io::Error::other("source metadata parent was not pinned"))?;
|
||||
let opened = std::fs::File::from(pinned.try_clone()?);
|
||||
fsync_directory_handle_std(parent, Some(&opened))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fsync_dir_std(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a conditional repair and sync its directory in one owned executor.
|
||||
/// Cancellation cannot release its metadata/fleet/tier leases between rename
|
||||
/// and fsync. The last authority check runs after destination preparation.
|
||||
@@ -3186,26 +3199,32 @@ pub(crate) struct DurableFileReceipt {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open_anchored_metadata(path: &Path, publication_root: &PublicationRoot) -> io::Result<std::fs::File> {
|
||||
use rustix::fs::{Mode, OFlags, openat};
|
||||
#[cfg(target_os = "linux")]
|
||||
let logical_path = publication_root.path().join(publication_root.relative_path(path)?);
|
||||
#[cfg(target_os = "linux")]
|
||||
let path = logical_path.as_path();
|
||||
let parents = open_existing_directory_below_base_std(
|
||||
path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?,
|
||||
publication_root.path(),
|
||||
publication_root,
|
||||
)?;
|
||||
let directory = parents
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("missing metadata directory guard"))?;
|
||||
Ok(openat(
|
||||
directory,
|
||||
path.file_name().ok_or_else(|| io::Error::other("metadata has no name"))?,
|
||||
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?
|
||||
.into())
|
||||
{
|
||||
let parent = path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?;
|
||||
existing_base_directory_suffix(parent, publication_root.path())
|
||||
.or_else(|_| existing_base_directory_suffix(parent, publication_root.io_path()))?;
|
||||
publication_root.open_readonly(path)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
use rustix::fs::{Mode, OFlags, openat};
|
||||
let parents = open_existing_directory_below_base_std(
|
||||
path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?,
|
||||
publication_root.path(),
|
||||
publication_root,
|
||||
)?;
|
||||
let directory = parents
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("missing metadata directory guard"))?;
|
||||
Ok(openat(
|
||||
directory,
|
||||
path.file_name().ok_or_else(|| io::Error::other("metadata has no name"))?,
|
||||
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -3681,8 +3700,10 @@ pub(crate) fn read_destination_file_with_commit_guard(
|
||||
};
|
||||
match open_linux_relative(&parent.file, Path::new(name), OFlags::RDONLY, Mode::empty()) {
|
||||
Ok(mut file) => {
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(&mut file, &mut data)?;
|
||||
let len = file.metadata()?.len();
|
||||
let cap = usize::try_from(len).map_or(0, |len| len.saturating_add(1));
|
||||
let mut data = Vec::with_capacity(cap);
|
||||
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, len), &mut data)?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
@@ -6259,6 +6280,7 @@ pub(crate) fn replace_multipart_source_metadata(
|
||||
publication_root: &PublicationRoot,
|
||||
) -> io::Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| io::Error::other("MPU record has no parent"))?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _pinned = pin_multipart_source_directory(parent, base, publication_root)?;
|
||||
let guard = prepare_rename_commit_guard(parent, parent, base, publication_root)?;
|
||||
let staged = parent.join(format!(".mpu-record-{}", uuid::Uuid::new_v4()));
|
||||
@@ -6474,6 +6496,7 @@ pub(crate) fn install_multipart_prepared_head(
|
||||
if original_path.parent() != Some(parent) || original_path == canonical_head {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "MPU proxy must be a private sibling"));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _pinned = pin_multipart_source_directory(parent, base, publication_root)?;
|
||||
let guard = prepare_rename_commit_guard(parent, parent, base, publication_root)?;
|
||||
rename_prepared_source_std(&prepared, &original_path, canonical_head, base, publication_root, &guard)?;
|
||||
@@ -6640,13 +6663,69 @@ fn read_multipart_metadata_in_pinned(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_and_sync_multipart_target_metadata(
|
||||
directory: &Path,
|
||||
name: &std::ffi::OsStr,
|
||||
base: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
validate: impl FnOnce(&[u8]) -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::fs::{Mode, OFlags, openat};
|
||||
let mut pinned = pin_multipart_source_directory(directory, base, publication_root)?;
|
||||
let dir_fd = pinned
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("missing multipart target directory"))?;
|
||||
let mut file =
|
||||
std::fs::File::from(openat(dir_fd, name, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?);
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "multipart metadata is not a regular file"));
|
||||
}
|
||||
let bytes = read_multipart_metadata_bytes(&mut file, Some(metadata.len()), None)?;
|
||||
validate(&bytes)?;
|
||||
file.sync_data()?;
|
||||
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| io::Error::other("empty target directory guard"))?);
|
||||
fsync_directory_handle_std(directory, Some(&opened))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _pinned = pin_multipart_source_directory(directory, base, publication_root)?;
|
||||
let file = directory.join(name);
|
||||
let bytes = read_multipart_metadata(&file, base, publication_root, None)?;
|
||||
validate(&bytes)?;
|
||||
sync_file(&file)?;
|
||||
fsync_dir_std(directory)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_multipart_metadata(
|
||||
path: &Path,
|
||||
base: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
maximum: Option<u64>,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("multipart metadata has no parent"))?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::other("multipart metadata has no name"))?;
|
||||
if name.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "multipart metadata has no name"));
|
||||
}
|
||||
existing_base_directory_suffix(parent, base)?;
|
||||
let mut file = publication_root.open_readonly(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "multipart metadata is not a regular file"));
|
||||
}
|
||||
read_multipart_metadata_bytes(&mut file, Some(metadata.len()), maximum)
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
{
|
||||
let parent = path
|
||||
.parent()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::meta::{
|
||||
RebalanceMetaMergeOutcome, clone_first_arc, clone_rebalance_pool_stats, defer_bucket_in_rebalance_queue,
|
||||
ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error, is_rebalance_actively_running,
|
||||
is_rebalance_conflicting_with_decommission, mark_rebalance_bucket_done, merge_rebalance_meta, percent_free_ratio,
|
||||
rebalance_metadata_not_initialized_error, record_rebalance_cleanup_warning_in_meta,
|
||||
RebalanceMetaMergeOutcome, RebalanceMetadataLoadError, clone_first_arc, clone_rebalance_pool_stats,
|
||||
defer_bucket_in_rebalance_queue, ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error,
|
||||
is_rebalance_actively_running, is_rebalance_conflicting_with_decommission, mark_rebalance_bucket_done, merge_rebalance_meta,
|
||||
percent_free_ratio, rebalance_metadata_not_initialized_error, record_rebalance_cleanup_warning_in_meta,
|
||||
record_rebalance_stop_propagation_snapshot, resolve_next_rebalance_bucket, rollback_rebalance_start_meta_snapshot_for_id,
|
||||
should_accept_rebalance_stats_update, should_pool_participate, stop_rebalance_meta_snapshot_for_id,
|
||||
validate_init_rebalance_state,
|
||||
@@ -853,6 +853,26 @@ impl ECStore {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Admin-only rebalance status snapshot. Diagnostic reads may use the last
|
||||
/// published state when the persisted record is temporarily unavailable;
|
||||
/// mutation paths continue to use the authoritative loaders above.
|
||||
pub async fn rebalance_admin_diagnostic_snapshot(&self) -> (Option<RebalanceMeta>, &'static str) {
|
||||
let fallback = self.rebalance_meta.read().await.clone();
|
||||
match self.rebalance_status_meta_snapshot().await {
|
||||
Ok(snapshot) => (snapshot, "fresh"),
|
||||
Err(err) => {
|
||||
let status = if error_chain_has_rebalance_metadata_load_error(&err) {
|
||||
"recovery_required"
|
||||
} else if fallback.is_some() {
|
||||
"stale"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
(fallback, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted rebalance metadata, or `None` when no rebalance was recorded.
|
||||
async fn load_rebalance_status_meta(&self) -> Result<Option<RebalanceMeta>> {
|
||||
let pool = clone_first_arc(&self.pools, "refresh_rebalance_status_meta: no pools available")?;
|
||||
@@ -860,7 +880,7 @@ impl ECStore {
|
||||
match persisted.load(pool).await {
|
||||
Ok(()) => Ok(Some(persisted)),
|
||||
Err(Error::ConfigNotFound) => Ok(None),
|
||||
Err(err) => Err(Error::other(format!("rebalance metadata refresh failed during pool status: {err}"))),
|
||||
Err(err) => Err(Error::other_with_context("rebalance metadata refresh failed during pool status", err)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1467,21 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn error_chain_has_rebalance_metadata_load_error(err: &(dyn std::error::Error + 'static)) -> bool {
|
||||
let mut current = Some(err);
|
||||
while let Some(error) = current {
|
||||
if error.downcast_ref::<RebalanceMetadataLoadError>().is_some() {
|
||||
return true;
|
||||
}
|
||||
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||
io.get_ref().map(|source| source as &(dyn std::error::Error + 'static))
|
||||
} else {
|
||||
error.source()
|
||||
};
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1782,6 +1782,285 @@ mod tests {
|
||||
fixture.assert_moved().await;
|
||||
}
|
||||
|
||||
/// Each drive's copy of `version_id` of `object` on `pool`.
|
||||
#[cfg(feature = "test-util")]
|
||||
async fn drive_copies(store: &ECStore, pool: usize, bucket: &str, object: &str, version_id: uuid::Uuid) -> Vec<FileInfo> {
|
||||
use crate::disk::DiskAPI as _;
|
||||
let set = store.pools[pool].get_disks_by_key(object);
|
||||
let mut copies = Vec::new();
|
||||
for disk in set.disks.read().await.iter().flatten() {
|
||||
let path = disk.path().join(bucket).join(object).join(crate::disk::STORAGE_FORMAT_FILE);
|
||||
let Ok(bytes) = tokio::fs::read(&path).await else {
|
||||
continue;
|
||||
};
|
||||
let meta = FileMeta::load(&bytes).expect("drive metadata should decode");
|
||||
if meta.find_version(Some(version_id)).is_ok() {
|
||||
copies.push(
|
||||
meta.into_fileinfo(bucket, object, &version_id.to_string(), false, false, false)
|
||||
.expect("drive copy should decode"),
|
||||
);
|
||||
}
|
||||
}
|
||||
copies
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
fn drive_space(total: u64, used: u64) -> crate::disk::DiskInfo {
|
||||
crate::disk::DiskInfo {
|
||||
total,
|
||||
used,
|
||||
free: total - used,
|
||||
free_inodes: 1 << 20,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
const DANGLING_COPY_REBALANCE_ID: &str = "rebalance-dangling-target-copy";
|
||||
|
||||
/// A delete marker over one object version on pool 0 of three four-drive
|
||||
/// pools, plus what an earlier attempt that lost write quorum left on one
|
||||
/// drive of pool 1: both version ids, with older modification times and
|
||||
/// different bytes. Object reads report such copies as absent. Pool 2
|
||||
/// reports far more free space than pool 1, so a free-space choice picks
|
||||
/// pool 2 (the kill-resume e2e case ended with the marker on two pools).
|
||||
#[cfg(feature = "test-util")]
|
||||
struct DanglingTargetCopy {
|
||||
_temp_dirs: Vec<tempfile::TempDir>,
|
||||
store: Arc<ECStore>,
|
||||
_space: crate::store::PoolSpaceForTest,
|
||||
bucket: &'static str,
|
||||
object: &'static str,
|
||||
incarnation: uuid::Uuid,
|
||||
source_set: Arc<SetDisks>,
|
||||
marker: FileInfo,
|
||||
version: FileInfo,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
impl DanglingTargetCopy {
|
||||
async fn new(holder_space: crate::disk::DiskInfo) -> Self {
|
||||
let (temp_dirs, store, _peer) =
|
||||
crate::services::rebalance::test_three_pool_stores_with_four_disk_sets_with_isolated_node_contexts(None).await;
|
||||
let space = crate::store::PoolSpaceForTest::install(store.id, &[(1, holder_space), (2, drive_space(1 << 50, 0))]);
|
||||
let bucket = "rebalance-dangling-target";
|
||||
let object = "versioned/06.bin";
|
||||
Box::pin(store.make_bucket(bucket, &MakeBucketOptions::default()))
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let incarnation = store.bucket_incarnation_id(bucket).await.expect("bucket incarnation");
|
||||
let mut rebalance_meta = active_rebalance_meta(DANGLING_COPY_REBALANCE_ID);
|
||||
rebalance_meta.pool_stats.push(RebalanceStats::default());
|
||||
rebalance_meta
|
||||
.save(Arc::clone(&store.pools[0]))
|
||||
.await
|
||||
.expect("persist the active rebalance run");
|
||||
*store.rebalance_meta.write().await = Some(rebalance_meta);
|
||||
let versioned = ObjectOptions {
|
||||
versioned: true,
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let payload = vec![0x5a; 2048];
|
||||
let source_set = store.pools[0].get_disks_by_key(object);
|
||||
Box::pin(source_set.put_object(bucket, object, &mut PutObjReader::from_vec(payload.clone()), &versioned))
|
||||
.await
|
||||
.expect("seed the object version");
|
||||
let marker = Box::pin(source_set.delete_object(bucket, object, versioned.clone()))
|
||||
.await
|
||||
.expect("seed the delete marker");
|
||||
assert!(marker.delete_marker);
|
||||
let seeded = source_set
|
||||
.load_file_info_versions_exact(bucket, object)
|
||||
.await
|
||||
.expect("read seeded versions")
|
||||
.expect("seeded versions must exist");
|
||||
let [marker, version] = seeded.versions.as_slice() else {
|
||||
panic!("expected a delete marker over one version: {seeded:?}");
|
||||
};
|
||||
let (marker, version) = (marker.clone(), version.clone());
|
||||
let marker_id = marker.version_id.expect("the marker has a version id");
|
||||
let version_id = version.version_id.expect("the object version has a version id");
|
||||
let earlier = |time: Option<OffsetDateTime>| time.map(|time| time - time::Duration::hours(1));
|
||||
|
||||
let holder = store.pools[1].get_disks_by_key(object);
|
||||
let mut marker_opts =
|
||||
super::super::migration::rebalance_delete_marker_opts(&marker, Some(marker_id.to_string()), 0, Some(incarnation));
|
||||
marker_opts.mod_time = earlier(marker.mod_time);
|
||||
Box::pin(holder.delete_object(bucket, object, marker_opts))
|
||||
.await
|
||||
.expect("publish the earlier marker copy");
|
||||
Box::pin(holder.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(vec![0x11; 4096]),
|
||||
&ObjectOptions {
|
||||
version_id: Some(version_id.to_string()),
|
||||
mod_time: earlier(version.mod_time),
|
||||
..versioned
|
||||
},
|
||||
))
|
||||
.await
|
||||
.expect("publish the earlier object version copy");
|
||||
{
|
||||
use crate::disk::DiskAPI as _;
|
||||
for disk in holder.disks.read().await.iter().flatten().skip(1) {
|
||||
tokio::fs::remove_dir_all(disk.path().join(bucket).join(object))
|
||||
.await
|
||||
.expect("drop the copies from the drives the attempt missed");
|
||||
}
|
||||
}
|
||||
for version_id in [marker_id, version_id] {
|
||||
assert_eq!(drive_copies(&store, 1, bucket, object, version_id).await.len(), 1);
|
||||
}
|
||||
|
||||
Self {
|
||||
_temp_dirs: temp_dirs,
|
||||
store,
|
||||
_space: space,
|
||||
bucket,
|
||||
object,
|
||||
incarnation,
|
||||
source_set,
|
||||
marker,
|
||||
version,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_entry(&self) -> RebalanceEntryOutcome {
|
||||
let entry = metacache_entry_from_source(&self.source_set, self.bucket, self.object).await;
|
||||
Box::pin(Arc::clone(&self.store).rebalance_entry(
|
||||
RebalanceEntryTarget {
|
||||
bucket: self.bucket.to_string(),
|
||||
pool_index: 0,
|
||||
},
|
||||
entry,
|
||||
Arc::clone(&self.source_set),
|
||||
Arc::new(RebalanceBucketConfigs {
|
||||
bucket_incarnation_id: Some(self.incarnation),
|
||||
..Default::default()
|
||||
}),
|
||||
Arc::from(DANGLING_COPY_REBALANCE_ID),
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.await
|
||||
.expect("rebalance entry")
|
||||
}
|
||||
|
||||
/// Pools whose drives hold `version_id`, with the number of drives.
|
||||
async fn holders(&self, version_id: uuid::Uuid) -> Vec<(usize, usize)> {
|
||||
let mut holders = Vec::new();
|
||||
for pool in 0..self.store.pools.len() {
|
||||
let drives = drive_copies(&self.store, pool, self.bucket, self.object, version_id)
|
||||
.await
|
||||
.len();
|
||||
if drives > 0 {
|
||||
holders.push((pool, drives));
|
||||
}
|
||||
}
|
||||
holders
|
||||
}
|
||||
|
||||
/// The source entry is gone and `pool` serves both versions as the
|
||||
/// source wrote them, on every drive.
|
||||
async fn assert_moved_to(&self, pool: usize) {
|
||||
assert!(
|
||||
self.source_set
|
||||
.load_file_info_versions_exact(self.bucket, self.object)
|
||||
.await
|
||||
.expect("read source versions")
|
||||
.is_none(),
|
||||
"the source entry must be cleaned up"
|
||||
);
|
||||
let target = self.store.pools[pool]
|
||||
.get_disks_by_key(self.object)
|
||||
.load_file_info_versions_exact(self.bucket, self.object)
|
||||
.await
|
||||
.expect("read target versions")
|
||||
.expect("both versions must reach the target pool");
|
||||
let moved = |id| {
|
||||
target
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.version_id == id)
|
||||
.unwrap_or_else(|| panic!("version {id:?} must reach pool {pool}: {target:?}"))
|
||||
};
|
||||
let marker = moved(self.marker.version_id);
|
||||
assert!(marker.deleted, "the marker must stay a delete marker");
|
||||
assert_eq!(marker.mod_time, self.marker.mod_time);
|
||||
let version = moved(self.version.version_id);
|
||||
assert!(!version.deleted);
|
||||
assert_eq!(version.mod_time, self.version.mod_time);
|
||||
|
||||
let version_id = self.version.version_id.expect("object version id");
|
||||
let copies = drive_copies(&self.store, pool, self.bucket, self.object, version_id).await;
|
||||
assert_eq!(copies.len(), 4, "the object version must be on every drive of pool {pool}");
|
||||
for copy in &copies {
|
||||
assert_eq!(copy.mod_time, self.version.mod_time, "an earlier copy must be replaced, not kept");
|
||||
assert_eq!(copy.data_dir, copies[0].data_dir, "every drive must hold the same data");
|
||||
}
|
||||
let marker_id = self.marker.version_id.expect("marker version id");
|
||||
let markers = drive_copies(&self.store, pool, self.bucket, self.object, marker_id).await;
|
||||
assert_eq!(markers.len(), 4, "the marker must be on every drive of pool {pool}");
|
||||
assert!(markers.iter().all(|copy| copy.deleted));
|
||||
|
||||
let mut reader = self.store.pools[pool]
|
||||
.get_object_reader(
|
||||
self.bucket,
|
||||
self.object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(version_id.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the moved version must be readable");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("drain the moved version");
|
||||
assert_eq!(actual, self.payload, "the moved version must carry the source bytes");
|
||||
}
|
||||
}
|
||||
|
||||
/// A retry reuses the pool that holds an earlier attempt's copy, even
|
||||
/// though free space favours another pool, and rewrites that copy from
|
||||
/// the source instead of leaving the version in two pools.
|
||||
#[cfg(feature = "test-util")]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn real_rebalance_retry_reuses_the_pool_holding_a_dangling_copy() {
|
||||
run_large_stack_async_test("rebalance-dangling-target-copy", || async {
|
||||
let fixture = DanglingTargetCopy::new(drive_space(64 << 20, 0)).await;
|
||||
assert_eq!(fixture.run_entry().await, RebalanceEntryOutcome::Completed);
|
||||
for version in [&fixture.marker, &fixture.version] {
|
||||
let id = version.version_id.expect("version id");
|
||||
assert_eq!(fixture.holders(id).await, vec![(1, 4)], "version {id} must be in one pool");
|
||||
}
|
||||
fixture.assert_moved_to(1).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// A full pool holding an earlier copy must not pin the retry: placement
|
||||
/// falls back to the free-space choice, and the stale copy stays for heal.
|
||||
#[cfg(feature = "test-util")]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn real_rebalance_retry_skips_a_full_pool_holding_a_dangling_copy() {
|
||||
run_large_stack_async_test("rebalance-dangling-full-holder", || async {
|
||||
let fixture = DanglingTargetCopy::new(drive_space(1 << 30, 1 << 30)).await;
|
||||
assert_eq!(fixture.run_entry().await, RebalanceEntryOutcome::Completed);
|
||||
for version in [&fixture.marker, &fixture.version] {
|
||||
let id = version.version_id.expect("version id");
|
||||
assert_eq!(fixture.holders(id).await, vec![(1, 1), (2, 4)], "version {id}");
|
||||
}
|
||||
fixture.assert_moved_to(2).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// An `ExpiredObjectAllVersions` expiry is a prefix delete that takes the
|
||||
/// bucket lifecycle write lock, while the entry holds that lock for read
|
||||
/// through its bucket incarnation fence. Applied inline, it waited out the
|
||||
|
||||
@@ -154,7 +154,8 @@ impl RebalanceMeta {
|
||||
ver => return Err(rebalance_meta_load_unknown_version_error(ver)),
|
||||
}
|
||||
|
||||
let meta: Self = rmp_serde::from_read(Cursor::new(&data[4..]))?;
|
||||
let meta: Self = rmp_serde::from_read(Cursor::new(&data[4..]))
|
||||
.map_err(|source| Error::other(RebalanceMetadataLoadError::Decode(source)))?;
|
||||
*self = meta;
|
||||
|
||||
self.last_refreshed_at = Some(OffsetDateTime::now_utc());
|
||||
@@ -259,16 +260,28 @@ pub(super) fn first_rebalance_bucket(pool_stat: &RebalanceStats) -> Option<Strin
|
||||
pool_stat.buckets.first().cloned()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(super) enum RebalanceMetadataLoadError {
|
||||
#[error("rebalance metadata load failed: metadata payload is too short")]
|
||||
PayloadTooShort,
|
||||
#[error("rebalance metadata load failed: unknown format {0}")]
|
||||
UnknownFormat(u16),
|
||||
#[error("rebalance metadata load failed: unknown version {0}")]
|
||||
UnknownVersion(u16),
|
||||
#[error("rebalance metadata load failed: payload decode failed: {0}")]
|
||||
Decode(#[source] rmp_serde::decode::Error),
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_meta_load_no_data_error() -> Error {
|
||||
Error::other("rebalance metadata load failed: metadata payload is too short")
|
||||
Error::other(RebalanceMetadataLoadError::PayloadTooShort)
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_meta_load_unknown_format_error(fmt: u16) -> Error {
|
||||
Error::other(format!("rebalance metadata load failed: unknown format {fmt}"))
|
||||
Error::other(RebalanceMetadataLoadError::UnknownFormat(fmt))
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_meta_load_unknown_version_error(ver: u16) -> Error {
|
||||
Error::other(format!("rebalance metadata load failed: unknown version {ver}"))
|
||||
Error::other(RebalanceMetadataLoadError::UnknownVersion(ver))
|
||||
}
|
||||
|
||||
pub fn encode_rebalance_stop_propagation_record(record: &RebalanceStopPropagationRecord) -> String {
|
||||
|
||||
@@ -1234,16 +1234,23 @@ async fn try_create_bitrot_readers_via_batch_pread(
|
||||
skip_verify_bitrot: bool,
|
||||
) -> Option<BitrotReaderSetup> {
|
||||
use crate::disk::local::batch_shard_pread;
|
||||
use rustfs_io_metrics::{LocalBatchReadDecision, record_local_batch_read_decision};
|
||||
use std::io::Cursor;
|
||||
|
||||
let prepare_started = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
||||
let record_decision = |decision| {
|
||||
record_local_batch_read_decision(decision, prepare_started.map(|started| started.elapsed()));
|
||||
};
|
||||
let (adj_off, adj_len) = adjust_shard_read_params(read_offset, read_length, shard_size, &checksum_algo);
|
||||
if adj_len > object_mmap_read_max_length() {
|
||||
record_decision(LocalBatchReadDecision::ReadCapExceeded);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut batch_items: Vec<(usize, std::path::PathBuf, usize, usize)> = Vec::new();
|
||||
for (idx, disk_op) in disks.iter().enumerate() {
|
||||
if files.get(idx).is_some_and(|fi| fi.data.is_some()) {
|
||||
record_decision(LocalBatchReadDecision::InlineData);
|
||||
return None;
|
||||
}
|
||||
if let Some(disk) = disk_op.as_ref() {
|
||||
@@ -1251,12 +1258,20 @@ async fn try_create_bitrot_readers_via_batch_pread(
|
||||
let path_str = format!("{object}/{data_dir}/part.{part_number}");
|
||||
match disk.get_object_path_for_io_if_local(bucket, &path_str) {
|
||||
Some(Ok(p)) => batch_items.push((idx, p, adj_off, adj_len)),
|
||||
_ => return None,
|
||||
Some(Err(_)) => {
|
||||
record_decision(LocalBatchReadDecision::InvalidPath);
|
||||
return None;
|
||||
}
|
||||
None => {
|
||||
record_decision(LocalBatchReadDecision::NonLocalShard);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if batch_items.is_empty() {
|
||||
record_decision(LocalBatchReadDecision::NoLocalShards);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1265,6 +1280,7 @@ async fn try_create_bitrot_readers_via_batch_pread(
|
||||
for (disk_index, _, _, _) in &batch_items {
|
||||
disk_call_counters::record(object, disk_call_counters::KIND_DISK_SHARD_OPEN, *disk_index);
|
||||
}
|
||||
record_decision(LocalBatchReadDecision::Selected);
|
||||
let batch_results = batch_shard_pread(requests).await;
|
||||
|
||||
let mut setup = BitrotReaderSetup::new(disks.len());
|
||||
@@ -1313,6 +1329,7 @@ async fn try_create_bitrot_readers_via_batch_pread(
|
||||
_checksum_algo: HashAlgorithm,
|
||||
_skip_verify_bitrot: bool,
|
||||
) -> Option<BitrotReaderSetup> {
|
||||
rustfs_io_metrics::record_local_batch_read_decision(rustfs_io_metrics::LocalBatchReadDecision::UnsupportedPlatform, None);
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1476,11 +1493,19 @@ pub(in crate::set_disk) async fn create_bitrot_readers_until_quorum_with_prefere
|
||||
) -> BitrotReaderSetup {
|
||||
let strategy = get_bitrot_reader_setup_strategy(mode, prefer_data_blocks_first);
|
||||
|
||||
if use_mmap_read
|
||||
&& !matches!(
|
||||
crate::set_disk::get_object_read_policy(),
|
||||
crate::set_disk::GetObjectReadPolicy::CopySource
|
||||
)
|
||||
let batch_read_enabled = if !use_mmap_read {
|
||||
rustfs_io_metrics::record_local_batch_read_decision(rustfs_io_metrics::LocalBatchReadDecision::MmapDisabled, None);
|
||||
false
|
||||
} else if matches!(
|
||||
crate::set_disk::get_object_read_policy(),
|
||||
crate::set_disk::GetObjectReadPolicy::CopySource
|
||||
) {
|
||||
rustfs_io_metrics::record_local_batch_read_decision(rustfs_io_metrics::LocalBatchReadDecision::CopySource, None);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if batch_read_enabled
|
||||
&& let Some(mut setup) = try_create_bitrot_readers_via_batch_pread(
|
||||
files,
|
||||
disks,
|
||||
@@ -7864,6 +7889,228 @@ mod tests {
|
||||
(dirs, disks)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_batch_read_decisions_follow_the_actual_reader_setup_entry() {
|
||||
use crate::test_metrics::CapturingRecorder;
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("reader setup validation runtime");
|
||||
let recorder = CapturingRecorder::default();
|
||||
let previous = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
runtime.block_on(temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_OBJECT_MMAP_READ_MAX_LENGTH, Some("4")),
|
||||
(rustfs_config::ENV_INTERNODE_PREWARM, Some("false")),
|
||||
],
|
||||
async {
|
||||
let bucket = "batch-path-attribution";
|
||||
let object = "object";
|
||||
let (_dirs, disks) = call_counter_local_disks(bucket, 2).await;
|
||||
let data_dir = Uuid::new_v4();
|
||||
let mut files = vec![FileInfo::new(object, 1, 1); 2];
|
||||
for (file, disk) in files.iter_mut().zip(&disks) {
|
||||
file.data_dir = Some(data_dir);
|
||||
disk.as_ref()
|
||||
.expect("local disk")
|
||||
.write_all(bucket, &format!("{object}/{data_dir}/part.1"), Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("write batch fixture");
|
||||
}
|
||||
assert_eq!(object_mmap_read_max_length(), 4, "cap must be controlled by this test");
|
||||
for length in [3, 4] {
|
||||
let setup = create_bitrot_readers_until_quorum_with_preference(
|
||||
&files,
|
||||
&disks,
|
||||
bucket,
|
||||
object,
|
||||
1,
|
||||
0,
|
||||
length,
|
||||
4,
|
||||
HashAlgorithm::None,
|
||||
false,
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
BitrotReaderSetupMode::ReadQuorum,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(setup.readers.iter().flatten().count(), 2);
|
||||
}
|
||||
assert!(
|
||||
try_create_bitrot_readers_via_batch_pread(
|
||||
&files,
|
||||
&disks,
|
||||
bucket,
|
||||
object,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
4,
|
||||
HashAlgorithm::None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.is_none(),
|
||||
"a read above the adjusted-length cap must fall back"
|
||||
);
|
||||
assert!(
|
||||
try_create_bitrot_readers_via_batch_pread(
|
||||
&files,
|
||||
&disks,
|
||||
bucket,
|
||||
object,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
HashAlgorithm::HighwayHash256S,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.is_none(),
|
||||
"checksum overhead must be included in the read cap"
|
||||
);
|
||||
let mut inline_files = files.clone();
|
||||
inline_files[0].data = Some(Bytes::from_static(b"data"));
|
||||
assert!(
|
||||
try_create_bitrot_readers_via_batch_pread(
|
||||
&inline_files,
|
||||
&disks,
|
||||
bucket,
|
||||
object,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
HashAlgorithm::None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
let mut endpoint = Endpoint::try_from("http://127.0.0.1:1/disk").expect("remote placeholder endpoint");
|
||||
endpoint.is_local = false;
|
||||
let remote = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("remote placeholder must initialize without a connection");
|
||||
assert!(
|
||||
try_create_bitrot_readers_via_batch_pread(
|
||||
&files,
|
||||
&[Some(remote), None],
|
||||
bucket,
|
||||
object,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
HashAlgorithm::None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.is_none(),
|
||||
"any remote shard must keep the setup out of the local batch path"
|
||||
);
|
||||
assert!(
|
||||
try_create_bitrot_readers_via_batch_pread(
|
||||
&files,
|
||||
&disks,
|
||||
bucket,
|
||||
"../outside",
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
HashAlgorithm::None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
let missing = vec![None, None];
|
||||
assert!(
|
||||
try_create_bitrot_readers_via_batch_pread(
|
||||
&files,
|
||||
&missing,
|
||||
bucket,
|
||||
object,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
HashAlgorithm::None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
for (use_mmap, policy) in [
|
||||
(false, crate::set_disk::GetObjectReadPolicy::Default),
|
||||
(true, crate::set_disk::GetObjectReadPolicy::CopySource),
|
||||
] {
|
||||
crate::set_disk::with_get_object_read_policy(policy, async {
|
||||
create_bitrot_readers_until_quorum_with_preference(
|
||||
&files,
|
||||
&missing,
|
||||
bucket,
|
||||
object,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
HashAlgorithm::None,
|
||||
false,
|
||||
use_mmap,
|
||||
1,
|
||||
1,
|
||||
BitrotReaderSetupMode::ReadQuorum,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
},
|
||||
));
|
||||
});
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(previous);
|
||||
let metric = "rustfs_io_get_object_local_batch_decisions_total";
|
||||
for (decision, expected) in [
|
||||
("selected", 2),
|
||||
("read_cap_exceeded", 2),
|
||||
("inline_data", 1),
|
||||
("invalid_path", 1),
|
||||
("non_local_shard", 1),
|
||||
("no_local_shards", 1),
|
||||
("mmap_disabled", 1),
|
||||
("copy_source", 1),
|
||||
] {
|
||||
assert_eq!(recorder.counter_value(metric, &[("decision", decision)]), expected, "{decision}");
|
||||
}
|
||||
assert_eq!(
|
||||
recorder.counter_value("rustfs_io_get_object_local_batch_shards_total", &[("outcome", "success")]),
|
||||
4
|
||||
);
|
||||
assert_eq!(recorder.counter_value("rustfs_io_get_object_local_batch_read_bytes_total", &[]), 14);
|
||||
assert_eq!(recorder.histogram_sample_count("rustfs_io_get_object_local_batch_prepare_seconds"), 8);
|
||||
}
|
||||
|
||||
async fn reopen_local_disk(dir: &TempDir) -> DiskStore {
|
||||
let endpoint =
|
||||
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
|
||||
|
||||
@@ -1024,6 +1024,19 @@ impl GetObjectFileInfo {
|
||||
self.as_parts().0
|
||||
}
|
||||
|
||||
/// Marks an exact-version selection as the object's latest version after
|
||||
/// the versions above it were skipped as dangling.
|
||||
fn mark_selected_as_latest(&mut self) {
|
||||
if let Some(OwnedGetObjectFileInfo {
|
||||
payload: OwnedGetObjectFileInfoPayload::Direct { fi, .. },
|
||||
..
|
||||
}) = self.owned.as_mut()
|
||||
{
|
||||
fi.is_latest = true;
|
||||
fi.successor_mod_time = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn parts_metadata(&self) -> &[FileInfo] {
|
||||
self.as_parts().1
|
||||
}
|
||||
@@ -4232,14 +4245,23 @@ pub(super) struct MultipartWriteQuorumContext<'a> {
|
||||
part_number: Option<usize>,
|
||||
}
|
||||
|
||||
fn record_multipart_write_quorum_failure(
|
||||
stage: &'static str,
|
||||
errs: &[Option<DiskError>],
|
||||
write_quorum: usize,
|
||||
) -> crate::disk::error_reduce::WriteQuorumFailureSummary {
|
||||
let summary = build_write_quorum_failure_summary(errs, OBJECT_OP_IGNORED_ERRS, write_quorum);
|
||||
runtime_sources::record_erasure_write_quorum_failure(stage, summary.dominant_error_label);
|
||||
summary
|
||||
}
|
||||
|
||||
fn log_multipart_write_quorum_failure(
|
||||
context: MultipartWriteQuorumContext<'_>,
|
||||
errs: &[Option<DiskError>],
|
||||
write_quorum: usize,
|
||||
returned_error: &DiskError,
|
||||
) {
|
||||
let summary = build_write_quorum_failure_summary(errs, OBJECT_OP_IGNORED_ERRS, write_quorum);
|
||||
runtime_sources::record_erasure_write_quorum_failure(context.stage, summary.dominant_error_label);
|
||||
let summary = record_multipart_write_quorum_failure(context.stage, errs, write_quorum);
|
||||
warn!(
|
||||
target: "rustfs_ecstore::set_disk",
|
||||
event = EVENT_SET_DISK_MULTIPART,
|
||||
@@ -6841,6 +6863,17 @@ impl SetDisks {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether any drive holds `version_id` of the key, including a copy on
|
||||
/// fewer drives than read quorum that object reads report as absent.
|
||||
pub(crate) async fn holds_version_on_any_drive(&self, bucket: &str, object: &str, version_id: Uuid) -> bool {
|
||||
let disks = self.disk_inventory().await;
|
||||
let (copies, _) = Self::read_all_raw_file_info(&disks, bucket, object, false).await;
|
||||
copies
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|copy| FileMeta::load(©.buf).is_ok_and(|meta| meta.find_version(Some(version_id)).is_ok()))
|
||||
}
|
||||
|
||||
/// Publish an internal tier free-version record without changing its
|
||||
/// delete-marker shape or remote-tier identity. The caller holds the
|
||||
/// source and target object locks; a write quorum is required before the
|
||||
|
||||
@@ -40,9 +40,35 @@ pub(crate) mod test_hooks {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type Hook = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
|
||||
type TransactionLockHook = Arc<dyn Fn(&rustfs_lock::NamespaceLockGuard) + Send + Sync>;
|
||||
|
||||
static BEFORE_GC_FINALIZE: Mutex<Vec<(String, String, Hook)>> = Mutex::new(Vec::new());
|
||||
static CLEANUP_ATTEMPTS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
|
||||
static AFTER_TRANSACTION_LOCK: Mutex<Vec<(String, String, TransactionLockHook)>> = Mutex::new(Vec::new());
|
||||
|
||||
pub(crate) fn after_transaction_lock_acquired(bucket: &str, object: &str, hook: TransactionLockHook) {
|
||||
AFTER_TRANSACTION_LOCK
|
||||
.lock()
|
||||
.expect("transaction lock hooks")
|
||||
.push((bucket.to_owned(), object.to_owned(), hook));
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn run_after_transaction_lock_acquired(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
guard: &rustfs_lock::NamespaceLockGuard,
|
||||
) {
|
||||
let hook = {
|
||||
let mut hooks = AFTER_TRANSACTION_LOCK.lock().expect("transaction lock hooks");
|
||||
hooks
|
||||
.iter()
|
||||
.position(|(hook_bucket, hook_object, _)| hook_bucket == bucket && hook_object == object)
|
||||
.map(|index| hooks.remove(index).2)
|
||||
};
|
||||
if let Some(hook) = hook {
|
||||
hook(guard);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_cleanup_attempt(bucket: &str, object: &str) {
|
||||
CLEANUP_ATTEMPTS
|
||||
@@ -112,6 +138,7 @@ pub(crate) struct DeleteRecoveryContext<'a> {
|
||||
/// Only the locked heal path may repair a live marker-creation source.
|
||||
pub repair_marker_source: bool,
|
||||
pub namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
pub transaction_guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
|
||||
pub recovery_ticket: Option<crate::store::delete_recovery_runtime::DeleteRecoveryTicket>,
|
||||
pub published_hint: Option<(Uuid, &'a [Result<DeleteRecoveryInspection>])>,
|
||||
pub can_commit: &'a (dyn Fn() -> bool + Sync),
|
||||
@@ -125,6 +152,7 @@ impl<'a> DeleteRecoveryContext<'a> {
|
||||
explicit_delete: false,
|
||||
repair_marker_source: false,
|
||||
namespace_owner: None,
|
||||
transaction_guard: None,
|
||||
recovery_ticket: None,
|
||||
published_hint: None,
|
||||
can_commit,
|
||||
@@ -373,9 +401,22 @@ impl SetDisks {
|
||||
write_quorum: usize,
|
||||
mut context: DeleteRecoveryContext<'_>,
|
||||
) -> Result<()> {
|
||||
if context.namespace_owner.is_none() {
|
||||
context.namespace_owner = Some(self.ctx.begin_namespace_commit());
|
||||
}
|
||||
let transaction_guard = match context.transaction_guard.take() {
|
||||
Some(guard) => guard,
|
||||
None => self
|
||||
.acquire_delete_transaction_lock(bucket, object)
|
||||
.await
|
||||
.map_err(DiskError::other)?,
|
||||
};
|
||||
let outer_can_commit = context.can_commit;
|
||||
let can_commit = || outer_can_commit() && !transaction_guard.is_lock_lost();
|
||||
context.can_commit = &can_commit;
|
||||
context.transaction_guard = Some(Arc::clone(&transaction_guard));
|
||||
let owner = context
|
||||
.namespace_owner
|
||||
.take()
|
||||
.unwrap_or_else(|| self.ctx.begin_namespace_commit());
|
||||
context.namespace_owner = Some(Arc::new((owner, Arc::clone(&transaction_guard))));
|
||||
let can_commit = context.can_commit;
|
||||
let states = inspect(disks, bucket, object, transaction).await;
|
||||
let untouched_absence = |state: &Result<DeleteRecoveryInspection>| matches!(state, Ok(state) if state.state == DeleteRecoveryState::Absent && state.current_digest.is_none());
|
||||
@@ -547,8 +588,25 @@ impl SetDisks {
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
context: DeleteRecoveryContext<'_>,
|
||||
mut context: DeleteRecoveryContext<'_>,
|
||||
) -> Result<DeleteRecoverySummary> {
|
||||
let transaction_guard = match context.transaction_guard.take() {
|
||||
Some(guard) => guard,
|
||||
None => self
|
||||
.acquire_delete_transaction_lock(bucket, object)
|
||||
.await
|
||||
.map_err(DiskError::other)?,
|
||||
};
|
||||
let outer_can_commit = context.can_commit;
|
||||
let can_commit = || outer_can_commit() && !transaction_guard.is_lock_lost();
|
||||
context.can_commit = &can_commit;
|
||||
if !context.dry_run {
|
||||
let owner = context
|
||||
.namespace_owner
|
||||
.take()
|
||||
.unwrap_or_else(|| self.ctx.begin_namespace_commit());
|
||||
context.namespace_owner = Some(Arc::new((owner, Arc::clone(&transaction_guard))));
|
||||
}
|
||||
let DeleteRecoveryContext {
|
||||
incarnation,
|
||||
dry_run,
|
||||
@@ -558,6 +616,7 @@ impl SetDisks {
|
||||
published_hint,
|
||||
can_commit,
|
||||
recovery_ticket: _,
|
||||
transaction_guard: _,
|
||||
} = context;
|
||||
let disks = self.disk_inventory().await;
|
||||
let complete_inventory = disks.len() == self.set_drive_count && disks.iter().all(Option::is_some);
|
||||
@@ -1257,6 +1316,135 @@ mod tests {
|
||||
assert_eq!(body(&store, bucket, None).await, original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_deferred_batch_discards_released_alias_fence_and_preserves_caller_fence() {
|
||||
use crate::bucket::metadata_sys::ObjectLockConfigState;
|
||||
use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence, ObjectLockConfigSnapshot};
|
||||
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
for lose_caller in [false, true] {
|
||||
let bucket = if lose_caller {
|
||||
"deferred-batch-lost-caller"
|
||||
} else {
|
||||
"deferred-batch-released-alias"
|
||||
};
|
||||
let (_dirs, store, disks) = fixture(bucket).await;
|
||||
put(&store, bucket, &[27; 4096], false).await;
|
||||
let transaction = applied(&store, &disks, bucket, None, disks.len()).await;
|
||||
let before = join_all(
|
||||
disks
|
||||
.iter()
|
||||
.map(|disk| disk.inspect_delete_transaction(bucket, "object", transaction)),
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.expect("capture pending recovery evidence");
|
||||
let mut set = (*store.pools[0].disk_set[0]).clone();
|
||||
set.lockers = vec![Arc::new(rustfs_lock::LocalClient::with_manager(Arc::new(
|
||||
rustfs_lock::GlobalLockManager::Enabled(Arc::new(rustfs_lock::FastObjectLockManager::new())),
|
||||
)))];
|
||||
set.ctx
|
||||
.update_erasure_type(crate::layout::endpoints::SetupType::DistErasure)
|
||||
.await;
|
||||
|
||||
let (caller_fence, caller_lost) = NamespaceLockFence::loss_handle_for_test();
|
||||
let released_alias_lost = Arc::new(AtomicBool::new(false));
|
||||
let signal_fence = Arc::new(Mutex::new(None));
|
||||
let captured_signal_fence = Arc::clone(&signal_fence);
|
||||
let captured_alias_lost = Arc::clone(&released_alias_lost);
|
||||
test_hooks::after_transaction_lock_acquired(
|
||||
bucket,
|
||||
"object",
|
||||
Arc::new(move |guard| {
|
||||
let signal = guard.lock_lost_signal().expect("distributed alias signal");
|
||||
let fence = NamespaceLockSignalTestFence::install_with_loss_handle(&signal, Arc::clone(&captured_alias_lost));
|
||||
// Retain the signal so a later allocation cannot reuse its
|
||||
// registry identity after the original guard is dropped.
|
||||
*captured_signal_fence.lock().expect("captured alias fence") = Some((fence, signal));
|
||||
}),
|
||||
);
|
||||
let replay_started = Arc::new(AtomicBool::new(false));
|
||||
let captured_replay_started = Arc::clone(&replay_started);
|
||||
let expired_alias = Arc::clone(&released_alias_lost);
|
||||
test_hooks::after_transaction_lock_acquired(
|
||||
bucket,
|
||||
"object",
|
||||
Arc::new(move |_| {
|
||||
// Acquiring the same transaction key again proves the first
|
||||
// lease was released. Only its copied fence becomes stale.
|
||||
expired_alias.store(true, Ordering::Release);
|
||||
caller_lost.store(lose_caller, Ordering::Release);
|
||||
captured_replay_started.store(true, Ordering::Release);
|
||||
}),
|
||||
);
|
||||
|
||||
let (deleted, errors) = set
|
||||
.delete_objects(
|
||||
bucket,
|
||||
vec![ObjectToDelete {
|
||||
object_name: "object".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions {
|
||||
namespace_lock_fence: Some(caller_fence),
|
||||
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(
|
||||
ObjectLockConfigState::ConfirmedAbsent,
|
||||
))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
replay_started.load(Ordering::Acquire),
|
||||
"missing metadata must take the deferred replay path"
|
||||
);
|
||||
assert!(
|
||||
released_alias_lost.load(Ordering::Acquire),
|
||||
"the completed batch alias must be marked lost"
|
||||
);
|
||||
if lose_caller {
|
||||
assert!(errors[0].is_some(), "losing the original caller fence must reject deferred replay");
|
||||
for (disk, original) in disks.iter().zip(before) {
|
||||
let after = disk
|
||||
.inspect_delete_transaction(bucket, "object", transaction)
|
||||
.await
|
||||
.expect("retained proof");
|
||||
assert_eq!(
|
||||
after.fingerprint, original.fingerprint,
|
||||
"lost caller authority must preserve every prepared journal"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert!(
|
||||
errors.iter().all(Option::is_none),
|
||||
"released alias loss must not reject fresh recovery: {errors:?}"
|
||||
);
|
||||
assert!(
|
||||
!deleted[0].found,
|
||||
"fresh unversioned recovery commits absence before the idempotent batch retry"
|
||||
);
|
||||
for disk in &disks {
|
||||
assert!(
|
||||
discover(disk, bucket, Some("object"))
|
||||
.await
|
||||
.expect("final recovery evidence")
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
disk.read_version("", bucket, "object", "", &ReadOptions::default())
|
||||
.await
|
||||
.expect_err("replayed delete must remove the source"),
|
||||
DiskError::FileNotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(signal_fence);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_corrupt_bucket_does_not_prevent_other_bucket_rollback() {
|
||||
let bad_bucket = "recover-corrupt-bucket";
|
||||
|
||||
@@ -3634,6 +3634,18 @@ impl SetDisks {
|
||||
return Ok((result, err.map(|e| e.into())));
|
||||
}
|
||||
|
||||
// Keep the transaction key through both replay and the metadata heal
|
||||
// based on that replay. The raw target key always precedes this key.
|
||||
let transaction_guard =
|
||||
if _write_lock_guard.is_some() && !opts.read_repair && !crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
Some(self.acquire_delete_transaction_lock(bucket, object).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let locks_lost = || {
|
||||
_write_lock_guard.as_deref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
};
|
||||
let mut recovery_removed_version = false;
|
||||
if !opts.read_repair
|
||||
&& _write_lock_guard.is_some()
|
||||
@@ -3655,12 +3667,17 @@ impl SetDisks {
|
||||
dry_run: opts.dry_run,
|
||||
explicit_delete,
|
||||
repair_marker_source: true,
|
||||
transaction_guard: transaction_guard.clone(),
|
||||
namespace_owner: (!opts.dry_run).then(|| {
|
||||
Arc::new((scope.clone(), self.ctx.begin_namespace_commit(), _write_lock_guard.clone()))
|
||||
as Arc<dyn Send + Sync>
|
||||
Arc::new((
|
||||
scope.clone(),
|
||||
self.ctx.begin_namespace_commit(),
|
||||
_write_lock_guard.clone(),
|
||||
transaction_guard.clone(),
|
||||
)) as Arc<dyn Send + Sync>
|
||||
}),
|
||||
..crate::set_disk::DeleteRecoveryContext::new(Some(scope.incarnation), &|| {
|
||||
scope.check().is_ok() && !_write_lock_guard.as_deref().is_some_and(|guard| guard.is_lock_lost())
|
||||
scope.check().is_ok() && !locks_lost()
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -3752,7 +3769,7 @@ impl SetDisks {
|
||||
&& errs
|
||||
.iter()
|
||||
.all(|err| matches!(err, Some(DiskError::FileNotFound | DiskError::FileVersionNotFound)))
|
||||
&& !_write_lock_guard.as_deref().is_some_and(|guard| guard.is_lock_lost())
|
||||
&& !locks_lost()
|
||||
{
|
||||
*absence = Some(HealedObjectAbsence {
|
||||
pool_index: self.pool_index,
|
||||
@@ -3767,8 +3784,10 @@ impl SetDisks {
|
||||
// Pass no_lock=true since we already obtained write lock (or are already called with no_lock=true)
|
||||
let mut inner_opts = *opts;
|
||||
inner_opts.no_lock = true;
|
||||
let commit_fence = || !_write_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost());
|
||||
let namespace_owner = _write_lock_guard.as_ref().map(|guard| guard.clone() as Arc<dyn Send + Sync>);
|
||||
let commit_fence = || !locks_lost();
|
||||
let namespace_owner = _write_lock_guard
|
||||
.as_ref()
|
||||
.map(|guard| Arc::new((Arc::clone(guard), transaction_guard.clone())) as Arc<dyn Send + Sync>);
|
||||
let (result, err) = pin_object_heal_future(|| {
|
||||
self.heal_object_with_explicit_version_regen(
|
||||
bucket,
|
||||
@@ -3815,7 +3834,7 @@ impl SetDisks {
|
||||
result,
|
||||
err.map(Into::into),
|
||||
absence,
|
||||
_write_lock_guard.as_deref().is_some_and(|guard| guard.is_lock_lost()),
|
||||
locks_lost(),
|
||||
bucket,
|
||||
object,
|
||||
));
|
||||
@@ -3827,7 +3846,7 @@ impl SetDisks {
|
||||
result,
|
||||
err.map(Into::into),
|
||||
absence,
|
||||
_write_lock_guard.as_deref().is_some_and(|guard| guard.is_lock_lost()),
|
||||
locks_lost(),
|
||||
bucket,
|
||||
object,
|
||||
))
|
||||
|
||||
@@ -44,6 +44,21 @@ impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks {
|
||||
|
||||
#[tracing::instrument(level = "trace", skip(self))]
|
||||
async fn new_ns_lock(&self, bucket: &str, object: &str) -> Result<NamespaceLockWrapper> {
|
||||
// Lock the file the disk resolves, not the spelling: on Windows `a\b`
|
||||
// and `a/b` share one xl.meta, so they must share one lock. The key is
|
||||
// folded by the requesting node only; lock servers compare it verbatim.
|
||||
let resource = ObjectKey {
|
||||
bucket: Arc::from(bucket),
|
||||
object: Arc::from(object_name_disk_identity(object).as_ref()),
|
||||
version: None,
|
||||
};
|
||||
|
||||
Ok(self.namespace_lock_for_key(resource).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
async fn namespace_lock_for_key(&self, resource: ObjectKey) -> NamespaceLockWrapper {
|
||||
// Resolved from this set's own instance context (backlog#1052), not the
|
||||
// ambient facade: the facade tracks whichever context is currently
|
||||
// published process-wide, so a second instance (or, in tests, another
|
||||
@@ -69,20 +84,33 @@ impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks {
|
||||
NamespaceLock::with_local_manager_shared(self.set_lock_namespace.clone(), self.local_lock_manager.clone())
|
||||
};
|
||||
|
||||
// Lock the file the disk resolves, not the spelling: on Windows `a\b`
|
||||
// and `a/b` share one xl.meta, so they must share one lock. The key is
|
||||
// folded by the requesting node only; lock servers compare it verbatim.
|
||||
let resource = ObjectKey {
|
||||
bucket: Arc::from(bucket),
|
||||
object: Arc::from(object_name_disk_identity(object).as_ref()),
|
||||
version: None,
|
||||
};
|
||||
|
||||
Ok(NamespaceLockWrapper::new(set_lock, resource, self.locker_owner.clone()))
|
||||
NamespaceLockWrapper::new(set_lock, resource, self.locker_owner.clone())
|
||||
}
|
||||
|
||||
/// Raw object locks precede this set-local transaction lock. Its versioned
|
||||
/// resource cannot collide with normal namespace keys, which use None.
|
||||
/// Keeping the target set's clients avoids nesting overlapping raw quorums.
|
||||
pub(in crate::set_disk) async fn acquire_delete_transaction_lock(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> Result<Arc<rustfs_lock::NamespaceLockGuard>> {
|
||||
let resource = ObjectKey::with_version(
|
||||
bucket,
|
||||
object_name_disk_identity(object).as_ref(),
|
||||
format!("rustfs:delete-transaction:{}:{}", self.pool_index, self.set_index),
|
||||
);
|
||||
let guard = self
|
||||
.namespace_lock_for_key(resource)
|
||||
.await
|
||||
.get_write_lock(super::super::get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(|error| self.map_namespace_lock_error(bucket, object, "delete_transaction", error))?;
|
||||
#[cfg(test)]
|
||||
super::delete_recovery::test_hooks::run_after_transaction_lock_acquired(bucket, object, &guard);
|
||||
Ok(Arc::new(guard))
|
||||
}
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
#[allow(dead_code, reason = "lock diagnostics formatter with no caller in this port (backlog#1823)")]
|
||||
pub(in crate::set_disk) fn format_lock_error(&self, bucket: &str, object: &str, mode: &str, err: &LockResult) -> String {
|
||||
match err {
|
||||
|
||||
@@ -44,9 +44,9 @@ use super::super::{
|
||||
create_bitrot_writer, debug, disk, error, file_info_is_valid_for_metadata, get_complete_multipart_md5, get_header_map,
|
||||
get_str, insert_str, is_err_object_not_found, is_err_version_not_found, is_min_allowed_part_size,
|
||||
log_multipart_write_quorum_failure, parts_after_marker, path_join_buf, prepare_complete_multipart_parts,
|
||||
record_compression_total_memory, reduce_read_quorum_errs, reduce_write_quorum_errs, remove_header_map, resolve_write_layout,
|
||||
restore_commit_operation_id_from_metadata, should_persist_encryption_original_size, strip_internal_multipart_metadata,
|
||||
to_object_err, warn,
|
||||
record_compression_total_memory, record_multipart_write_quorum_failure, reduce_read_quorum_errs, reduce_write_quorum_errs,
|
||||
remove_header_map, resolve_write_layout, restore_commit_operation_id_from_metadata, should_persist_encryption_original_size,
|
||||
strip_internal_multipart_metadata, to_object_err, warn,
|
||||
};
|
||||
use super::bitrot_self_verify::{BitrotSelfVerifyTarget, drop_failed_writer_disks, verify_written_bitrot_shards};
|
||||
#[cfg(test)]
|
||||
@@ -233,6 +233,19 @@ fn admitted_multipart_size(current: u64, candidate: u64, limit: u64) -> Result<u
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum MultipartMetadataQuorum {
|
||||
Read,
|
||||
Write,
|
||||
Cleanup,
|
||||
}
|
||||
|
||||
impl From<bool> for MultipartMetadataQuorum {
|
||||
fn from(write: bool) -> Self {
|
||||
if write { Self::Write } else { Self::Read }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct StaleMultipartCleanupGuard {
|
||||
file_info: FileInfo,
|
||||
upload_path: String,
|
||||
@@ -1139,7 +1152,7 @@ impl SetDisks {
|
||||
}
|
||||
let upload_id = runtime_sources::deployment_upload_id(raw_upload_id);
|
||||
let file_info = match self
|
||||
.check_multipart_upload_path_exists(bucket, object, &upload_id, &upload_path, false, true)
|
||||
.check_multipart_upload_path_exists(bucket, object, &upload_id, &upload_path, MultipartMetadataQuorum::Read, true)
|
||||
.await
|
||||
{
|
||||
Ok((file_info, _)) => file_info,
|
||||
@@ -1226,7 +1239,7 @@ impl SetDisks {
|
||||
.acquire_write_lock_diag("abort_data_movement_multipart", RUSTFS_META_MULTIPART_BUCKET, &upload_path)
|
||||
.await?;
|
||||
let file_info = match self
|
||||
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_path, true, true)
|
||||
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_path, MultipartMetadataQuorum::Write, true)
|
||||
.await
|
||||
{
|
||||
Ok((file_info, _)) => file_info,
|
||||
@@ -1333,7 +1346,7 @@ impl SetDisks {
|
||||
data_movement: bool,
|
||||
) -> Result<(FileInfo, Vec<FileInfo>)> {
|
||||
let upload_id_path = Self::validated_multipart_upload_path(bucket, object, upload_id, data_movement)?;
|
||||
self.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, write, false)
|
||||
self.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, write.into(), false)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1373,7 +1386,7 @@ impl SetDisks {
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
upload_id_path: &str,
|
||||
write: bool,
|
||||
quorum_mode: MultipartMetadataQuorum,
|
||||
include_recovery: bool,
|
||||
) -> Result<(FileInfo, Vec<FileInfo>)> {
|
||||
// Slow metadata I/O must not hold the topology registry's read lock.
|
||||
@@ -1489,22 +1502,26 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
let mut quorum = read_quorum as usize;
|
||||
if write {
|
||||
if quorum_mode != MultipartMetadataQuorum::Read {
|
||||
quorum = write_quorum as usize;
|
||||
|
||||
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, quorum) {
|
||||
log_multipart_write_quorum_failure(
|
||||
MultipartWriteQuorumContext {
|
||||
stage: MULTIPART_WRITE_QUORUM_UPLOAD_METADATA,
|
||||
bucket,
|
||||
object,
|
||||
upload_id,
|
||||
part_number: None,
|
||||
},
|
||||
&errs,
|
||||
quorum,
|
||||
&err,
|
||||
);
|
||||
if quorum_mode == MultipartMetadataQuorum::Write {
|
||||
log_multipart_write_quorum_failure(
|
||||
MultipartWriteQuorumContext {
|
||||
stage: MULTIPART_WRITE_QUORUM_UPLOAD_METADATA,
|
||||
bucket,
|
||||
object,
|
||||
upload_id,
|
||||
part_number: None,
|
||||
},
|
||||
&errs,
|
||||
quorum,
|
||||
&err,
|
||||
);
|
||||
} else {
|
||||
record_multipart_write_quorum_failure(MULTIPART_WRITE_QUORUM_UPLOAD_METADATA, &errs, quorum);
|
||||
}
|
||||
return Err(map_error(err));
|
||||
}
|
||||
} else if let Some(err) = reduce_read_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, quorum) {
|
||||
@@ -1523,7 +1540,7 @@ impl SetDisks {
|
||||
.acquire_write_lock_diag("stale_multipart_cleanup", RUSTFS_META_MULTIPART_BUCKET, upload_path)
|
||||
.await?;
|
||||
let (file_info, _) = self
|
||||
.check_multipart_upload_path_exists("", "", "", upload_path, true, true)
|
||||
.check_multipart_upload_path_exists("", "", "", upload_path, MultipartMetadataQuorum::Cleanup, true)
|
||||
.await?;
|
||||
let write_quorum = file_info.write_quorum(self.default_write_quorum());
|
||||
Ok(StaleMultipartCleanupGuard {
|
||||
@@ -3032,7 +3049,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
// its full write quorum and the upload's incarnation/lifecycle fences.
|
||||
let upload_id_path = Self::validated_multipart_upload_path(bucket, object, upload_id, opts.data_movement)?;
|
||||
let (fi, _) = self
|
||||
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, false, true)
|
||||
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, MultipartMetadataQuorum::Read, true)
|
||||
.await?;
|
||||
ensure_data_movement_upload_access(&fi, bucket, object, upload_id, opts)?;
|
||||
ensure_multipart_bucket_incarnation(&self.ctx, &fi, bucket, object, upload_id, opts.expected_bucket_incarnation_id)
|
||||
@@ -3192,7 +3209,14 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
}
|
||||
SourceRecovery::Missing => {
|
||||
return match self
|
||||
.check_multipart_upload_path_exists(bucket, object, upload_id, &upload_id_path, false, true)
|
||||
.check_multipart_upload_path_exists(
|
||||
bucket,
|
||||
object,
|
||||
upload_id,
|
||||
&upload_id_path,
|
||||
MultipartMetadataQuorum::Read,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Err(Error::ErasureWriteQuorum),
|
||||
@@ -11133,7 +11157,7 @@ mod tests {
|
||||
*disk = None;
|
||||
}
|
||||
let (info, _) = set
|
||||
.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
|
||||
.check_multipart_upload_path_exists(bucket, object, &upload, &path, MultipartMetadataQuorum::Read, true)
|
||||
.await
|
||||
.expect("four primary plus four recovery copies reach metadata read quorum");
|
||||
assert_eq!(info.name, path);
|
||||
@@ -11154,7 +11178,7 @@ mod tests {
|
||||
*disk = None;
|
||||
}
|
||||
assert!(
|
||||
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
|
||||
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, MultipartMetadataQuorum::Read, true)
|
||||
.await
|
||||
.is_err(),
|
||||
"two files on each of four disks must not count as eight voters"
|
||||
@@ -14471,7 +14495,7 @@ mod tests {
|
||||
object,
|
||||
&u_new,
|
||||
&SetDisks::get_upload_id_dir(bucket, object, &u_new),
|
||||
true,
|
||||
MultipartMetadataQuorum::Write,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
@@ -14516,7 +14540,7 @@ mod tests {
|
||||
object,
|
||||
&upload,
|
||||
&SetDisks::get_upload_id_dir(bucket, object, &upload),
|
||||
true,
|
||||
MultipartMetadataQuorum::Write,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
@@ -14599,7 +14623,7 @@ mod tests {
|
||||
assert_completion_files_preserved(&disk_stores, RUSTFS_META_MULTIPART_BUCKET, &source_files).await;
|
||||
set_disks
|
||||
.check_multipart_upload_path_exists(
|
||||
bucket, object, &u_new, &SetDisks::get_upload_id_dir(bucket, object, &u_new), true, true,
|
||||
bucket, object, &u_new, &SetDisks::get_upload_id_dir(bucket, object, &u_new), MultipartMetadataQuorum::Write, true,
|
||||
)
|
||||
.await
|
||||
.expect("upload authority must survive the post-commit crash");
|
||||
|
||||
@@ -507,7 +507,7 @@ mod native_abort_closure_tests {
|
||||
let path = SetDisks::get_upload_id_dir(bucket, object, &upload);
|
||||
pin_historical_unmarked_upload(&disks, bucket, &path).await;
|
||||
let before = originals(&disks, &path).await;
|
||||
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
|
||||
set.check_multipart_upload_path_exists(bucket, object, &upload, &path, MultipartMetadataQuorum::Read, true)
|
||||
.await
|
||||
.expect("readonly legacy cleanup identity lookup");
|
||||
assert_eq!(
|
||||
@@ -786,7 +786,7 @@ mod native_abort_closure_tests {
|
||||
"known orphan native evidence remains unknown: {inspected:?}"
|
||||
);
|
||||
let readonly = set
|
||||
.check_multipart_upload_path_exists(bucket, object, &upload, &path, false, true)
|
||||
.check_multipart_upload_path_exists(bucket, object, &upload, &path, MultipartMetadataQuorum::Read, true)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
|
||||
@@ -73,6 +73,7 @@ struct DeleteCommitContext<'a> {
|
||||
incarnation: Option<Uuid>,
|
||||
options: Option<&'a ObjectOptions>,
|
||||
guard: Option<Arc<super::super::ObjectLockDiagGuard>>,
|
||||
transaction_guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
|
||||
}
|
||||
|
||||
impl<'a> DeleteCommitContext<'a> {
|
||||
@@ -81,17 +82,37 @@ impl<'a> DeleteCommitContext<'a> {
|
||||
incarnation: options.expected_bucket_incarnation_id,
|
||||
options: Some(options),
|
||||
guard: guard.cloned(),
|
||||
transaction_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_transaction_guard(mut self, guard: Option<&Arc<rustfs_lock::NamespaceLockGuard>>) -> Self {
|
||||
self.transaction_guard = guard.cloned();
|
||||
self
|
||||
}
|
||||
|
||||
fn ensure_transaction_lock_held(&self, bucket: &str, object: &str) -> Result<()> {
|
||||
if self.transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "delete_transaction",
|
||||
bucket: bucket.to_owned(),
|
||||
object: object.to_owned(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn can_commit(&self, bucket: &str, object: &str) -> bool {
|
||||
self.options.is_none_or(|options| {
|
||||
ensure_delete_commit_locks_held(self.guard.as_deref(), bucket, object, options).is_ok()
|
||||
&& options
|
||||
.scanner_publication_commit_scope
|
||||
.as_ref()
|
||||
.is_none_or(|scope| scope.can_commit())
|
||||
})
|
||||
!self.transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
&& self.options.is_none_or(|options| {
|
||||
ensure_delete_commit_locks_held(self.guard.as_deref(), bucket, object, options).is_ok()
|
||||
&& options
|
||||
.scanner_publication_commit_scope
|
||||
.as_ref()
|
||||
.is_none_or(|scope| scope.can_commit())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3852,6 +3873,7 @@ impl SetDisks {
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
lock_guard: Option<&Arc<super::super::ObjectLockDiagGuard>>,
|
||||
transaction_guard: Option<&Arc<rustfs_lock::NamespaceLockGuard>>,
|
||||
) -> Result<bool> {
|
||||
if is_meta_bucketname(bucket)
|
||||
|| opts.version_id.is_some()
|
||||
@@ -3888,6 +3910,7 @@ impl SetDisks {
|
||||
object,
|
||||
DeleteRecoveryContext {
|
||||
explicit_delete: true,
|
||||
transaction_guard: transaction_guard.cloned(),
|
||||
namespace_owner: Some(Arc::new((
|
||||
self.ctx.begin_namespace_commit(),
|
||||
lock_guard.cloned(),
|
||||
@@ -8839,7 +8862,7 @@ impl SetDisks {
|
||||
// quorum, failing write quorum on update_object_meta (backlog#872).
|
||||
let read_opts = opts.as_read_opts();
|
||||
let (mut fi, _, disks) = self
|
||||
.get_object_fileinfo_gated(bucket, object, &read_opts, false, false)
|
||||
.get_object_fileinfo_for_metadata_update(bucket, object, &read_opts)
|
||||
.await?
|
||||
.into_owned();
|
||||
|
||||
@@ -8877,8 +8900,11 @@ impl SetDisks {
|
||||
fi: &FileInfo,
|
||||
force_del_marker: bool,
|
||||
delete_marker_purge: Option<MrfDeleteMarkerPurge>,
|
||||
commit_context: DeleteCommitContext<'_>,
|
||||
mut commit_context: DeleteCommitContext<'_>,
|
||||
) -> Result<()> {
|
||||
if !is_meta_bucketname(bucket) && commit_context.transaction_guard.is_none() {
|
||||
commit_context.transaction_guard = Some(self.acquire_delete_transaction_lock(bucket, object).await?);
|
||||
}
|
||||
let source_bucket_incarnation_id = commit_context.incarnation;
|
||||
let transported = delete_file_info_with_replication_transport_metadata(fi);
|
||||
let fi = &transported;
|
||||
@@ -8887,6 +8913,7 @@ impl SetDisks {
|
||||
Arc::new((
|
||||
self.ctx.begin_namespace_commit(),
|
||||
commit_context.guard.clone(),
|
||||
commit_context.transaction_guard.clone(),
|
||||
commit_context
|
||||
.options
|
||||
.and_then(|options| options.object_lock_config_snapshot.clone()),
|
||||
@@ -8926,7 +8953,11 @@ impl SetDisks {
|
||||
|
||||
for disk in disks.iter() {
|
||||
let disk_namespace_owner = namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>);
|
||||
let transaction_guard = commit_context.transaction_guard.as_ref();
|
||||
futures.push(async move {
|
||||
if transaction_guard.is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
if let Some(disk) = disk {
|
||||
let options = DeleteOptions {
|
||||
old_data_dir: Some(rollback_dir),
|
||||
@@ -8976,6 +9007,7 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
commit_context.ensure_transaction_lock_held(bucket, object)?;
|
||||
let mut quorum_result = resolve_tiered_decommission_write_quorum_result(&errs, write_quorum, bucket, object);
|
||||
if !is_meta_bucketname(bucket) && errs.iter().flatten().any(super::delete_recovery::unsupported_recovery) {
|
||||
quorum_result = Err(DiskError::UnsupportedDisk.into());
|
||||
@@ -9008,6 +9040,7 @@ impl SetDisks {
|
||||
write_quorum,
|
||||
DeleteRecoveryContext {
|
||||
recovery_ticket,
|
||||
transaction_guard: commit_context.transaction_guard.clone(),
|
||||
namespace_owner: namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>),
|
||||
..DeleteRecoveryContext::new(source_bucket_incarnation_id, &|| commit_context.can_commit(bucket, object))
|
||||
},
|
||||
@@ -9017,6 +9050,7 @@ impl SetDisks {
|
||||
}
|
||||
#[cfg(test)]
|
||||
pause_delete_before_cleanup(bucket).await;
|
||||
commit_context.ensure_transaction_lock_held(bucket, object)?;
|
||||
let mut rollback_futures = Vec::new();
|
||||
for (index, err) in errs.iter().enumerate() {
|
||||
if structured_cleanup {
|
||||
@@ -9042,7 +9076,11 @@ impl SetDisks {
|
||||
let object = object.to_string();
|
||||
let fi = fi.clone();
|
||||
let disk_namespace_owner = namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>);
|
||||
let transaction_guard = commit_context.transaction_guard.as_ref();
|
||||
rollback_futures.push(async move {
|
||||
if transaction_guard.is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return;
|
||||
}
|
||||
if should_rollback {
|
||||
// The dedicated undo path never forwards the marker-only creation flag.
|
||||
if let Err(err) = disk
|
||||
@@ -9099,6 +9137,7 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
join_all(rollback_futures).await;
|
||||
commit_context.ensure_transaction_lock_held(bucket, object)?;
|
||||
drop(namespace_owner);
|
||||
if quorum_result.is_ok()
|
||||
&& errs.iter().any(Option::is_some)
|
||||
@@ -9294,36 +9333,56 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
}
|
||||
};
|
||||
|
||||
let (read_quorum, write_quorum) = match Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count) {
|
||||
Ok((r, w)) => (
|
||||
usize::try_from(r)
|
||||
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![src_bucket, src_object]))?,
|
||||
usize::try_from(w)
|
||||
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![src_bucket, src_object]))?,
|
||||
),
|
||||
let src_version_id = src_opts.version_id.as_deref().unwrap_or_default();
|
||||
let quorum = Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count);
|
||||
let selection = match &quorum {
|
||||
Ok((read_quorum, write_quorum)) => {
|
||||
let read_quorum = usize::try_from(*read_quorum)
|
||||
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![src_bucket, src_object]))?;
|
||||
let write_quorum = usize::try_from(*write_quorum)
|
||||
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![src_bucket, src_object]))?;
|
||||
Self::select_fileinfo_for_read(src_bucket, &disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
|
||||
.map(|(online_disks, fi)| (online_disks, fi, write_quorum))
|
||||
}
|
||||
Err(err) => Err(err.clone()),
|
||||
};
|
||||
let (online_disks, mut fi, write_quorum) = match selection {
|
||||
Ok(selection) => selection,
|
||||
Err(mut err) => {
|
||||
if err == DiskError::ErasureReadQuorum
|
||||
&& !src_bucket.starts_with(RUSTFS_META_BUCKET)
|
||||
&& self
|
||||
.delete_if_dangling(src_bucket, src_object, &metas, &errs, &HashMap::new(), src_opts.clone())
|
||||
.await
|
||||
.is_ok()
|
||||
// The merged latest version can be one a failed PUT left below
|
||||
// quorum; resolve it like a latest GET before the dangling
|
||||
// cleanup below can report a key with a committed version absent.
|
||||
if let Some(snapshot) = self
|
||||
.select_latest_past_dangling_versions(src_bucket, src_object, src_opts, true, &disks, &err)
|
||||
.await
|
||||
{
|
||||
if src_opts.version_id.is_some() {
|
||||
err = DiskError::FileVersionNotFound
|
||||
} else {
|
||||
err = DiskError::FileNotFound
|
||||
// A version-only copy writes the served version's own
|
||||
// copies, so they and its write quorum replace the latest
|
||||
// read's.
|
||||
let (fi, parts_metadata, online_disks) = snapshot.into_owned();
|
||||
metas = parts_metadata;
|
||||
let write_quorum = fi.write_quorum(self.default_write_quorum());
|
||||
(online_disks, fi, write_quorum)
|
||||
} else {
|
||||
if quorum.is_err()
|
||||
&& err == DiskError::ErasureReadQuorum
|
||||
&& !src_bucket.starts_with(RUSTFS_META_BUCKET)
|
||||
&& self
|
||||
.delete_if_dangling(src_bucket, src_object, &metas, &errs, &HashMap::new(), src_opts.clone())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
if src_opts.version_id.is_some() {
|
||||
err = DiskError::FileVersionNotFound
|
||||
} else {
|
||||
err = DiskError::FileNotFound
|
||||
}
|
||||
}
|
||||
return Err(to_object_err(err.into(), vec![src_bucket, src_object]));
|
||||
}
|
||||
return Err(to_object_err(err.into(), vec![src_bucket, src_object]));
|
||||
}
|
||||
};
|
||||
|
||||
let src_version_id = src_opts.version_id.as_deref().unwrap_or_default();
|
||||
let (online_disks, mut fi) =
|
||||
Self::select_fileinfo_for_read(src_bucket, &disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
|
||||
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
|
||||
|
||||
if fi.deleted {
|
||||
if src_opts.version_id.is_none() {
|
||||
return Err(to_object_err(Error::FileNotFound, vec![src_bucket, src_object]));
|
||||
@@ -9461,6 +9520,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
// A version-only copy writes and then reverts on a quorum miss;
|
||||
// refuse it before writing when the served version's disks cannot
|
||||
// reach its write quorum even if every write succeeds.
|
||||
let reachable = online_disks
|
||||
.iter()
|
||||
.map(|disk| disk.is_none().then_some(DiskError::DiskNotFound))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(err) = reduce_write_quorum_errs(&reachable, OBJECT_OP_IGNORED_ERRS, write_quorum) {
|
||||
return Err(to_object_err(err.into(), vec![src_bucket, src_object]));
|
||||
}
|
||||
Self::write_unique_file_info(&online_disks, "", src_bucket, src_object, &metas, write_quorum)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
|
||||
@@ -9528,9 +9597,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&self,
|
||||
bucket: &str,
|
||||
objects: Vec<ObjectToDelete>,
|
||||
opts: ObjectOptions,
|
||||
mut opts: ObjectOptions,
|
||||
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
|
||||
let current_generations = opts.conditional_delete_generations.as_deref();
|
||||
let mut del_objects = vec![DeletedObject::default(); objects.len()];
|
||||
let mut accounting = vec![None; objects.len()];
|
||||
let delete_config_snapshot = opts
|
||||
@@ -9625,6 +9693,30 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
let outer_namespace_lock_fence = opts.namespace_lock_fence.clone();
|
||||
let mut transaction_guards = std::collections::BTreeMap::new();
|
||||
if !is_meta_bucketname(bucket) {
|
||||
// Every raw batch lock is acquired first. Canonical sorting also
|
||||
// gives aliases of one disk key exactly one transaction owner.
|
||||
let keys: std::collections::BTreeSet<_> = locked_objects.iter().cloned().collect();
|
||||
for key in keys {
|
||||
match self.acquire_delete_transaction_lock(bucket, &key).await {
|
||||
Ok(guard) => {
|
||||
opts.add_namespace_lock_guard(&guard);
|
||||
transaction_guards.insert(key, guard);
|
||||
}
|
||||
Err(error) => {
|
||||
for (index, lock_name) in lock_names.iter().enumerate() {
|
||||
if lock_name.as_ref() == key {
|
||||
del_errs[index] = Some(error.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let transaction_guards = Arc::new(transaction_guards);
|
||||
let current_generations = opts.conditional_delete_generations.as_deref();
|
||||
let object_lock_config = if is_meta_bucketname(bucket) {
|
||||
Arc::new(ObjectLockConfigSnapshot::new(ObjectLockConfigState::ConfirmedAbsent))
|
||||
} else {
|
||||
@@ -9722,6 +9814,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&dobj.object_name,
|
||||
DeleteRecoveryContext {
|
||||
dry_run: !opts.no_lock,
|
||||
transaction_guard: transaction_guards.get(lock_names[i].as_ref()).cloned(),
|
||||
namespace_owner: opts.delete_mutation_owner.clone(),
|
||||
..DeleteRecoveryContext::new(opts.expected_bucket_incarnation_id, &|| {
|
||||
ensure_delete_commit_locks_held(None, bucket, &dobj.object_name, &opts).is_ok()
|
||||
@@ -10003,6 +10096,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
let namespace_owner = (!is_meta_bucketname(bucket)).then(|| {
|
||||
Arc::new((
|
||||
self.ctx.begin_namespace_commit(),
|
||||
Arc::clone(&transaction_guards),
|
||||
opts.delete_mutation_owner.clone(),
|
||||
opts.object_lock_config_snapshot.clone(),
|
||||
opts.scanner_publication_commit_scope.clone(),
|
||||
@@ -10178,6 +10272,24 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
}
|
||||
|
||||
let results = join_all(futures).await;
|
||||
if transaction_guards.values().any(|guard| guard.is_lock_lost()) {
|
||||
for (index, object) in objects.iter().enumerate() {
|
||||
if del_errs[index].is_none() {
|
||||
accounting[index] = None;
|
||||
del_errs[index] = Some(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "delete_transaction",
|
||||
bucket: bucket.to_owned(),
|
||||
object: object.object_name.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
if dist_erasure {
|
||||
self.release_dist_delete_object_locks_batch(dist_batch_lock_ids).await;
|
||||
}
|
||||
return (del_objects, del_errs, accounting);
|
||||
}
|
||||
|
||||
let mut del_obj_errs: Vec<Vec<Option<DiskError>>> = vec![vec![None; objects.len()]; disks.len()];
|
||||
|
||||
@@ -10273,6 +10385,9 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
let group = &vers[index];
|
||||
let recovery_ticket = recovery_tickets[index].clone();
|
||||
let namespace_owner = namespace_owner.clone();
|
||||
let transaction_guard = transaction_guards
|
||||
.get(object_name_disk_identity(&group.name).as_ref())
|
||||
.cloned();
|
||||
let disks = &disks;
|
||||
let opts = &opts;
|
||||
async move {
|
||||
@@ -10293,6 +10408,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
DeleteRecoveryContext {
|
||||
recovery_ticket,
|
||||
namespace_owner,
|
||||
transaction_guard,
|
||||
..DeleteRecoveryContext::new(opts.expected_bucket_incarnation_id, &can_commit)
|
||||
},
|
||||
)
|
||||
@@ -10339,7 +10455,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
let object = fi_vers.name.clone();
|
||||
let versions = fi_vers.clone();
|
||||
let disk_namespace_owner = namespace_owner.clone();
|
||||
let transaction_guard = transaction_guards.get(object_name_disk_identity(&object).as_ref()).cloned();
|
||||
rollback_futures.push(async move {
|
||||
if transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return;
|
||||
}
|
||||
if should_rollback {
|
||||
let errs = disk
|
||||
.delete_versions_with_namespace_owner(
|
||||
@@ -10415,7 +10535,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
if del_errs[index].is_none() {
|
||||
let object = &objects[index].object_name;
|
||||
let result = match ensure_delete_commit_locks_held(None, bucket, object, &opts) {
|
||||
Ok(()) => self.reclaim_missing_delete_residue(bucket, object, &opts, None).await,
|
||||
Ok(()) => {
|
||||
self.reclaim_missing_delete_residue(
|
||||
bucket,
|
||||
object,
|
||||
&opts,
|
||||
None,
|
||||
transaction_guards.get(object_name_disk_identity(object).as_ref()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
@@ -10424,6 +10553,13 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
// Deferred recovery takes fresh raw and transaction locks. Discard the
|
||||
// completed leases' deadlines while retaining every caller-owned fence.
|
||||
opts.namespace_lock_fence = outer_namespace_lock_fence;
|
||||
// Release inner owners before reacquiring any raw key lock.
|
||||
drop(namespace_owner);
|
||||
drop(transaction_guards);
|
||||
|
||||
// TODO(backlog): support partial object deletion for multi-part objects
|
||||
|
||||
if dist_erasure {
|
||||
@@ -10533,6 +10669,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Lock order: the caller's raw object lock, then this target set's
|
||||
// transaction key. The inner key remains distinct even when pool
|
||||
// client quorums overlap, and no_lock never bypasses journal ownership.
|
||||
let transaction_guard = if !is_meta_bucketname(bucket) && (!opts.delete_prefix || opts.delete_prefix_object) {
|
||||
let guard = self.acquire_delete_transaction_lock(bucket, object).await?;
|
||||
opts.add_namespace_lock_guard(&guard);
|
||||
Some(guard)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if opts.delete_prefix {
|
||||
let destructive_dispatch_phase = opts.lifecycle_delete_all.as_ref().is_none_or(|request| {
|
||||
matches!(
|
||||
@@ -10633,7 +10779,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&delete_request,
|
||||
false,
|
||||
None,
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref())
|
||||
.with_transaction_guard(transaction_guard.as_ref()),
|
||||
)
|
||||
.await?;
|
||||
if let Some((_, deleted_object)) = replication_delete {
|
||||
@@ -10667,7 +10814,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&delete_request,
|
||||
false,
|
||||
None,
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref())
|
||||
.with_transaction_guard(transaction_guard.as_ref()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -10687,7 +10835,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&delete_request,
|
||||
false,
|
||||
None,
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref())
|
||||
.with_transaction_guard(transaction_guard.as_ref()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -10776,6 +10925,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
bucket,
|
||||
object,
|
||||
DeleteRecoveryContext {
|
||||
transaction_guard: transaction_guard.clone(),
|
||||
namespace_owner: Some(Arc::new((
|
||||
self.ctx.begin_namespace_commit(),
|
||||
_lock_guard.clone(),
|
||||
@@ -10816,7 +10966,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
if is_err_object_not_found(err) {
|
||||
ensure_delete_commit_locks_held(_lock_guard.as_deref(), bucket, object, &opts)?;
|
||||
if self
|
||||
.reclaim_missing_delete_residue(bucket, object, &opts, _lock_guard.as_ref())
|
||||
.reclaim_missing_delete_residue(bucket, object, &opts, _lock_guard.as_ref(), transaction_guard.as_ref())
|
||||
.await?
|
||||
{
|
||||
if let Some(scope) = scanner_publication_commit_scope.as_ref() {
|
||||
@@ -10866,7 +11016,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&dfi,
|
||||
false,
|
||||
None,
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()).with_transaction_guard(transaction_guard.as_ref()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
|
||||
@@ -10968,7 +11118,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&fi,
|
||||
should_force_delete_marker_for_missing_version(&opts),
|
||||
delete_marker_purge.clone(),
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()).with_transaction_guard(transaction_guard.as_ref()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
|
||||
@@ -11033,7 +11183,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
&dfi,
|
||||
opts.delete_marker,
|
||||
delete_marker_purge,
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()),
|
||||
DeleteCommitContext::request(&opts, _lock_guard.as_ref()).with_transaction_guard(transaction_guard.as_ref()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
|
||||
@@ -11147,45 +11297,63 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
}
|
||||
};
|
||||
|
||||
let (read_quorum, write_quorum) = match Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count) {
|
||||
Ok((read_quorum, write_quorum)) => (read_quorum, write_quorum),
|
||||
let version_id = opts.version_id.as_deref().unwrap_or_default();
|
||||
let quorum = Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count);
|
||||
let selection = match &quorum {
|
||||
Ok((read_quorum, write_quorum)) => {
|
||||
let read_quorum = usize::try_from(*read_quorum)
|
||||
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]))?;
|
||||
let write_quorum = usize::try_from(*write_quorum)
|
||||
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![bucket, object]))?;
|
||||
Self::select_fileinfo_for_read(bucket, &disks, &metas, &errs, version_id, read_quorum, write_quorum)
|
||||
}
|
||||
Err(err) => Err(err.clone()),
|
||||
};
|
||||
let (online_disks, mut fi) = match selection {
|
||||
Ok(selection) => selection,
|
||||
Err(mut err) => {
|
||||
// A replication terminal CAS is a validation/write-back
|
||||
// attempt, never a heal operation. In particular,
|
||||
// ValidateOnly must remain zero-write, and a worker whose
|
||||
// outer replication lease is already lost must not delete the
|
||||
// surviving xl.meta copies before the namespace fence below
|
||||
// can reject it. Leave dangling cleanup to heal/scanner.
|
||||
if opts.replication_status_writeback.is_some() {
|
||||
// The merged latest version can be one a failed PUT left below
|
||||
// quorum; resolve it like a latest GET before the dangling
|
||||
// cleanup below can report a key with a committed version absent.
|
||||
// A replication terminal CAS stays side-effect free: the walk
|
||||
// queues heal for the versions it skips, so it does not run.
|
||||
let resolved = if opts.replication_status_writeback.is_none() {
|
||||
self.select_latest_past_dangling_versions(bucket, object, opts, false, &disks, &err)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(snapshot) = resolved {
|
||||
let (fi, _, online_disks) = snapshot.into_owned();
|
||||
(online_disks, fi)
|
||||
} else {
|
||||
// A replication terminal CAS is a validation/write-back
|
||||
// attempt, never a heal operation. In particular,
|
||||
// ValidateOnly must remain zero-write, and a worker whose
|
||||
// outer replication lease is already lost must not delete
|
||||
// the surviving xl.meta copies before the namespace fence
|
||||
// below can reject it. Leave dangling cleanup to
|
||||
// heal/scanner.
|
||||
if quorum.is_err()
|
||||
&& opts.replication_status_writeback.is_none()
|
||||
&& err == DiskError::ErasureReadQuorum
|
||||
&& !bucket.starts_with(RUSTFS_META_BUCKET)
|
||||
&& self
|
||||
.delete_if_dangling(bucket, object, &metas, &errs, &HashMap::new(), opts.clone())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
if opts.version_id.is_some() {
|
||||
err = DiskError::FileVersionNotFound
|
||||
} else {
|
||||
err = DiskError::FileNotFound
|
||||
}
|
||||
}
|
||||
return Err(to_object_err(err.into(), vec![bucket, object]));
|
||||
}
|
||||
if err == DiskError::ErasureReadQuorum
|
||||
&& !bucket.starts_with(RUSTFS_META_BUCKET)
|
||||
&& self
|
||||
.delete_if_dangling(bucket, object, &metas, &errs, &HashMap::new(), opts.clone())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
if opts.version_id.is_some() {
|
||||
err = DiskError::FileVersionNotFound
|
||||
} else {
|
||||
err = DiskError::FileNotFound
|
||||
}
|
||||
}
|
||||
return Err(to_object_err(err.into(), vec![bucket, object]));
|
||||
}
|
||||
};
|
||||
|
||||
let read_quorum =
|
||||
usize::try_from(read_quorum).map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]))?;
|
||||
let write_quorum = usize::try_from(write_quorum)
|
||||
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![bucket, object]))?;
|
||||
|
||||
let version_id = opts.version_id.as_deref().unwrap_or_default();
|
||||
let (online_disks, mut fi) =
|
||||
Self::select_fileinfo_for_read(bucket, &disks, &metas, &errs, version_id, read_quorum, write_quorum)
|
||||
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
||||
|
||||
if fi.deleted {
|
||||
return Err(to_object_err(Error::MethodNotAllowed, vec![bucket, object]));
|
||||
}
|
||||
@@ -11790,13 +11958,12 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
let bucket_lifecycle_guard = if let Some(expected_incarnation_id) = opts.expected_bucket_incarnation_id
|
||||
&& opts.bucket_lifecycle_lock_fence.is_none()
|
||||
{
|
||||
let guard = metadata_sys::object_store_in(&self.ctx)
|
||||
.await?
|
||||
.acquire_bucket_lifecycle_read_lock(bucket)
|
||||
.await?;
|
||||
self.validate_bucket_incarnation(bucket, expected_incarnation_id, opts)
|
||||
.await?;
|
||||
Some(guard)
|
||||
Some(
|
||||
metadata_sys::object_store_in(&self.ctx)
|
||||
.await?
|
||||
.acquire_bucket_incarnation_fence_with_options(bucket, expected_incarnation_id, Some(opts))
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -25358,6 +25525,7 @@ mod delete_objects_lock_gating_tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn delete_object_aborts_when_outer_lock_is_lost_at_commit() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "single-lost-outer-lock";
|
||||
@@ -25578,6 +25746,13 @@ mod delete_objects_lock_gating_tests {
|
||||
.as_fast_lock_manager()
|
||||
.expect("isolated fixture should use the local lock manager");
|
||||
let lock_metrics = lock_manager.get_metrics().shard_metrics;
|
||||
let raw_key = ObjectKey::new(bucket, "obj-a");
|
||||
let raw_holder = lock_manager.get_lock_info(&raw_key).expect("outer holder must be visible");
|
||||
let transaction_key = ObjectKey::with_version(
|
||||
bucket,
|
||||
"obj-a",
|
||||
format!("rustfs:delete-transaction:{}:{}", set_disks.pool_index, set_disks.set_index),
|
||||
);
|
||||
assert_eq!(lock_manager.current_lock_counts(), (1, 0));
|
||||
let mut delete = Box::pin(set_disks.delete_objects(
|
||||
bucket,
|
||||
@@ -25594,23 +25769,43 @@ mod delete_objects_lock_gating_tests {
|
||||
// than a second on CI without waiting for the namespace lock at all.
|
||||
let deletion = futures::future::poll_fn(|cx| {
|
||||
let result = std::future::Future::poll(delete.as_mut(), cx);
|
||||
let current_holder = lock_manager.get_lock_info(&raw_key).expect("outer holder must remain held");
|
||||
assert_eq!(
|
||||
(current_holder.mode, ¤t_holder.owner, current_holder.acquired_at),
|
||||
(raw_holder.mode, &raw_holder.owner, raw_holder.acquired_at),
|
||||
"no_lock must preserve the original raw object holder"
|
||||
);
|
||||
let transaction_holder = lock_manager.get_lock_info(&transaction_key);
|
||||
if let Some(holder) = &transaction_holder {
|
||||
assert_eq!(holder.mode, rustfs_lock::LockMode::Exclusive);
|
||||
}
|
||||
assert_eq!(
|
||||
lock_manager.current_lock_counts(),
|
||||
(1, 0),
|
||||
"no_lock batch delete must preserve the outer holder without adding lock waiters"
|
||||
(1 + u64::from(transaction_holder.is_some()), 0),
|
||||
"only the distinct transaction owner may be added, without raw-key waiters"
|
||||
);
|
||||
let current = lock_manager.get_metrics().shard_metrics;
|
||||
assert_eq!(
|
||||
(current.total_acquisitions(), current.timeouts),
|
||||
(lock_metrics.total_acquisitions(), lock_metrics.timeouts),
|
||||
"no_lock batch delete must not acquire or time out on another namespace lock"
|
||||
assert!(
|
||||
current.total_acquisitions() <= lock_metrics.total_acquisitions() + 1,
|
||||
"the transaction owner must be reused by inline recovery"
|
||||
);
|
||||
assert_eq!(current.timeouts, lock_metrics.timeouts, "the raw key must never be reacquired");
|
||||
result
|
||||
});
|
||||
let (deleted, errs) = tokio::time::timeout(Duration::from_secs(30), deletion)
|
||||
.await
|
||||
.expect("no_lock batch delete disk work should complete");
|
||||
|
||||
assert_eq!(lock_manager.current_lock_counts(), (1, 0));
|
||||
assert!(
|
||||
lock_manager.get_lock_info(&transaction_key).is_none(),
|
||||
"the transaction owner must be released"
|
||||
);
|
||||
assert_eq!(
|
||||
lock_manager.get_metrics().shard_metrics.total_acquisitions(),
|
||||
lock_metrics.total_acquisitions() + 1,
|
||||
"no_lock still requires exactly one distinct transaction owner"
|
||||
);
|
||||
assert!(errs[0].is_none(), "no_lock batch delete should not fail with a lock error: {:?}", errs[0]);
|
||||
assert!(deleted[0].found, "existing object must still be deleted");
|
||||
for disk in &disk_stores {
|
||||
|
||||
@@ -264,6 +264,11 @@ enum MetadataReadPurpose {
|
||||
GetObjectReader,
|
||||
}
|
||||
|
||||
/// Upper bound on exact-version reads a latest read spends looking past
|
||||
/// dangling versions before it keeps the read-quorum error; each read is one
|
||||
/// metadata fan-out.
|
||||
const MAX_DANGLING_LATEST_VERSIONS: usize = 8;
|
||||
|
||||
/// How a metadata read-quorum failure is reported when the replies prove the
|
||||
/// object or version dangling. Only the object read entry points
|
||||
/// (`get_object_info`, `get_object_reader` and prepared GET metadata) report
|
||||
@@ -273,6 +278,10 @@ enum MetadataReadPurpose {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum DanglingRead {
|
||||
KeepQuorumError,
|
||||
/// Metadata updates of the latest version keep the quorum error but, like
|
||||
/// object reads, look past newer versions proven dangling
|
||||
/// (`read_latest_past_dangling_versions`).
|
||||
SkipDanglingVersions,
|
||||
ReportNotFound,
|
||||
}
|
||||
|
||||
@@ -724,9 +733,10 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
/// Like `get_object_fileinfo`, but `allow_early_stop=false` forces the full
|
||||
/// quorum fanout. Read-before-write callers (object tagging) must use this:
|
||||
/// the returned online-disk set is the write target, and the early-stop
|
||||
/// subset would fail write quorum (backlog#872 regression fix).
|
||||
/// quorum fanout. Read-before-write callers must use this or
|
||||
/// `get_object_fileinfo_for_metadata_update`: the returned online-disk set
|
||||
/// is the write target, and the early-stop subset would fail write quorum
|
||||
/// (backlog#872 regression fix).
|
||||
#[tracing::instrument(level = "debug", skip(self))]
|
||||
pub(in crate::set_disk) async fn get_object_fileinfo_gated(
|
||||
&self,
|
||||
@@ -748,6 +758,64 @@ impl SetDisks {
|
||||
.await
|
||||
}
|
||||
|
||||
/// The full-fanout read behind a metadata update such as object tagging.
|
||||
/// A latest read whose newest versions never committed resolves to the
|
||||
/// version a latest GET serves, while a dangling key keeps the quorum
|
||||
/// error. The returned online disks are the write target.
|
||||
#[tracing::instrument(level = "debug", skip(self))]
|
||||
pub(in crate::set_disk) async fn get_object_fileinfo_for_metadata_update(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<GetObjectFileInfo> {
|
||||
self.get_object_fileinfo_gated_inner(
|
||||
bucket,
|
||||
object,
|
||||
opts,
|
||||
false,
|
||||
false,
|
||||
MetadataReadPurpose::FullLayout,
|
||||
DanglingRead::SkipDanglingVersions,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Recovers a failed latest selection in read-before-write callers that
|
||||
/// read and select metadata themselves (`put_object_metadata` and the
|
||||
/// CopyObject source) the way a latest GET does. `None` keeps the caller's
|
||||
/// error: a failure other than read quorum, an explicit version, the
|
||||
/// system bucket's commit-proof rule, or a walk that finds no readable
|
||||
/// version or cannot read every drive. The selected metadata keeps its
|
||||
/// part checksums because a version-only copy carries them into the new
|
||||
/// version.
|
||||
pub(in crate::set_disk) async fn select_latest_past_dangling_versions(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
read_data: bool,
|
||||
disks: &[Option<DiskStore>],
|
||||
err: &DiskError,
|
||||
) -> Option<GetObjectFileInfo> {
|
||||
if *err != DiskError::ErasureReadQuorum || opts.version_id.is_some() || crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
return None;
|
||||
}
|
||||
let opts = ObjectOptions {
|
||||
include_part_checksums: true,
|
||||
..opts.clone()
|
||||
};
|
||||
Box::pin(self.read_latest_past_dangling_versions(
|
||||
bucket,
|
||||
object,
|
||||
&opts,
|
||||
read_data,
|
||||
MetadataReadPurpose::FullLayout,
|
||||
disks,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn get_object_fileinfo_gated_inner(
|
||||
&self,
|
||||
@@ -971,6 +1039,18 @@ impl SetDisks {
|
||||
{
|
||||
Ok(selection) => selection,
|
||||
Err(err) => {
|
||||
if err == DiskError::ErasureReadQuorum
|
||||
&& vid.is_empty()
|
||||
&& dangling_read != DanglingRead::KeepQuorumError
|
||||
&& metadata_fanout_complete
|
||||
&& !crate::bucket::utils::is_meta_bucketname(bucket)
|
||||
&& let Some(snapshot) =
|
||||
Box::pin(self.read_latest_past_dangling_versions(bucket, object, opts, read_data, purpose, &disks))
|
||||
.await
|
||||
{
|
||||
rustfs_io_metrics::record_get_object_metadata_quorum_result(metadata_metrics_path, "reached");
|
||||
return Ok(snapshot);
|
||||
}
|
||||
let err = StorageError::from(err);
|
||||
rustfs_io_metrics::record_get_object_metadata_quorum_result(
|
||||
metadata_metrics_path,
|
||||
@@ -1051,6 +1131,168 @@ impl SetDisks {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// A latest read whose split cannot reach read quorum may still have a
|
||||
/// readable older version under newer versions that never committed;
|
||||
/// MinIO resolves this in `pickLatestQuorumFilesInfo`. With every drive
|
||||
/// answering, try versions newest first through exact-version reads and
|
||||
/// serve the readable one with the newest selected modification time. A
|
||||
/// version whose exact read proves it dangling (the NoSuchVersion an
|
||||
/// explicit GET reports) is skipped and queued for heal, which owns its
|
||||
/// cleanup; any other outcome keeps the read-quorum error.
|
||||
async fn read_latest_past_dangling_versions(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
read_data: bool,
|
||||
purpose: MetadataReadPurpose,
|
||||
disks: &[Option<DiskStore>],
|
||||
) -> Option<GetObjectFileInfo> {
|
||||
let disk_object = rustfs_utils::path::encode_dir_object(object);
|
||||
let (raw, errs) = Self::read_all_raw_file_info(disks, bucket, &disk_object, false).await;
|
||||
if errs
|
||||
.iter()
|
||||
.any(|err| err.as_ref().is_some_and(|err| !matches!(err, DiskError::FileNotFound)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Stacks are newest first, so only each drive's first few headers can
|
||||
// matter. Keep a version's newest modification time across drives: a
|
||||
// rewritten null version can carry different times on different drives.
|
||||
let mut newest = std::collections::HashMap::<Option<uuid::Uuid>, Option<time::OffsetDateTime>>::new();
|
||||
let mut truncated = false;
|
||||
let mut metadata = Vec::with_capacity(raw.len());
|
||||
for raw in raw.into_iter().flatten() {
|
||||
let mut meta = rustfs_filemeta::FileMeta::load(&raw.buf).ok()?;
|
||||
let mut versions = meta
|
||||
.versions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, version)| !version.header.free_version());
|
||||
let mut retained_end = 0;
|
||||
for (index, version) in versions.by_ref().take(MAX_DANGLING_LATEST_VERSIONS) {
|
||||
let mod_time = newest.entry(version.header.version_id).or_insert(version.header.mod_time);
|
||||
*mod_time = (*mod_time).max(version.header.mod_time);
|
||||
retained_end = index + 1;
|
||||
}
|
||||
truncated |= versions.next().is_some();
|
||||
meta.versions.truncate(retained_end);
|
||||
metadata.push(meta);
|
||||
}
|
||||
let mut candidates = newest.into_iter().collect::<Vec<_>>();
|
||||
candidates.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
|
||||
|
||||
// Use the stored header for equal-time ordering: re-encoding a selected
|
||||
// FileInfo can change legacy signatures or omit part-checksum metadata.
|
||||
let selected_header = |snapshot: &GetObjectFileInfo| {
|
||||
let mut selected = snapshot.fi().clone();
|
||||
selected.data = None;
|
||||
let version_id = selected.version_id.unwrap_or_else(uuid::Uuid::nil).to_string();
|
||||
let include_part_checksums =
|
||||
opts.include_part_checksums || opts.part_number.is_some() || opts.data_movement || opts.raw_data_movement_read;
|
||||
metadata.iter().find_map(|meta| {
|
||||
let version = meta
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| !version.header.free_version())
|
||||
.take(MAX_DANGLING_LATEST_VERSIONS)
|
||||
.find(|version| {
|
||||
version.header.version_id.unwrap_or_else(uuid::Uuid::nil)
|
||||
== selected.version_id.unwrap_or_else(uuid::Uuid::nil)
|
||||
&& version.header.mod_time == selected.mod_time
|
||||
})?;
|
||||
let mut original = if include_part_checksums {
|
||||
meta.into_fileinfo(&selected.volume, &selected.name, &version_id, false, opts.incl_free_versions, true)
|
||||
} else {
|
||||
meta.into_fileinfo_without_part_checksums(
|
||||
&selected.volume,
|
||||
&selected.name,
|
||||
&version_id,
|
||||
false,
|
||||
opts.incl_free_versions,
|
||||
)
|
||||
}
|
||||
.ok()?;
|
||||
if !include_part_checksums {
|
||||
rustfs_utils::http::remove_str(&mut original.metadata, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
|
||||
}
|
||||
original.data = None;
|
||||
original.is_latest = selected.is_latest;
|
||||
original.successor_mod_time = selected.successor_mod_time;
|
||||
original.num_versions = selected.num_versions;
|
||||
(original == selected).then_some(version.header.clone())
|
||||
})
|
||||
};
|
||||
|
||||
let mut best: Option<(Option<time::OffsetDateTime>, GetObjectFileInfo)> = None;
|
||||
let mut skipped = Vec::new();
|
||||
let mut settled = false;
|
||||
for (reads, (version_id, newest_mod_time)) in candidates.into_iter().enumerate() {
|
||||
if best.as_ref().is_some_and(|(selected, _)| newest_mod_time < *selected) {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
if reads == MAX_DANGLING_LATEST_VERSIONS {
|
||||
return None;
|
||||
}
|
||||
// The nil UUID addresses the null version slot at this layer.
|
||||
let version_id = version_id.unwrap_or_else(uuid::Uuid::nil).to_string();
|
||||
let exact = ObjectOptions {
|
||||
version_id: Some(version_id.clone()),
|
||||
..opts.clone()
|
||||
};
|
||||
match Box::pin(self.get_object_fileinfo_gated_inner(
|
||||
bucket,
|
||||
object,
|
||||
&exact,
|
||||
read_data,
|
||||
false,
|
||||
purpose,
|
||||
DanglingRead::ReportNotFound,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(snapshot) => {
|
||||
let selected = snapshot.fi().mod_time;
|
||||
let replace = match best.as_ref() {
|
||||
Some((current, previous)) if selected == *current => {
|
||||
selected_header(&snapshot)?.sorts_before(&selected_header(previous)?)
|
||||
}
|
||||
Some((current, _)) => selected > *current,
|
||||
None => true,
|
||||
};
|
||||
if replace {
|
||||
best = Some((selected, snapshot));
|
||||
}
|
||||
}
|
||||
Err(StorageError::VersionNotFound(..)) => skipped.push(version_id),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
// Headers past each drive's first few were not compared; only a walk
|
||||
// that proved no remaining candidate can be newer may stop early.
|
||||
if !settled && truncated {
|
||||
return None;
|
||||
}
|
||||
let (_, mut snapshot) = best?;
|
||||
snapshot.mark_selected_as_latest();
|
||||
if !opts.suppress_read_repair {
|
||||
for version_id in &skipped {
|
||||
submit_read_repair_heal(
|
||||
bucket,
|
||||
object,
|
||||
Some(version_id),
|
||||
self.pool_index,
|
||||
self.set_index,
|
||||
None,
|
||||
"dangling_latest_version",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
#[hotpath::measure(impl_type = "SetDisks")]
|
||||
pub(super) async fn get_object_info_fileinfo_and_quorum(
|
||||
&self,
|
||||
@@ -9751,6 +9993,7 @@ mod dangling_read_tests {
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::ObjectOperations;
|
||||
use http::HeaderMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -9761,9 +10004,13 @@ mod dangling_read_tests {
|
||||
const PARITY: usize = 2;
|
||||
|
||||
async fn dangling_test_set(bucket: &str) -> (Vec<TempDir>, Arc<SetDisks>) {
|
||||
dangling_test_set_with(bucket, DRIVES, PARITY).await
|
||||
}
|
||||
|
||||
async fn dangling_test_set_with(bucket: &str, drives: usize, parity: usize) -> (Vec<TempDir>, Arc<SetDisks>) {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
ctx.update_erasure_type(crate::layout::endpoints::SetupType::Erasure).await;
|
||||
let (dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks_with_ctx(DRIVES, PARITY, ctx).await;
|
||||
let (dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks_with_ctx(drives, parity, ctx).await;
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("test bucket should be created on every drive");
|
||||
@@ -9777,6 +10024,9 @@ mod dangling_read_tests {
|
||||
&mut PutObjReader::from_vec(body.to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned,
|
||||
// Wait for every drive so tests that edit one drive's metadata
|
||||
// do not race the PUT's background tail writes.
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -9796,6 +10046,25 @@ mod dangling_read_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `version_id` from `disks`, modelling drives a failed PUT never
|
||||
/// reached while the older versions below it stay intact.
|
||||
async fn drop_version_from(set: &SetDisks, bucket: &str, object: &str, version_id: Uuid, disks: std::ops::Range<usize>) {
|
||||
let all = set.disks.read().await.clone();
|
||||
for disk in &all[disks] {
|
||||
let disk = disk.as_ref().expect("every drive should be online");
|
||||
let mut remove = FileInfo {
|
||||
volume: bucket.to_string(),
|
||||
name: object.to_string(),
|
||||
version_id: Some(version_id),
|
||||
..Default::default()
|
||||
};
|
||||
remove.set_skip_tier_free_version();
|
||||
disk.delete_version(bucket, object, remove, false, DeleteOptions::default())
|
||||
.await
|
||||
.expect("dropping the version should model a drive that never received it");
|
||||
}
|
||||
}
|
||||
|
||||
fn read_opts(version_id: Option<&str>) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
version_id: version_id.map(str::to_owned),
|
||||
@@ -9955,12 +10224,642 @@ mod dangling_read_tests {
|
||||
head(&set, bucket, object, Some(&v1))
|
||||
.await
|
||||
.expect("HEAD of the committed version must succeed");
|
||||
// The latest read sees two generations (version two on three drives,
|
||||
// version one on the rest), which is not proof of absence.
|
||||
let Err(err) = head(&set, bucket, object, None).await else {
|
||||
panic!("a split latest version must not resolve without quorum");
|
||||
// The latest read sees version two on three drives and version one on
|
||||
// the rest. Version two's exact read proves it dangling, so the latest
|
||||
// read serves version one, the newest version an exact read can serve.
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("the latest read must serve the newest readable version");
|
||||
assert_eq!(latest.version_id.map(|id| id.to_string()).as_deref(), Some(v1.as_str()));
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"version one");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_skips_dangling_versions_above_a_committed_version() {
|
||||
// EC 12+4: a failed PUT that left version two on 5..=11 drives keeps
|
||||
// version one, present in every stack, below its read quorum of twelve
|
||||
// when only the top of each stack is read. Up to seven copies the
|
||||
// listing quorum already shows version one; from eight it lists the
|
||||
// dangling version two.
|
||||
let bucket = "dangling-latest-12-4";
|
||||
let (_dirs, set) = dangling_test_set_with(bucket, 16, 4).await;
|
||||
for copies in 5..=11 {
|
||||
let object = format!("torn/latest-{copies:02}");
|
||||
let v1 = put(&set, bucket, &object, b"committed version", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
let v2 = put(&set, bucket, &object, b"failed overwrite", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(&set, bucket, &object, v2, copies..16).await;
|
||||
|
||||
let context = format!("version two on {copies} of 16 drives");
|
||||
let latest = head(&set, bucket, &object, None)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{context}: HEAD must serve version one: {err:?}"));
|
||||
assert_eq!(latest.version_id, Some(v1), "{context}");
|
||||
assert!(latest.is_latest, "{context}: the served version is the latest one");
|
||||
assert_eq!(
|
||||
get(&set, bucket, &object, None)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{context}: GET must serve version one: {err:?}")),
|
||||
b"committed version",
|
||||
"{context}"
|
||||
);
|
||||
let Err(err) = head(&set, bucket, &object, Some(&v2.to_string())).await else {
|
||||
panic!("{context}: the dangling version must not be served");
|
||||
};
|
||||
assert!(matches!(err, StorageError::VersionNotFound(..)), "{context}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_keeps_quorum_error_when_the_newest_version_is_uncertain() {
|
||||
// Version two is on every drive, but a torn metadata update left two
|
||||
// identities at one modification time, three copies each. Its exact
|
||||
// read fails with a quorum error rather than NoSuchVersion, so the
|
||||
// walk must not fall through to version one.
|
||||
let bucket = "dangling-latest-uncertain";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
put(&set, bucket, object, b"version one", true).await;
|
||||
let v2 = put(&set, bucket, object, b"version two", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id")
|
||||
.to_string();
|
||||
let disks = set.disks.read().await.clone();
|
||||
for disk in disks.iter().take(3) {
|
||||
let disk = disk.as_ref().expect("every drive should be online");
|
||||
let mut fi = disk
|
||||
.read_version("", bucket, object, &v2, &ReadOptions::default())
|
||||
.await
|
||||
.expect("version two should be on every drive");
|
||||
fi.metadata.insert("x-amz-meta-torn".to_string(), "half-applied".to_string());
|
||||
disk.update_metadata(bucket, object, fi, &crate::disk::UpdateMetadataOpts::default())
|
||||
.await
|
||||
.expect("a half-applied metadata update should be stored");
|
||||
}
|
||||
|
||||
assert_get_and_head_keep_read_quorum_error(&set, bucket, object, "torn metadata update on version two").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_serves_a_committed_null_version_below_a_dangling_version() {
|
||||
// A null version written while versioning was suspended sits between
|
||||
// version one and a dangling version three. The walk reaches the null
|
||||
// slot through the nil UUID and serves it, not version one.
|
||||
let bucket = "dangling-latest-null";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
let v1 = put(&set, bucket, object, b"version one", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"null version".to_vec()),
|
||||
&ObjectOptions {
|
||||
version_suspended: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a suspended-versioning PUT should commit the null version");
|
||||
let v3 = put(&set, bucket, object, b"version three", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(&set, bucket, object, v3, 3..DRIVES).await;
|
||||
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("the committed null version must be served");
|
||||
assert_ne!(latest.version_id, Some(v1));
|
||||
assert_ne!(latest.version_id, Some(v3));
|
||||
assert!(latest.is_latest);
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"null version");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_orders_versions_by_their_newest_modification_time() {
|
||||
// Drive 0 missed the suspended-versioning rewrite of the null version,
|
||||
// so its null copy predates version one while every other drive holds
|
||||
// the committed rewrite. Ordering by the first drive's time would try
|
||||
// version one before the null version and serve it.
|
||||
let bucket = "dangling-latest-order";
|
||||
let object = "torn/latest";
|
||||
let (dirs, set) = dangling_test_set(bucket).await;
|
||||
let suspended = ObjectOptions {
|
||||
version_suspended: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(is_read_quorum_error(&err), "a split latest version keeps the quorum error: {err:?}");
|
||||
set.put_object(bucket, object, &mut PutObjReader::from_vec(b"stale null".to_vec()), &suspended)
|
||||
.await
|
||||
.expect("the first null version should commit");
|
||||
let v1 = put(&set, bucket, object, b"version one", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
let drive_zero = object_dir(&dirs, 0, bucket, object).join(STORAGE_FORMAT_FILE);
|
||||
let stale = tokio::fs::read(&drive_zero)
|
||||
.await
|
||||
.expect("drive zero metadata should be readable");
|
||||
set.put_object(bucket, object, &mut PutObjReader::from_vec(b"committed null".to_vec()), &suspended)
|
||||
.await
|
||||
.expect("the null rewrite should commit");
|
||||
tokio::fs::write(&drive_zero, stale)
|
||||
.await
|
||||
.expect("drive zero should keep the pre-rewrite stack");
|
||||
let v5 = put(&set, bucket, object, b"version five", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(&set, bucket, object, v5, 3..DRIVES).await;
|
||||
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("the committed null rewrite must be served");
|
||||
assert_ne!(latest.version_id, Some(v1));
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"committed null");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_preserves_object_before_equal_time_marker() {
|
||||
let bucket = "dangling-latest-equal-time-marker";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
let same_time = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed source mtime");
|
||||
for suspended in [false, true] {
|
||||
let object = if suspended {
|
||||
"torn/equal-time-null"
|
||||
} else {
|
||||
"torn/equal-time-versioned"
|
||||
};
|
||||
let object_id = if suspended { Uuid::nil() } else { Uuid::from_u128(2) };
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"committed object".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: !suspended,
|
||||
version_suspended: suspended,
|
||||
replication_request: true,
|
||||
version_id: Some(object_id.to_string()),
|
||||
mod_time: Some(same_time),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("commit object version");
|
||||
set.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(Uuid::from_u128(1).to_string()),
|
||||
mod_time: Some(same_time),
|
||||
data_movement: true,
|
||||
delete_marker: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("recreate source delete marker during data movement");
|
||||
let expected = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("canonical latest before dangling write");
|
||||
assert_eq!(expected.version_id, (!suspended).then_some(object_id));
|
||||
assert!(!expected.delete_marker, "canonical header order prefers object to equal-time marker");
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("canonical GET"), b"committed object");
|
||||
let dangling_id = Uuid::from_u128(3);
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"failed newer version".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
replication_request: true,
|
||||
version_id: Some(dangling_id.to_string()),
|
||||
mod_time: Some(same_time + time::Duration::seconds(1)),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("commit newer version before modeling failed rollback");
|
||||
drop_version_from(&set, bucket, object, dangling_id, 3..DRIVES).await;
|
||||
let actual = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("latest HEAD past dangling version");
|
||||
let actual_get = get(&set, bucket, object, None).await;
|
||||
assert_eq!(
|
||||
actual.version_id, expected.version_id,
|
||||
"{object}: unreadable newer version must preserve canonical equal-time latest; GET result: {actual_get:?}"
|
||||
);
|
||||
assert!(
|
||||
!actual.delete_marker,
|
||||
"{object}: fallback must preserve the object before its equal-time marker"
|
||||
);
|
||||
assert_eq!(actual_get.expect("latest GET"), b"committed object");
|
||||
|
||||
let checksum_opts = ObjectOptions {
|
||||
include_part_checksums: true,
|
||||
..Default::default()
|
||||
};
|
||||
let checksum_head = set
|
||||
.get_object_info(bucket, object, &checksum_opts)
|
||||
.await
|
||||
.expect("latest HEAD with part checksums");
|
||||
assert_eq!(checksum_head.version_id, expected.version_id, "{object}: checksum HEAD version");
|
||||
assert!(!checksum_head.delete_marker, "{object}: checksum HEAD must return the object");
|
||||
let mut checksum_reader = set
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &checksum_opts)
|
||||
.await
|
||||
.expect("latest GET with part checksums");
|
||||
let mut checksum_body = Vec::new();
|
||||
checksum_reader
|
||||
.stream
|
||||
.read_to_end(&mut checksum_body)
|
||||
.await
|
||||
.expect("checksum GET should stream the committed bytes");
|
||||
assert_eq!(checksum_body, b"committed object", "{object}: checksum GET bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_past_dangling_versions_requires_every_drive_to_answer() {
|
||||
let bucket = "dangling-latest-offline";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
put(&set, bucket, object, b"version one", true).await;
|
||||
let v2 = put(&set, bucket, object, b"version two", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(&set, bucket, object, v2, 3..DRIVES).await;
|
||||
// The offline drive may hold the copy that makes version two readable.
|
||||
let offline = set.disks.write().await[5].take().expect("drive five should start online");
|
||||
|
||||
assert_get_and_head_keep_read_quorum_error(&set, bucket, object, "one drive offline").await;
|
||||
|
||||
set.disks.write().await[5] = Some(offline);
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("with every drive answering the dangling version is skipped");
|
||||
assert_ne!(latest.version_id, Some(v2));
|
||||
}
|
||||
|
||||
/// Version one on every drive under a version two that a failed PUT left
|
||||
/// on drives `0..3`, below the read quorum of four.
|
||||
async fn put_committed_below_dangling(set: &SetDisks, bucket: &str, object: &str) -> (Uuid, Uuid) {
|
||||
let v1 = put(set, bucket, object, b"version one", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
let v2 = put(set, bucket, object, b"version two", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(set, bucket, object, v2, 3..DRIVES).await;
|
||||
(v1, v2)
|
||||
}
|
||||
|
||||
/// Each drive's copy of `version_id`, or `None` where the drive lacks it.
|
||||
async fn version_metadata(
|
||||
set: &SetDisks,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Uuid,
|
||||
) -> Vec<Option<HashMap<String, String>>> {
|
||||
let disks = set.disks.read().await.clone();
|
||||
let mut copies = Vec::with_capacity(disks.len());
|
||||
for disk in &disks {
|
||||
let disk = disk.as_ref().expect("every drive should be online");
|
||||
copies.push(
|
||||
disk.read_version("", bucket, object, &version_id.to_string(), &ReadOptions::default())
|
||||
.await
|
||||
.ok()
|
||||
.map(|fi| fi.metadata),
|
||||
);
|
||||
}
|
||||
copies
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_tagging_skips_dangling_versions_above_a_committed_version() {
|
||||
let bucket = "dangling-latest-tagging";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
let (v1, v2) = put_committed_below_dangling(&set, bucket, object).await;
|
||||
let tag_of = |metadata: &Option<HashMap<String, String>>| {
|
||||
metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING).cloned())
|
||||
};
|
||||
|
||||
let tagged = set
|
||||
.put_object_tags(bucket, object, "stage=committed", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("PutObjectTagging must update the version a latest GET serves");
|
||||
assert_eq!(tagged.version_id, Some(v1));
|
||||
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
|
||||
assert_eq!(
|
||||
tag_of(metadata).as_deref(),
|
||||
Some("stage=committed"),
|
||||
"drive {disk} must carry the tag on version one"
|
||||
);
|
||||
}
|
||||
for (disk, metadata) in version_metadata(&set, bucket, object, v2).await.iter().enumerate() {
|
||||
assert_eq!(tag_of(metadata), None, "drive {disk} must leave the dangling version untouched");
|
||||
}
|
||||
assert_eq!(*head(&set, bucket, object, None).await.expect("latest HEAD").user_tags, "stage=committed");
|
||||
|
||||
let untagged = set
|
||||
.delete_object_tags(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("DeleteObjectTagging must update the version a latest GET serves");
|
||||
assert_eq!(untagged.version_id, Some(v1));
|
||||
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
|
||||
assert_eq!(tag_of(metadata).as_deref(), Some(""), "drive {disk} must clear the tag on version one");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_metadata_update_skips_dangling_versions_above_a_committed_version() {
|
||||
let bucket = "dangling-latest-metadata";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
let (v1, v2) = put_committed_below_dangling(&set, bucket, object).await;
|
||||
|
||||
let updated = set
|
||||
.put_object_metadata(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "updated".to_string())])),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a latest metadata update must target the version a latest GET serves");
|
||||
assert_eq!(updated.version_id, Some(v1));
|
||||
assert!(updated.is_latest, "the updated version is the latest one");
|
||||
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
|
||||
let metadata = metadata.as_ref().expect("version one is on every drive");
|
||||
assert_eq!(metadata.get("x-amz-meta-probe").map(String::as_str), Some("updated"), "drive {disk}");
|
||||
}
|
||||
for (disk, metadata) in version_metadata(&set, bucket, object, v2).await.iter().enumerate() {
|
||||
assert!(
|
||||
metadata
|
||||
.as_ref()
|
||||
.is_none_or(|metadata| !metadata.contains_key("x-amz-meta-probe")),
|
||||
"drive {disk} must leave the dangling version untouched"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_copy_source_skips_dangling_versions_above_a_committed_version() {
|
||||
let bucket = "dangling-latest-copy";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
|
||||
// A metadata-only self-copy rewrites the latest version in place.
|
||||
let object = "torn/in-place";
|
||||
let (v1, _) = put_committed_below_dangling(&set, bucket, object).await;
|
||||
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
|
||||
assert_eq!(source.version_id, Some(v1));
|
||||
source.metadata_only = true;
|
||||
source.user_defined = Arc::new(HashMap::from([("x-amz-meta-copied".to_string(), "in-place".to_string())]));
|
||||
let copied = set
|
||||
.copy_object(
|
||||
bucket,
|
||||
object,
|
||||
bucket,
|
||||
object,
|
||||
&mut source,
|
||||
&ObjectOptions::default(),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("a CopyObject source without a version id must resolve like a latest GET");
|
||||
assert_eq!(copied.version_id, Some(v1));
|
||||
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
|
||||
let metadata = metadata.as_ref().expect("version one is on every drive");
|
||||
assert_eq!(metadata.get("x-amz-meta-copied").map(String::as_str), Some("in-place"), "drive {disk}");
|
||||
}
|
||||
|
||||
// A version-only self-copy commits a new version that shares the
|
||||
// served version's data.
|
||||
let object = "torn/new-version";
|
||||
let (_, v2) = put_committed_below_dangling(&set, bucket, object).await;
|
||||
let v3 = Uuid::new_v4();
|
||||
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
|
||||
source.version_only = true;
|
||||
let copied = set
|
||||
.copy_object(
|
||||
bucket,
|
||||
object,
|
||||
bucket,
|
||||
object,
|
||||
&mut source,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
&ObjectOptions {
|
||||
version_id: Some(v3.to_string()),
|
||||
versioned: true,
|
||||
object_lock_config_snapshot: Some(Arc::new(crate::object_api::ObjectLockConfigSnapshot::new(
|
||||
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
|
||||
))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a version-only CopyObject source without a version id must resolve like a latest GET");
|
||||
assert_eq!(copied.version_id, Some(v3));
|
||||
let latest = head(&set, bucket, object, None).await.expect("latest HEAD");
|
||||
assert_eq!(latest.version_id, Some(v3));
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"version one");
|
||||
assert!(
|
||||
version_metadata(&set, bucket, object, v2).await[..3]
|
||||
.iter()
|
||||
.all(Option::is_some),
|
||||
"the copy must not remove the dangling version; heal owns its cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_metadata_update_past_dangling_versions_keeps_write_quorum() {
|
||||
// EC 3+3: read quorum three, write quorum four. Version one reached
|
||||
// three drives, so it is readable but below write quorum; versions two
|
||||
// and three are dangling on two drives each. A latest read serves
|
||||
// version one, and a metadata update of it is refused before writing.
|
||||
let bucket = "dangling-latest-write-quorum";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set_with(bucket, 6, 3).await;
|
||||
let mut versions = Vec::new();
|
||||
for body in [b"version zero".as_slice(), b"version one", b"version two", b"version three"] {
|
||||
versions.push(
|
||||
put(&set, bucket, object, body, true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id"),
|
||||
);
|
||||
}
|
||||
let [_, v1, v2, v3] = versions[..] else {
|
||||
unreachable!("four versions were written");
|
||||
};
|
||||
drop_version_from(&set, bucket, object, v1, 3..6).await;
|
||||
drop_version_from(&set, bucket, object, v2, 2..6).await;
|
||||
drop_version_from(&set, bucket, object, v3, 0..3).await;
|
||||
drop_version_from(&set, bucket, object, v3, 5..6).await;
|
||||
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("version one is readable at read quorum");
|
||||
assert_eq!(latest.version_id, Some(v1));
|
||||
|
||||
let is_write_quorum_error =
|
||||
|err: &StorageError| matches!(err, StorageError::ErasureWriteQuorum | StorageError::InsufficientWriteQuorum(_, _));
|
||||
let Err(err) = set
|
||||
.put_object_tags(bucket, object, "stage=refused", &ObjectOptions::default())
|
||||
.await
|
||||
else {
|
||||
panic!("tagging a version below write quorum must be refused");
|
||||
};
|
||||
assert!(is_write_quorum_error(&err), "tagging keeps the write-quorum error: {err:?}");
|
||||
let Err(err) = set
|
||||
.put_object_metadata(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "refused".to_string())])),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("a metadata update of a version below write quorum must be refused");
|
||||
};
|
||||
assert!(is_write_quorum_error(&err), "the metadata update keeps the write-quorum error: {err:?}");
|
||||
let v4 = Uuid::new_v4();
|
||||
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
|
||||
source.version_only = true;
|
||||
let Err(err) = set
|
||||
.copy_object(
|
||||
bucket,
|
||||
object,
|
||||
bucket,
|
||||
object,
|
||||
&mut source,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
&ObjectOptions {
|
||||
version_id: Some(v4.to_string()),
|
||||
versioned: true,
|
||||
object_lock_config_snapshot: Some(Arc::new(crate::object_api::ObjectLockConfigSnapshot::new(
|
||||
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
|
||||
))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("a version-only copy of a version below write quorum must be refused");
|
||||
};
|
||||
assert!(is_write_quorum_error(&err), "the version-only copy keeps the write-quorum error: {err:?}");
|
||||
assert!(
|
||||
version_metadata(&set, bucket, object, v4).await.iter().all(Option::is_none),
|
||||
"a refused version-only copy must not write the new version to any drive"
|
||||
);
|
||||
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
|
||||
assert!(
|
||||
metadata.as_ref().is_none_or(|metadata| {
|
||||
!metadata.contains_key(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING)
|
||||
&& !metadata.contains_key("x-amz-meta-probe")
|
||||
}),
|
||||
"drive {disk} must not be written by a refused update"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replication_status_writeback_never_walks_past_dangling_versions() {
|
||||
// A replication terminal CAS stays side-effect free: it keeps the
|
||||
// read-quorum error instead of resolving the latest version, so it
|
||||
// neither queues heal for the dangling version nor writes version one.
|
||||
let bucket = "dangling-latest-replication-cas";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
let (v1, _) = put_committed_below_dangling(&set, bucket, object).await;
|
||||
|
||||
let Err(err) = set
|
||||
.put_object_metadata(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "written".to_string())])),
|
||||
replication_status_writeback: Some(Box::new(crate::object_api::ReplicationStatusWritebackCondition {
|
||||
mode: crate::object_api::ReplicationStatusWritebackMode::ValidateOnly,
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("a replication terminal CAS must not resolve past a dangling version");
|
||||
};
|
||||
assert!(is_read_quorum_error(&err), "the CAS keeps the read-quorum error: {err:?}");
|
||||
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
|
||||
let metadata = metadata.as_ref().expect("version one is on every drive");
|
||||
assert!(!metadata.contains_key("x-amz-meta-probe"), "drive {disk} must not be written");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_metadata_updates_past_dangling_versions_require_every_drive_to_answer() {
|
||||
let bucket = "dangling-latest-update-offline";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
put_committed_below_dangling(&set, bucket, object).await;
|
||||
let offline = set.disks.write().await[5].take().expect("drive five should start online");
|
||||
|
||||
let Err(err) = set
|
||||
.put_object_tags(bucket, object, "stage=uncertain", &ObjectOptions::default())
|
||||
.await
|
||||
else {
|
||||
panic!("tagging must not resolve past a version an offline drive may commit");
|
||||
};
|
||||
assert!(is_read_quorum_error(&err), "tagging keeps the read-quorum error: {err:?}");
|
||||
let Err(err) = set
|
||||
.put_object_metadata(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "uncertain".to_string())])),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("a metadata update must not resolve past a version an offline drive may commit");
|
||||
};
|
||||
assert!(is_read_quorum_error(&err), "the metadata update keeps the read-quorum error: {err:?}");
|
||||
set.disks.write().await[5] = Some(offline);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -448,6 +448,14 @@ impl ECStore {
|
||||
await_orphan_bucket_recovery(bucket, &publication_guard, &lifecycle_guard, &metadata_guard, &ns_guard, recover).await
|
||||
}
|
||||
|
||||
/// Read quota and bucket identity from one authoritative metadata snapshot.
|
||||
pub async fn bucket_quota_and_incarnation_from_disk(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<(Option<crate::bucket::quota::BucketQuota>, Uuid, OffsetDateTime)> {
|
||||
metadata_sys::get_quota_config_and_incarnation_from_disk_in(&self.ctx, bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_bucket_metadata(&self, bucket: &str) -> Result<Arc<BucketMetadata>> {
|
||||
let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?;
|
||||
sys.read().await.get(bucket).await
|
||||
@@ -564,13 +572,83 @@ impl ECStore {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn acquire_bucket_incarnation_fence_with_options(
|
||||
pub(crate) async fn acquire_bucket_incarnation_fence_with_options(
|
||||
&self,
|
||||
bucket: &str,
|
||||
expected: Uuid,
|
||||
opts: Option<&ObjectOptions>,
|
||||
) -> Result<super::BucketIncarnationFenceGuard> {
|
||||
Ok(self
|
||||
.acquire_bucket_incarnation_fence_for_options(bucket, Some(expected), opts)
|
||||
.await?
|
||||
.0)
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_bucket_incarnation_fence_for_options(
|
||||
&self,
|
||||
bucket: &str,
|
||||
expected: Option<Uuid>,
|
||||
opts: Option<&ObjectOptions>,
|
||||
) -> Result<(super::BucketIncarnationFenceGuard, Uuid)> {
|
||||
let inner = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||
self.validate_bucket_incarnation_fence_guard_with_options(bucket, inner, expected, opts)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Acquire lexicographically ordered bucket lifecycle read locks for a copy
|
||||
/// operation and validate both bucket incarnations through [`super::BucketFenceRegistry`].
|
||||
pub async fn acquire_copy_bucket_incarnation_fences(
|
||||
&self,
|
||||
source_bucket: &str,
|
||||
expected_source: Option<Uuid>,
|
||||
destination_bucket: &str,
|
||||
expected_destination: Option<Uuid>,
|
||||
) -> Result<(super::BucketIncarnationFenceGuard, Uuid, Option<super::BucketIncarnationFenceGuard>, Uuid)> {
|
||||
let (source_guard, destination_guard) = if source_bucket == destination_bucket {
|
||||
(self.acquire_bucket_lifecycle_read_lock(source_bucket).await?, None)
|
||||
} else if source_bucket < destination_bucket {
|
||||
let source_guard = self.acquire_bucket_lifecycle_read_lock(source_bucket).await?;
|
||||
let destination_guard = self.acquire_bucket_lifecycle_read_lock(destination_bucket).await?;
|
||||
(source_guard, Some(destination_guard))
|
||||
} else {
|
||||
let destination_guard = self.acquire_bucket_lifecycle_read_lock(destination_bucket).await?;
|
||||
let source_guard = self.acquire_bucket_lifecycle_read_lock(source_bucket).await?;
|
||||
(source_guard, Some(destination_guard))
|
||||
};
|
||||
let (source_guard, current_source_incarnation_id) = self
|
||||
.validate_bucket_incarnation_fence_guard_with_options(source_bucket, source_guard, expected_source, None)
|
||||
.await?;
|
||||
let (destination_guard, current_destination_incarnation_id) = if let Some(destination_guard) = destination_guard {
|
||||
let (destination_guard, destination_incarnation_id) = self
|
||||
.validate_bucket_incarnation_fence_guard_with_options(
|
||||
destination_bucket,
|
||||
destination_guard,
|
||||
expected_destination,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
(Some(destination_guard), destination_incarnation_id)
|
||||
} else {
|
||||
if expected_destination.is_some_and(|expected| expected != current_source_incarnation_id) {
|
||||
return Err(StorageError::BucketNotFound(destination_bucket.to_owned()));
|
||||
}
|
||||
(None, current_source_incarnation_id)
|
||||
};
|
||||
Ok((
|
||||
source_guard,
|
||||
current_source_incarnation_id,
|
||||
destination_guard,
|
||||
current_destination_incarnation_id,
|
||||
))
|
||||
}
|
||||
|
||||
async fn validate_bucket_incarnation_fence_guard_with_options(
|
||||
&self,
|
||||
bucket: &str,
|
||||
inner: rustfs_lock::NamespaceLockGuard,
|
||||
expected: Option<Uuid>,
|
||||
opts: Option<&ObjectOptions>,
|
||||
) -> Result<(super::BucketIncarnationFenceGuard, Uuid)> {
|
||||
let mut guard = super::BucketIncarnationFenceGuard::new(
|
||||
Arc::clone(&self.bucket_fence_registry),
|
||||
bucket,
|
||||
@@ -586,10 +664,10 @@ impl ECStore {
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if current != expected {
|
||||
if expected.is_some_and(|expected| current != expected) {
|
||||
return Err(StorageError::BucketNotFound(bucket.to_owned()));
|
||||
}
|
||||
Ok(guard)
|
||||
Ok((guard, current))
|
||||
}
|
||||
|
||||
/// Hold this guard through recursive-delete authorization and mutation so
|
||||
|
||||
@@ -149,6 +149,18 @@ impl BucketFenceRegistry {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the memoized incarnation id for `bucket` when at least one
|
||||
/// registered lifecycle read guard is currently held without lock loss.
|
||||
pub(crate) fn validated_incarnation(&self, bucket: &str) -> Option<Uuid> {
|
||||
let mut entries = self.entries.lock().expect("bucket fence registry poisoned");
|
||||
let entry = entries.get_mut(bucket)?;
|
||||
if entry.guards.iter().any(|guard| guard.loss_probe.is_lost()) {
|
||||
entry.validated = None;
|
||||
return None;
|
||||
}
|
||||
entry.validated
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns continuous lifecycle coverage and its incarnation validation memo.
|
||||
@@ -221,7 +233,7 @@ impl BucketIncarnationFenceGuard {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
pub fn is_lock_lost(&self) -> bool {
|
||||
self.inner.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
|
||||
}
|
||||
|
||||
@@ -310,9 +322,16 @@ mod tests {
|
||||
let (first_probe, first_lost) = controllable_probe();
|
||||
let first = reg.enter("b", first_probe);
|
||||
assert_eq!(first.memoized, None);
|
||||
assert_eq!(reg.validated_incarnation("b"), None);
|
||||
reg.memoize("b", uuid(7));
|
||||
assert_eq!(reg.validated_incarnation("b"), Some(uuid(7)));
|
||||
|
||||
first_lost.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
reg.validated_incarnation("b"),
|
||||
None,
|
||||
"live lost guard must clear and block validated_incarnation"
|
||||
);
|
||||
|
||||
let second = reg.enter("b", live_probe());
|
||||
assert_eq!(second.memoized, None, "live lost guard must force disk revalidation");
|
||||
@@ -320,9 +339,11 @@ mod tests {
|
||||
|
||||
let third = reg.enter("b", live_probe());
|
||||
assert_eq!(third.memoized, None, "memo remains blocked while the lost guard is live");
|
||||
assert_eq!(reg.validated_incarnation("b"), None);
|
||||
|
||||
reg.exit("b", first.token, true);
|
||||
reg.memoize("b", uuid(8));
|
||||
assert_eq!(reg.validated_incarnation("b"), Some(uuid(8)));
|
||||
let fourth = reg.enter("b", live_probe());
|
||||
assert_eq!(fourth.memoized, Some(uuid(8)), "memo resumes after lost coverage leaves");
|
||||
|
||||
|
||||
@@ -21384,6 +21384,141 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn put_object_metadata_and_copy_object_reuse_overlapping_incarnation_fence() {
|
||||
let temp = tempfile::tempdir().expect("create metadata/copy fence store");
|
||||
let (_ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp.path(), "metadata-copy-fence-reuse", &[4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("metadata-copy-fence-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let mut seed_reader = PutObjReader::from_vec(b"seed payload".to_vec());
|
||||
let versioned_opts = ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut src_info = store
|
||||
.put_object(&bucket, "src.bin", &mut seed_reader, &versioned_opts)
|
||||
.await
|
||||
.expect("seed source object");
|
||||
let src_version_id = src_info
|
||||
.version_id
|
||||
.expect("versioned PUT should return a version ID")
|
||||
.to_string();
|
||||
let expected = store.bucket_incarnation_id(&bucket).await.expect("read incarnation");
|
||||
let coverage = store
|
||||
.acquire_bucket_incarnation_fence(&bucket, expected)
|
||||
.await
|
||||
.expect("validate continuous coverage");
|
||||
let metadata_writer = crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_lock_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("hold metadata writer during covered metadata and copy operations");
|
||||
|
||||
let updated = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
store.put_object_metadata(
|
||||
&bucket,
|
||||
"src.bin",
|
||||
&ObjectOptions {
|
||||
version_id: Some(src_version_id.clone()),
|
||||
versioned: true,
|
||||
eval_metadata: Some(HashMap::from([("x-amz-meta-fenced".to_string(), "true".to_string())])),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("covered put_object_metadata must not reacquire the metadata transaction lock")
|
||||
.expect("update object metadata using authoritative memo");
|
||||
assert_eq!(updated.user_defined.get("x-amz-meta-fenced").map(String::as_str), Some("true"));
|
||||
|
||||
Arc::make_mut(&mut src_info.user_defined).insert("x-amz-meta-copied".to_string(), "true".to_string());
|
||||
let src_copy_opts = ObjectOptions {
|
||||
version_id: Some(src_version_id),
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
};
|
||||
let copied = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
store.copy_object(&bucket, "src.bin", &bucket, "src.bin", &mut src_info, &src_copy_opts, &versioned_opts),
|
||||
)
|
||||
.await
|
||||
.expect("covered versioned copy_object must not reacquire the metadata transaction lock")
|
||||
.expect("versioned copy using authoritative memo");
|
||||
assert_eq!(copied.user_defined.get("x-amz-meta-copied").map(String::as_str), Some("true"));
|
||||
|
||||
let (src_guard, src_incarnation, dst_guard, dst_incarnation) = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
store.acquire_copy_bucket_incarnation_fences(&bucket, Some(expected), &bucket, Some(expected)),
|
||||
)
|
||||
.await
|
||||
.expect("covered acquire_copy_bucket_incarnation_fences must not reacquire the metadata transaction lock")
|
||||
.expect("validate same-bucket copy fences using authoritative memo");
|
||||
assert!(dst_guard.is_none());
|
||||
assert_eq!(src_incarnation, expected);
|
||||
assert_eq!(dst_incarnation, expected);
|
||||
let mut borrowed_fence_opts = ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(expected),
|
||||
..Default::default()
|
||||
};
|
||||
src_guard.attach_to_object_options(&mut borrowed_fence_opts);
|
||||
let fenced_incarnation = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
crate::bucket::metadata_sys::get_bucket_incarnation_id_for_options_in(&store.ctx, &bucket, &borrowed_fence_opts),
|
||||
)
|
||||
.await
|
||||
.expect("borrowed fence under active coverage must not reacquire the metadata transaction lock")
|
||||
.expect("resolve incarnation under borrowed fence");
|
||||
assert_eq!(fenced_incarnation, expected);
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.acquire_copy_bucket_incarnation_fences(&bucket, Some(expected), &bucket, Some(Uuid::new_v4()))
|
||||
.await,
|
||||
Err(StorageError::BucketNotFound(_))
|
||||
),
|
||||
"same-bucket copy fence must reject a mismatched destination incarnation"
|
||||
);
|
||||
drop(src_guard);
|
||||
|
||||
let stale_opts = ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(Uuid::new_v4()),
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
matches!(
|
||||
store.put_object_metadata(&bucket, "src.bin", &stale_opts).await,
|
||||
Err(StorageError::BucketNotFound(_))
|
||||
),
|
||||
"put_object_metadata must reject a stale expected incarnation even under warm coverage"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.copy_object(
|
||||
&bucket,
|
||||
"src.bin",
|
||||
&bucket,
|
||||
"src.bin",
|
||||
&mut src_info,
|
||||
&ObjectOptions::default(),
|
||||
&stale_opts,
|
||||
)
|
||||
.await,
|
||||
Err(StorageError::BucketNotFound(_))
|
||||
),
|
||||
"copy_object must reject a stale expected incarnation even under warm coverage"
|
||||
);
|
||||
drop(metadata_writer);
|
||||
drop(coverage);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn multipart_snapshot_bypasses_initializer_queued_behind_metadata_writer() {
|
||||
@@ -21469,10 +21604,25 @@ mod tests {
|
||||
.acquire_bucket_incarnation_fence(&bucket, old)
|
||||
.await
|
||||
.expect("validate with no-op lock");
|
||||
let snapshot = store
|
||||
.object_lock_config_snapshot_under_lifecycle_fence(&bucket, &crate::object_api::NamespaceLockFence::new())
|
||||
.await
|
||||
.expect("capture snapshot under no-op lock");
|
||||
let current = Uuid::new_v4();
|
||||
crate::bucket::metadata::save_bucket_incarnation(store.clone(), &bucket, current)
|
||||
.await
|
||||
.expect("replace authority while no-op guard provides no exclusion");
|
||||
let snapshot_opts = ObjectOptions {
|
||||
expected_bucket_incarnation_id: Some(old),
|
||||
object_lock_config_snapshot: Some(snapshot),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
crate::bucket::metadata_sys::get_bucket_incarnation_id_for_options_in(&store.ctx, &bucket, &snapshot_opts)
|
||||
.await
|
||||
.expect("disabled lock manager must re-read incarnation from disk instead of trusting snapshot"),
|
||||
current
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
store.acquire_bucket_incarnation_fence(&bucket, old).await,
|
||||
|
||||
@@ -1030,16 +1030,19 @@ impl ECStore {
|
||||
// Lock order matches writes: bucket lifecycle, metadata validation,
|
||||
// then object namespace. Borrow fences supplied by an outer operation.
|
||||
let bucket_guard = if opts.bucket_lifecycle_lock_fence.is_none() {
|
||||
let guard = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||
opts.add_bucket_lifecycle_lock_guard(&guard);
|
||||
let guard = self
|
||||
.acquire_bucket_incarnation_fence_with_options(bucket, bucket_incarnation_id, Some(&opts))
|
||||
.await?;
|
||||
guard.attach_to_object_options(&mut opts);
|
||||
Some(guard)
|
||||
} else {
|
||||
let incarnation =
|
||||
crate::bucket::metadata_sys::get_bucket_incarnation_id_for_options_in(&self.ctx, bucket, &opts).await?;
|
||||
if incarnation != bucket_incarnation_id {
|
||||
return Err(Error::BucketNotFound(bucket.to_owned()));
|
||||
}
|
||||
None
|
||||
};
|
||||
let incarnation = crate::bucket::metadata_sys::get_bucket_incarnation_id_for_options_in(&self.ctx, bucket, &opts).await?;
|
||||
if incarnation != bucket_incarnation_id {
|
||||
return Err(Error::BucketNotFound(bucket.to_owned()));
|
||||
}
|
||||
let metadata_sys = crate::bucket::metadata_sys::bucket_metadata_sys_of(&self.ctx)?;
|
||||
let metadata_sys = metadata_sys.read().await.clone();
|
||||
let (versioning, _) = metadata_sys.get_versioning_config(bucket).await?;
|
||||
|
||||
@@ -452,6 +452,8 @@ pub use object::{
|
||||
};
|
||||
mod peer;
|
||||
mod rebalance;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rebalance::PoolSpaceForTest;
|
||||
pub(crate) mod utils;
|
||||
|
||||
use peer::init_local_peer;
|
||||
|
||||
@@ -412,7 +412,7 @@ impl ECStore {
|
||||
|
||||
/// Multipart lock order is bucket lifecycle, generation validation, then
|
||||
/// object/upload locks in the selected set.
|
||||
async fn guard_multipart_bucket_incarnation(
|
||||
pub(super) async fn guard_multipart_bucket_incarnation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
opts: &ObjectOptions,
|
||||
|
||||
@@ -30,8 +30,8 @@ use crate::bucket::lifecycle::{
|
||||
},
|
||||
};
|
||||
use crate::bucket::metadata_sys::{
|
||||
acquire_bucket_metadata_transaction_read_lock_in, get_bucket_incarnation_id_for_options_in,
|
||||
get_cached_bucket_incarnation_id_in, get_delete_config_from_disk_in, get_object_lock_config_and_incarnation_from_disk_in,
|
||||
acquire_bucket_metadata_transaction_read_lock_in, get_cached_bucket_incarnation_id_in, get_delete_config_from_disk_in,
|
||||
get_object_lock_config_and_incarnation_from_disk_in,
|
||||
};
|
||||
use crate::bucket::object_lock::objectlock_sys::{
|
||||
check_object_lock_for_deletion_with_state, ensure_recursive_force_delete_allowed_for_state,
|
||||
@@ -4291,8 +4291,40 @@ impl ECStore {
|
||||
.select_decommission_capacity_target_pool(owner, expected_data_bytes)
|
||||
.await;
|
||||
}
|
||||
let holder = self
|
||||
.data_movement_pool_holding_version(bucket, object, opts.version_id.as_deref(), opts.src_pool_idx)
|
||||
.await;
|
||||
self.get_available_pool_idx_preferring(bucket, object, size, None, holder)
|
||||
.await
|
||||
.ok_or(Error::DiskFull)
|
||||
}
|
||||
|
||||
self.get_available_pool_idx(bucket, object, size).await.ok_or(Error::DiskFull)
|
||||
/// The target pool that already holds a copy of the exact version being
|
||||
/// moved. An attempt that lost write quorum can leave that copy on fewer
|
||||
/// drives than read quorum, which the pool lookup reports as absent; a
|
||||
/// retry that placed the version on another pool would leave it in two.
|
||||
/// Callers still subject the holder to the free-space checks, so a full
|
||||
/// holder cannot pin every retry of a deferred entry.
|
||||
async fn data_movement_pool_holding_version(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<&str>,
|
||||
src_pool_idx: usize,
|
||||
) -> Option<usize> {
|
||||
let version_id = Uuid::parse_str(version_id?).ok()?;
|
||||
let holders = join_all(self.pools.iter().enumerate().map(|(pool_idx, pool)| async move {
|
||||
// Match the pools the free-space choice can return.
|
||||
if pool_idx == src_pool_idx || self.is_suspended(pool_idx).await || self.is_pool_rebalancing(pool_idx).await {
|
||||
return None;
|
||||
}
|
||||
pool.get_disks_by_key(object)
|
||||
.holds_version_on_any_drive(bucket, object, version_id)
|
||||
.await
|
||||
.then_some(pool_idx)
|
||||
}))
|
||||
.await;
|
||||
holders.into_iter().flatten().next()
|
||||
}
|
||||
|
||||
async fn find_data_movement_target_info(
|
||||
@@ -4851,24 +4883,20 @@ impl ECStore {
|
||||
if !is_meta_bucketname(dst_bucket) && dst_opts.expected_bucket_incarnation_id.is_none() {
|
||||
dst_opts.expected_bucket_incarnation_id = Some(self.bucket_incarnation_id(dst_bucket).await?);
|
||||
}
|
||||
let _bucket_lifecycle_guard = if is_meta_bucketname(dst_bucket) || dst_opts.bucket_lifecycle_lock_fence.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(dst_bucket).await?)
|
||||
};
|
||||
let current_bucket_incarnation_id = if let Some(guard) = _bucket_lifecycle_guard.as_ref() {
|
||||
dst_opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
let current_incarnation_id = get_bucket_incarnation_id_for_options_in(&self.ctx, dst_bucket, &dst_opts).await?;
|
||||
if dst_opts
|
||||
.expected_bucket_incarnation_id
|
||||
.is_some_and(|expected| expected != current_incarnation_id)
|
||||
{
|
||||
return Err(StorageError::BucketNotFound(dst_bucket.to_string()));
|
||||
}
|
||||
Some(current_incarnation_id)
|
||||
} else {
|
||||
dst_opts.expected_bucket_incarnation_id
|
||||
};
|
||||
let (_bucket_lifecycle_guard, current_bucket_incarnation_id) =
|
||||
if is_meta_bucketname(dst_bucket) || dst_opts.bucket_lifecycle_lock_fence.is_some() {
|
||||
(None, dst_opts.expected_bucket_incarnation_id)
|
||||
} else {
|
||||
let (guard, current_incarnation_id) = self
|
||||
.acquire_bucket_incarnation_fence_for_options(
|
||||
dst_bucket,
|
||||
dst_opts.expected_bucket_incarnation_id,
|
||||
Some(&dst_opts),
|
||||
)
|
||||
.await?;
|
||||
guard.attach_to_object_options(&mut dst_opts);
|
||||
(Some(guard), Some(current_incarnation_id))
|
||||
};
|
||||
if dst_opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
@@ -5420,7 +5448,10 @@ impl ECStore {
|
||||
if let Some(owner) = DecommissionCapacityOwner::from_options(&opts) {
|
||||
self.select_decommission_capacity_target_pool(owner, 0).await?
|
||||
} else {
|
||||
self.get_available_pool_idx_excluding(bucket, object, 0, opts.src_pool_idx)
|
||||
let holder = self
|
||||
.data_movement_pool_holding_version(bucket, object, opts.version_id.as_deref(), opts.src_pool_idx)
|
||||
.await;
|
||||
self.get_available_pool_idx_preferring(bucket, object, 0, Some(opts.src_pool_idx), holder)
|
||||
.await
|
||||
.ok_or(Error::DiskFull)?
|
||||
}
|
||||
@@ -6469,24 +6500,8 @@ impl ECStore {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
let object = encode_dir_object(object);
|
||||
let mut opts = transition_restore_pool_opts(opts);
|
||||
if !is_meta_bucketname(bucket) && opts.expected_bucket_incarnation_id.is_none() {
|
||||
opts.expected_bucket_incarnation_id = Some(self.bucket_incarnation_id(bucket).await?);
|
||||
}
|
||||
let bucket_lifecycle_guard = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(bucket).await?)
|
||||
};
|
||||
if let Some(guard) = bucket_lifecycle_guard.as_ref() {
|
||||
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
}
|
||||
if !is_meta_bucketname(bucket) {
|
||||
let current_incarnation_id = get_bucket_incarnation_id_for_options_in(&self.ctx, bucket, &opts).await?;
|
||||
if opts.expected_bucket_incarnation_id != Some(current_incarnation_id) {
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
}
|
||||
let opts = transition_restore_pool_opts(opts);
|
||||
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, &opts).await?;
|
||||
if opts.overwrites_existing_version() && !is_meta_bucketname(bucket) {
|
||||
let expected_incarnation_id = opts
|
||||
.expected_bucket_incarnation_id
|
||||
@@ -6554,16 +6569,11 @@ impl ECStore {
|
||||
let bucket_lifecycle_guard = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
let guard = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||
let current_incarnation_id = get_bucket_incarnation_id_for_options_in(&self.ctx, bucket, &opts).await?;
|
||||
if opts
|
||||
.expected_bucket_incarnation_id
|
||||
.is_some_and(|expected| expected != current_incarnation_id)
|
||||
{
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
let (guard, current_incarnation_id) = self
|
||||
.acquire_bucket_incarnation_fence_for_options(bucket, opts.expected_bucket_incarnation_id, Some(&opts))
|
||||
.await?;
|
||||
opts.expected_bucket_incarnation_id = Some(current_incarnation_id);
|
||||
opts.add_bucket_lifecycle_lock_guard(&guard);
|
||||
guard.attach_to_object_options(&mut opts);
|
||||
if guard.is_lock_lost() {
|
||||
return Err(Error::other("bucket lifecycle lock was lost before the metadata update"));
|
||||
}
|
||||
@@ -13132,6 +13142,202 @@ mod tests {
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn assert_delete_excludes_recovery_across_pool_lock_domains(topology: &str, batch: bool) {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (_first_dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
let (_second_dirs, second_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
let mut store = new_prepared_reader_test_store_with_ctx(&[first_set, second_set], Arc::clone(&ctx)).await;
|
||||
let clients: Vec<Arc<dyn rustfs_lock::LockClient>> = (0..4)
|
||||
.map(|_| {
|
||||
Arc::new(rustfs_lock::LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::Enabled(
|
||||
Arc::new(rustfs_lock::FastObjectLockManager::new()),
|
||||
)))) as Arc<dyn rustfs_lock::LockClient>
|
||||
})
|
||||
.collect();
|
||||
let domains = match topology {
|
||||
"disjoint" => [vec![clients[0].clone()], vec![clients[1].clone()]],
|
||||
"identical" => [clients[..3].to_vec(), clients[..3].to_vec()],
|
||||
"overlapping" => [clients[..3].to_vec(), clients[1..].to_vec()],
|
||||
_ => panic!("unknown lock topology"),
|
||||
};
|
||||
for (pool, lockers) in store.pools.iter_mut().zip(domains) {
|
||||
let mut set = (*pool.disk_set[0]).clone();
|
||||
set.lockers = lockers;
|
||||
Arc::make_mut(pool).disk_set[0] = Arc::new(set);
|
||||
}
|
||||
let store = Arc::new(store);
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "delete-nonzero-pool-lock";
|
||||
let object = "object.bin";
|
||||
store
|
||||
.handle_make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create the bucket in both pools");
|
||||
if batch {
|
||||
// Latest-marker routing sends this batch only to the nonzero pool,
|
||||
// so the cleanup barrier observes its prepared journal directly.
|
||||
store
|
||||
.update_bucket_metadata_config(
|
||||
bucket,
|
||||
crate::bucket::metadata::BUCKET_VERSIONING_CONFIG,
|
||||
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
|
||||
)
|
||||
.await
|
||||
.expect("enable versioning for the routed batch");
|
||||
}
|
||||
let target = store.pools[1].get_disks_by_key(object);
|
||||
let source = target
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"delete lock regression".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: batch,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed an object only in the nonzero pool");
|
||||
ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
|
||||
let object_barrier = (!batch).then(|| crate::set_disk::DeleteObjectCommitBarrier::install(bucket, object));
|
||||
let batch_barrier = batch.then(|| crate::set_disk::DeleteCleanupBarrier::install(bucket));
|
||||
let delete_store = Arc::clone(&store);
|
||||
let delete = tokio::spawn(async move {
|
||||
if batch {
|
||||
let (_, errors) = delete_store
|
||||
.delete_objects(
|
||||
bucket,
|
||||
vec![ObjectToDelete {
|
||||
object_name: object.to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions::default(),
|
||||
)
|
||||
.await;
|
||||
match errors.into_iter().flatten().next() {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
} else {
|
||||
delete_store
|
||||
.delete_object(bucket, object, ObjectOptions::default())
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
});
|
||||
if let Some(barrier) = &object_barrier {
|
||||
barrier.wait_until_paused().await;
|
||||
}
|
||||
if let Some(barrier) = &batch_barrier {
|
||||
barrier.wait_until_paused().await;
|
||||
}
|
||||
let recovery_store = Arc::clone(&store);
|
||||
let mut recovery = tokio::spawn(async move {
|
||||
recovery_store
|
||||
.recover_delete_transactions_at_incarnation(
|
||||
bucket,
|
||||
Some(object),
|
||||
None,
|
||||
&HealOpts {
|
||||
pool: Some(1),
|
||||
set: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
let early_recovery = tokio::time::timeout(Duration::from_millis(100), &mut recovery).await;
|
||||
let recovery_was_blocked = early_recovery.is_err();
|
||||
if let Some(barrier) = &object_barrier {
|
||||
barrier.release();
|
||||
}
|
||||
if let Some(barrier) = &batch_barrier {
|
||||
barrier.release();
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), delete)
|
||||
.await
|
||||
.expect("the delete should finish after releasing its commit barrier")
|
||||
.expect("the delete task should join")
|
||||
.expect("the delete should succeed");
|
||||
let recovered = match early_recovery {
|
||||
Ok(result) => result,
|
||||
Err(_) => tokio::time::timeout(Duration::from_secs(10), recovery)
|
||||
.await
|
||||
.expect("recovery should finish after the frontend delete"),
|
||||
};
|
||||
recovered
|
||||
.expect("the recovery task should join")
|
||||
.expect("recovery should succeed");
|
||||
assert!(
|
||||
recovery_was_blocked,
|
||||
"recovery must not inspect an in-flight frontend delete with {topology} pool lock clients"
|
||||
);
|
||||
if batch {
|
||||
let marker = target
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the versioned batch must publish a current delete marker");
|
||||
assert!(marker.delete_marker && marker.is_latest);
|
||||
assert_eq!(marker.num_versions, 2, "the source version must remain beside the new marker");
|
||||
assert_ne!(marker.version_id, source.version_id);
|
||||
let mut reader = target
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(source.version_id.expect("versioned source identity").to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the original source version must remain readable");
|
||||
let mut body = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.expect("read the retained source version");
|
||||
assert_eq!(body, b"delete lock regression");
|
||||
} else {
|
||||
let error = target
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("the deleted object must be absent");
|
||||
assert!(is_err_object_not_found(&error));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn batch_delete_excludes_recovery_on_nonzero_pool() {
|
||||
assert_delete_excludes_recovery_across_pool_lock_domains("disjoint", true).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn delete_excludes_recovery_with_disjoint_pool_lock_clients() {
|
||||
assert_delete_excludes_recovery_across_pool_lock_domains("disjoint", false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn delete_excludes_recovery_with_identical_pool_lock_clients() {
|
||||
assert_delete_excludes_recovery_across_pool_lock_domains("identical", false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn delete_excludes_recovery_with_overlapping_pool_lock_clients() {
|
||||
assert_delete_excludes_recovery_across_pool_lock_domains("overlapping", false).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -122,6 +122,51 @@ async fn pause_prepared_pool_read_before_refetch(object: &str) {
|
||||
state.refetch_release.notified().await;
|
||||
}
|
||||
}
|
||||
/// Capacity that every online drive of one pool reports to free-space target
|
||||
/// selection, per store, until the guard drops.
|
||||
#[cfg(test)]
|
||||
static POOL_SPACE_FOR_TEST: std::sync::Mutex<Vec<(Uuid, usize, crate::disk::DiskInfo)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct PoolSpaceForTest(Uuid);
|
||||
|
||||
#[cfg(test)]
|
||||
impl PoolSpaceForTest {
|
||||
pub(crate) fn install(store: Uuid, pools: &[(usize, crate::disk::DiskInfo)]) -> Self {
|
||||
POOL_SPACE_FOR_TEST
|
||||
.lock()
|
||||
.expect("pool space override must not be poisoned")
|
||||
.extend(pools.iter().map(|(pool, info)| (store, *pool, info.clone())));
|
||||
Self(store)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for PoolSpaceForTest {
|
||||
fn drop(&mut self) {
|
||||
POOL_SPACE_FOR_TEST
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.retain(|(store, _, _)| *store != self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn pool_space_for_test(
|
||||
store: Uuid,
|
||||
pool: usize,
|
||||
infos: Vec<Option<crate::disk::DiskInfo>>,
|
||||
) -> Vec<Option<crate::disk::DiskInfo>> {
|
||||
let overrides = POOL_SPACE_FOR_TEST.lock().expect("pool space override must not be poisoned");
|
||||
let Some((_, _, info)) = overrides
|
||||
.iter()
|
||||
.find(|(override_store, override_pool, _)| *override_store == store && *override_pool == pool)
|
||||
else {
|
||||
return infos;
|
||||
};
|
||||
infos.into_iter().map(|disk| disk.map(|_| info.clone())).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
use support::resolve_latest_object_info_candidates;
|
||||
use support::{
|
||||
@@ -438,30 +483,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
pub(super) async fn get_available_pool_idx(&self, bucket: &str, object: &str, size: i64) -> Option<usize> {
|
||||
// // Return a random one first
|
||||
|
||||
let mut server_pools = self.get_server_pools_available_space(bucket, object, size).await;
|
||||
server_pools.filter_max_used(100 - (100_f64 * DISK_RESERVE_FRACTION) as u64);
|
||||
let total = server_pools.total_available();
|
||||
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut rng = rand::rng();
|
||||
let random_u64: u64 = rng.random_range(0..total);
|
||||
|
||||
let choose = random_u64 % total;
|
||||
let mut at_total = 0;
|
||||
|
||||
for pool in server_pools.iter() {
|
||||
at_total += pool.available;
|
||||
if at_total > choose && pool.available > 0 {
|
||||
return Some(pool.index);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
self.get_available_pool_idx_preferring(bucket, object, size, None, None).await
|
||||
}
|
||||
|
||||
pub(super) async fn get_available_pool_idx_excluding(
|
||||
@@ -470,13 +492,33 @@ impl ECStore {
|
||||
object: &str,
|
||||
size: i64,
|
||||
excluded_pool_idx: usize,
|
||||
) -> Option<usize> {
|
||||
self.get_available_pool_idx_preferring(bucket, object, size, Some(excluded_pool_idx), None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Free-space choice among the pools that can take `size` more bytes,
|
||||
/// weighted by available space. `preferred_pool_idx` is returned whenever
|
||||
/// it passes the same checks, so a full or excluded pool is never forced.
|
||||
pub(super) async fn get_available_pool_idx_preferring(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
size: i64,
|
||||
excluded_pool_idx: Option<usize>,
|
||||
preferred_pool_idx: Option<usize>,
|
||||
) -> Option<usize> {
|
||||
let mut server_pools = self.get_server_pools_available_space(bucket, object, size).await;
|
||||
server_pools.filter_max_used(100 - (100_f64 * DISK_RESERVE_FRACTION) as u64);
|
||||
|
||||
if let Some(pool) = server_pools.0.get_mut(excluded_pool_idx) {
|
||||
if let Some(pool) = excluded_pool_idx.and_then(|idx| server_pools.0.get_mut(idx)) {
|
||||
pool.available = 0;
|
||||
}
|
||||
if let Some(pool_idx) = preferred_pool_idx
|
||||
&& server_pools.0.get(pool_idx).is_some_and(|pool| pool.available > 0)
|
||||
{
|
||||
return Some(pool_idx);
|
||||
}
|
||||
|
||||
let total = server_pools.total_available();
|
||||
if total == 0 {
|
||||
@@ -509,6 +551,8 @@ impl ECStore {
|
||||
|
||||
let disks = pool.get_disks_by_key(object).disk_inventory().await;
|
||||
let disk_infos = get_disk_infos(&disks).await;
|
||||
#[cfg(test)]
|
||||
let disk_infos = pool_space_for_test(self.id, idx, disk_infos);
|
||||
|
||||
(idx, pool.set_count, disk_infos)
|
||||
}))
|
||||
|
||||
@@ -16,7 +16,7 @@ use rustfs_io_metrics::internode_metrics::INTERNODE_OPERATION_PUT_FILE_STREAM;
|
||||
use rustfs_rio::{InternodeHttpError, InternodeHttpErrorKind};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::heal::{DiskError, EcstoreError};
|
||||
use super::heal::{DiskError, EcstoreError, storage_api::owner::EcstorePoolMetadataFailure};
|
||||
|
||||
const HEAL_DANGLING_DELETE_GRACE_MESSAGE: &str = "dangling object deletion deferred by heal grace window";
|
||||
pub(crate) const MRF_MISSING_PROOF_PREFIX: &str = "Missing exact storage proof for durable MRF repair ";
|
||||
@@ -236,6 +236,14 @@ pub(crate) fn classify_heal_error(error: &Error) -> HealErrorClass {
|
||||
/// with object absence, the conflation that once let an offline drive be
|
||||
/// recorded as absent and skipped (backlog#856 / #799 B7).
|
||||
pub(crate) fn classify_storage_error(error: &EcstoreError) -> HealErrorClass {
|
||||
if let Some(failure) = error.pool_metadata_failure() {
|
||||
return match failure.kind {
|
||||
EcstorePoolMetadataFailure::ReadUnavailable | EcstorePoolMetadataFailure::TransactionUnknown => {
|
||||
HealErrorClass::Transient
|
||||
}
|
||||
EcstorePoolMetadataFailure::RecoveryRequired | EcstorePoolMetadataFailure::FenceLost => HealErrorClass::Permanent,
|
||||
};
|
||||
}
|
||||
if let EcstoreError::Lock(lock_error) = error {
|
||||
return if lock_error.is_fatal() {
|
||||
HealErrorClass::Permanent
|
||||
@@ -435,6 +443,34 @@ mod tests {
|
||||
use super::Error;
|
||||
use crate::heal::{DiskError, EcstoreError};
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_failures_keep_their_typed_recovery_classification() {
|
||||
use crate::heal::storage_api::owner::{EcstorePoolMetadataError, EcstorePoolMetadataFailure};
|
||||
|
||||
for (kind, retryable) in [
|
||||
(EcstorePoolMetadataFailure::ReadUnavailable, true),
|
||||
(EcstorePoolMetadataFailure::TransactionUnknown, true),
|
||||
(EcstorePoolMetadataFailure::RecoveryRequired, false),
|
||||
(EcstorePoolMetadataFailure::FenceLost, false),
|
||||
] {
|
||||
let error = EcstoreError::other(EcstorePoolMetadataError {
|
||||
kind,
|
||||
operation: "bucket heal".to_owned(),
|
||||
phase: "native_heal",
|
||||
since: std::time::UNIX_EPOCH.into(),
|
||||
source: Some(std::sync::Arc::new(EcstoreError::SlowDown)),
|
||||
});
|
||||
let text = error.to_string();
|
||||
for error in [error.clone(), error] {
|
||||
assert_eq!(Error::Storage(error).is_recoverable_heal(), retryable, "{kind:?}");
|
||||
}
|
||||
assert!(
|
||||
!Error::Storage(EcstoreError::other(text)).is_recoverable_heal(),
|
||||
"display text must not grant recovery authority"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_lock_quorum_is_recoverable_across_io_error_conversions() {
|
||||
for (required, achieved) in [(2, 1), (1, 0)] {
|
||||
|
||||
@@ -26,7 +26,11 @@ pub(crate) use rustfs_ecstore::api::disk::{
|
||||
RUSTFS_META_BUCKET as ECSTORE_RUSTFS_META_BUCKET, ReplacementExecutionLease as EcstoreReplacementExecutionLease,
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskOption as EcstoreDiskOption, new_disk as ecstore_new_disk};
|
||||
pub(crate) use rustfs_ecstore::api::error::{Error as EcstoreErrorType, StorageError as EcstoreStorageError};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::error::PoolMetadataError as EcstorePoolMetadataError;
|
||||
pub(crate) use rustfs_ecstore::api::error::{
|
||||
Error as EcstoreErrorType, PoolMetadataFailure as EcstorePoolMetadataFailure, StorageError as EcstoreStorageError,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::layout::{
|
||||
EndpointServerPools as EcstoreEndpointServerPools, Endpoints as EcstoreEndpoints, PoolEndpoints as EcstorePoolEndpoints,
|
||||
@@ -53,6 +57,9 @@ pub(crate) use rustfs_ecstore::api::storage::{
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
|
||||
pub(crate) mod owner {
|
||||
#[cfg(test)]
|
||||
pub(crate) use super::EcstorePoolMetadataError;
|
||||
pub(crate) use super::EcstorePoolMetadataFailure;
|
||||
pub(crate) use super::storage_contracts::{ObjectIO, ObjectOperations};
|
||||
|
||||
pub(crate) use super::{
|
||||
|
||||
@@ -306,9 +306,7 @@ impl HealTask {
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.update_stage(3, 3);
|
||||
}
|
||||
Err(Error::TaskExecutionFailed {
|
||||
message: format!("Failed to heal bucket {bucket}: {e}"),
|
||||
})
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,9 +518,7 @@ impl HealTask {
|
||||
error = %err,
|
||||
"Heal cluster pool metadata failed"
|
||||
);
|
||||
Err(Error::TaskExecutionFailed {
|
||||
message: format!("Failed to heal cluster pool metadata: {err}"),
|
||||
})
|
||||
Err(err)
|
||||
}
|
||||
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
|
||||
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
|
||||
@@ -539,9 +535,7 @@ impl HealTask {
|
||||
error = %err,
|
||||
"Heal cluster pool metadata failed"
|
||||
);
|
||||
Err(Error::TaskExecutionFailed {
|
||||
message: format!("Failed to heal cluster pool metadata: {err}"),
|
||||
})
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
|
||||
use super::*;
|
||||
use crate::heal::EcstoreError;
|
||||
use crate::heal::storage::HealStorageObjectResult;
|
||||
use crate::heal::storage_api::owner::{EcstorePoolMetadataError, EcstorePoolMetadataFailure};
|
||||
|
||||
mod absent_verdict;
|
||||
mod concurrent_delete;
|
||||
@@ -784,7 +785,10 @@ mod canonical_outcome {
|
||||
async fn untraversable_bucket_does_not_claim_complete_cluster_coverage() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
listed_buckets: Mutex::new(Some(vec!["bucket-a".to_string(), "bucket-b".to_string()])),
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_string(), VecDeque::from(["metadata unavailable"]))])),
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([(
|
||||
"bucket-a".to_string(),
|
||||
VecDeque::from([Error::other("metadata unavailable")]),
|
||||
)])),
|
||||
..Default::default()
|
||||
});
|
||||
let task = HealTask::from_request(
|
||||
@@ -1566,7 +1570,7 @@ struct MockStorage {
|
||||
include_object_dir_candidate: Mutex<bool>,
|
||||
listed_buckets: Mutex<Option<Vec<String>>>,
|
||||
bucket_discovery_incomplete: bool,
|
||||
bucket_heal_errors: Mutex<HashMap<String, VecDeque<&'static str>>>,
|
||||
bucket_heal_errors: Mutex<HashMap<String, VecDeque<Error>>>,
|
||||
bucket_heal_calls: Mutex<Vec<String>>,
|
||||
delete_recovery_results: Mutex<HashMap<String, Result<Option<HealResultItem>>>>,
|
||||
delete_recovery_calls: Mutex<Vec<String>>,
|
||||
@@ -1990,6 +1994,7 @@ fn replacement_identity(
|
||||
|
||||
#[derive(Clone)]
|
||||
enum MockHealObjectOutcome {
|
||||
PoolMetadata { kind: EcstorePoolMetadataFailure, outer: bool },
|
||||
LegacyAmbiguous { outer: bool },
|
||||
MissingObject { outer: bool },
|
||||
MissingVersion,
|
||||
@@ -2009,6 +2014,16 @@ enum MockHealObjectOutcome {
|
||||
PermanentOther(&'static str),
|
||||
}
|
||||
|
||||
fn pool_metadata_error(kind: EcstorePoolMetadataFailure) -> Error {
|
||||
Error::Storage(EcstoreError::other(EcstorePoolMetadataError {
|
||||
kind,
|
||||
operation: "bucket heal".to_owned(),
|
||||
phase: "native_heal",
|
||||
since: std::time::UNIX_EPOCH.into(),
|
||||
source: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn unavailable_drive_heal_result(state: DriveState) -> (HealResultItem, Option<Error>) {
|
||||
(
|
||||
HealResultItem {
|
||||
@@ -2180,6 +2195,14 @@ impl HealStorageAPI for MockStorage {
|
||||
.and_then(VecDeque::pop_front)
|
||||
{
|
||||
return match outcome {
|
||||
MockHealObjectOutcome::PoolMetadata { kind, outer } => {
|
||||
let error = pool_metadata_error(kind);
|
||||
if outer {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok((HealResultItem::default(), Some(error)))
|
||||
}
|
||||
}
|
||||
MockHealObjectOutcome::LegacyAmbiguous { outer } => {
|
||||
let error = Error::Storage(EcstoreError::DeleteRecoveryLegacyAmbiguous);
|
||||
if outer {
|
||||
@@ -2256,6 +2279,14 @@ impl HealStorageAPI for MockStorage {
|
||||
}
|
||||
if let Some(outcome) = self.heal_object_outcome.lock().unwrap().take() {
|
||||
return match outcome {
|
||||
MockHealObjectOutcome::PoolMetadata { kind, outer } => {
|
||||
let error = pool_metadata_error(kind);
|
||||
if outer {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok((HealResultItem::default(), Some(error)))
|
||||
}
|
||||
}
|
||||
MockHealObjectOutcome::LegacyAmbiguous { outer } => {
|
||||
let error = Error::Storage(EcstoreError::DeleteRecoveryLegacyAmbiguous);
|
||||
if outer {
|
||||
@@ -2387,14 +2418,14 @@ impl HealStorageAPI for MockStorage {
|
||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
self.bucket_heal_calls.lock().unwrap().push(bucket.to_string());
|
||||
self.bucket_heal_opts.lock().unwrap().push(*opts);
|
||||
if let Some(message) = self
|
||||
if let Some(error) = self
|
||||
.bucket_heal_errors
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(bucket)
|
||||
.and_then(VecDeque::pop_front)
|
||||
{
|
||||
return Err(Error::other(message));
|
||||
return Err(error);
|
||||
}
|
||||
Ok(HealResultItem::default())
|
||||
}
|
||||
@@ -3429,13 +3460,70 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
||||
.await
|
||||
.expect_err("cluster recreate heal must not hide pool metadata failure");
|
||||
|
||||
assert!(matches!(err, Error::TaskExecutionFailed { .. }));
|
||||
assert!(matches!(err, Error::Other(ref message) if message == "pool metadata missing"));
|
||||
assert_eq!(
|
||||
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
||||
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_pool_metadata_failures_preserve_recovery_classification_in_both_response_shapes() {
|
||||
for outer in [false, true] {
|
||||
for (kind, retryable) in [
|
||||
(EcstorePoolMetadataFailure::ReadUnavailable, true),
|
||||
(EcstorePoolMetadataFailure::TransactionUnknown, true),
|
||||
(EcstorePoolMetadataFailure::RecoveryRequired, false),
|
||||
(EcstorePoolMetadataFailure::FenceLost, false),
|
||||
] {
|
||||
let storage = Arc::new(MockStorage {
|
||||
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||
..Default::default()
|
||||
});
|
||||
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
|
||||
POOL_META_NAME.to_string(),
|
||||
VecDeque::from([MockHealObjectOutcome::PoolMetadata { kind, outer }]),
|
||||
);
|
||||
let mut request = HealRequest::new(
|
||||
HealType::Cluster,
|
||||
HealOptions {
|
||||
recreate_missing: true,
|
||||
timeout: None,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
request.source = HealRequestSource::Admin;
|
||||
let task = HealTask::from_request(request, storage.clone());
|
||||
let error = task
|
||||
.execute()
|
||||
.await
|
||||
.expect_err("pool metadata failure must reach the scheduler");
|
||||
assert!(
|
||||
matches!(&error, Error::Storage(error) if error.pool_metadata_failure().is_some_and(|failure| failure.kind == kind)),
|
||||
"the original typed pool metadata state must survive either response shape: {error}"
|
||||
);
|
||||
assert_eq!(error.is_recoverable_heal(), retryable, "{kind:?}, outer={outer}");
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("object calls").as_slice(), [POOL_META_NAME]);
|
||||
if retryable {
|
||||
let retry = task
|
||||
.retry_request_with_remaining_timeout()
|
||||
.await
|
||||
.expect("existing retry request");
|
||||
assert_eq!(retry.id, task.id, "recovery retains the accepted task token");
|
||||
HealTask::from_request(retry, storage.clone())
|
||||
.execute()
|
||||
.await
|
||||
.expect("the same task should complete after pool metadata recovery");
|
||||
assert_eq!(
|
||||
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
||||
[POOL_META_NAME, POOL_META_NAME]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_dry_run_does_not_heal_pool_metadata() {
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
@@ -3799,7 +3887,10 @@ async fn test_recursive_bucket_heal_reports_typed_exhausted_and_permanent_failur
|
||||
async fn test_cluster_heal_continues_after_bucket_failure() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
listed_buckets: Mutex::new(Some(vec!["bucket-a".to_string(), "bucket-b".to_string()])),
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_string(), VecDeque::from(["metadata unavailable"]))])),
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([(
|
||||
"bucket-a".to_string(),
|
||||
VecDeque::from([Error::other("metadata unavailable")]),
|
||||
)])),
|
||||
..Default::default()
|
||||
});
|
||||
let request = HealRequest::new(
|
||||
@@ -3831,7 +3922,10 @@ async fn test_cluster_heal_continues_after_bucket_failure() {
|
||||
async fn test_cluster_heal_retries_only_recoverable_bucket() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
listed_buckets: Mutex::new(Some(vec!["bucket-a".to_string(), "bucket-b".to_string()])),
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_string(), VecDeque::from(["lock acquisition timeout"]))])),
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([(
|
||||
"bucket-a".to_string(),
|
||||
VecDeque::from([Error::other("lock acquisition timeout")]),
|
||||
)])),
|
||||
..Default::default()
|
||||
});
|
||||
let request = HealRequest::new(
|
||||
@@ -3855,6 +3949,47 @@ async fn test_cluster_heal_retries_only_recoverable_bucket() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cluster_bucket_heal_retries_only_recoverable_pool_metadata_failures() {
|
||||
for (kind, retryable) in [
|
||||
(EcstorePoolMetadataFailure::ReadUnavailable, true),
|
||||
(EcstorePoolMetadataFailure::TransactionUnknown, true),
|
||||
(EcstorePoolMetadataFailure::RecoveryRequired, false),
|
||||
(EcstorePoolMetadataFailure::FenceLost, false),
|
||||
] {
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_id: Mutex::new(Some(Uuid::new_v4())),
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([("bucket-a".to_owned(), VecDeque::from([pool_metadata_error(kind)]))])),
|
||||
..Default::default()
|
||||
});
|
||||
let mut request = HealRequest::new(
|
||||
HealType::Cluster,
|
||||
HealOptions {
|
||||
recursive: true,
|
||||
timeout: None,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
request.source = HealRequestSource::Admin;
|
||||
let task = HealTask::from_request(request, storage.clone());
|
||||
let result = task.execute().await;
|
||||
if retryable {
|
||||
result.expect("a recoverable pool metadata state must not terminate the root heal");
|
||||
assert_eq!(task.get_status().await, HealTaskStatus::Completed);
|
||||
assert_eq!(
|
||||
storage.bucket_heal_calls.lock().expect("bucket calls").as_slice(),
|
||||
["bucket-a", "bucket-a"]
|
||||
);
|
||||
} else {
|
||||
result.expect_err("operator recovery and lost fences must remain terminal");
|
||||
let failure = task.take_batch_failure().await.expect("permanent cluster failure");
|
||||
assert_eq!((failure.retryable, failure.permanent), (0, 1));
|
||||
assert_eq!(storage.bucket_heal_calls.lock().expect("bucket calls").as_slice(), ["bucket-a"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recursive_bucket_heal_does_not_remove_bucket_metadata() {
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
@@ -4617,7 +4752,7 @@ async fn erasure_set_bucket_prepass_failure_stops_before_object_heal() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_heal_errors: Mutex::new(HashMap::from([(
|
||||
"bucket-a".to_string(),
|
||||
VecDeque::from(["injected bucket prepass failure"]),
|
||||
VecDeque::from([Error::other("injected bucket prepass failure")]),
|
||||
)])),
|
||||
resume_disk: Mutex::new(Some(disk)),
|
||||
..Default::default()
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Durable restrictions owned by an authenticated user. A record is not an IAM
|
||||
//! grant or credential; issuing credentials still requires current IAM checks.
|
||||
use crate::{error::is_err_config_not_found, store::Store};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PREFIX: &str = "config/iam/plugin-delegations/v1";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Restrictions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present_value")]
|
||||
pub version: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present_value")]
|
||||
pub output_bucket: Option<String>,
|
||||
pub deployment: String,
|
||||
pub bucket: String,
|
||||
pub input_prefix: String,
|
||||
pub output_prefix: String,
|
||||
pub expires_at_unix_seconds: u64,
|
||||
}
|
||||
|
||||
// An absent v1 field is compatible; an explicitly null v2 field is invalid.
|
||||
fn present_value<'de, T: Deserialize<'de>, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<T>, D::Error> {
|
||||
T::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
impl Restrictions {
|
||||
pub fn output_bucket(&self) -> Result<&str, DelegationError> {
|
||||
match (self.version, self.output_bucket.as_deref()) {
|
||||
(None, None) => Ok(&self.bucket),
|
||||
(Some(2), Some(output)) => Ok(output),
|
||||
_ => Err(DelegationError::Invalid),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self, now: u64) -> Result<(), DelegationError> {
|
||||
let output_bucket = self.output_bucket()?;
|
||||
let names: [&str; 5] = [
|
||||
&self.deployment,
|
||||
&self.bucket,
|
||||
&self.input_prefix,
|
||||
&self.output_prefix,
|
||||
output_bucket,
|
||||
];
|
||||
if names
|
||||
.iter()
|
||||
.any(|s| s.is_empty() || s.len() > 1024 || s.contains("${") || s.chars().any(char::is_control))
|
||||
|| self.bucket.contains('/')
|
||||
|| self.bucket.contains('*')
|
||||
|| self.bucket.contains('?')
|
||||
|| output_bucket.contains('/')
|
||||
|| output_bucket.contains('*')
|
||||
|| output_bucket.contains('?')
|
||||
|| self.input_prefix.contains('*')
|
||||
|| self.input_prefix.contains('?')
|
||||
|| self.output_prefix.contains('*')
|
||||
|| self.output_prefix.contains('?')
|
||||
|| self.expires_at_unix_seconds <= now
|
||||
|| self.expires_at_unix_seconds.saturating_sub(now) > 86400
|
||||
{
|
||||
return Err(DelegationError::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Delegation {
|
||||
pub version: u32,
|
||||
pub id: String,
|
||||
pub revision: String,
|
||||
pub owner_access_key: String,
|
||||
pub created_at_unix_seconds: u64,
|
||||
pub restrictions: Restrictions,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Tombstone {
|
||||
version: u32,
|
||||
id: String,
|
||||
revision: String,
|
||||
owner_access_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DelegationError {
|
||||
#[error("invalid delegation restriction")]
|
||||
Invalid,
|
||||
#[error("delegation not found")]
|
||||
NotFound,
|
||||
#[error("delegation owner rejected")]
|
||||
OwnerRejected,
|
||||
#[error("delegation unavailable")]
|
||||
Unavailable,
|
||||
#[error("delegation revoked")]
|
||||
Revoked,
|
||||
#[error("delegation expired")]
|
||||
Expired,
|
||||
}
|
||||
|
||||
fn record_path(id: &str) -> Result<String, DelegationError> {
|
||||
if id.len() != 32 || !id.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(DelegationError::Invalid);
|
||||
}
|
||||
Ok(format!("{PREFIX}/{id}/record.json"))
|
||||
}
|
||||
|
||||
async fn load<T: Store>(store: &T, id: &str) -> Result<Delegation, DelegationError> {
|
||||
let record: Delegation = store.load_iam_config_strict(record_path(id)?).await.map_err(|error| {
|
||||
if is_err_config_not_found(&error) {
|
||||
DelegationError::NotFound
|
||||
} else {
|
||||
DelegationError::Unavailable
|
||||
}
|
||||
})?;
|
||||
if !matches!(record.version, 1 | 2)
|
||||
|| record.version != record.restrictions.version.unwrap_or(1)
|
||||
|| record.id != id
|
||||
|| record.revision != id
|
||||
|| record.owner_access_key.is_empty()
|
||||
{
|
||||
return Err(DelegationError::Unavailable);
|
||||
}
|
||||
record
|
||||
.restrictions
|
||||
.validate(record.created_at_unix_seconds)
|
||||
.map_err(|_| DelegationError::Unavailable)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub(crate) async fn create<T: Store>(
|
||||
store: &T,
|
||||
owner: &str,
|
||||
restrictions: Restrictions,
|
||||
now: u64,
|
||||
) -> Result<Delegation, DelegationError> {
|
||||
restrictions.validate(now)?;
|
||||
if owner.is_empty() {
|
||||
return Err(DelegationError::OwnerRejected);
|
||||
}
|
||||
// IDs are generated exclusively here; no update operation accepts an ID.
|
||||
let id = format!("{:032x}", rand::random::<u128>());
|
||||
let path = record_path(&id)?;
|
||||
match store.load_iam_config_strict::<Delegation>(&path).await {
|
||||
Err(error) if is_err_config_not_found(&error) => {}
|
||||
_ => return Err(DelegationError::Unavailable),
|
||||
}
|
||||
let record = Delegation {
|
||||
version: restrictions.version.unwrap_or(1),
|
||||
revision: id.clone(),
|
||||
id,
|
||||
owner_access_key: owner.to_owned(),
|
||||
created_at_unix_seconds: now,
|
||||
restrictions,
|
||||
};
|
||||
store
|
||||
.save_iam_config(&record, path)
|
||||
.await
|
||||
.map_err(|_| DelegationError::Unavailable)?;
|
||||
if load(store, &record.id).await? != record {
|
||||
return Err(DelegationError::Unavailable);
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub(crate) async fn revoke<T: Store>(store: &T, owner: &str, id: &str) -> Result<(), DelegationError> {
|
||||
let record = load(store, id).await?;
|
||||
if record.owner_access_key != owner {
|
||||
return Err(DelegationError::OwnerRejected);
|
||||
}
|
||||
let tombstone = Tombstone {
|
||||
version: 1,
|
||||
id: record.id,
|
||||
revision: record.revision,
|
||||
owner_access_key: record.owner_access_key,
|
||||
};
|
||||
let path = format!("{PREFIX}/{id}/revoked.json");
|
||||
// Every retry writes exactly the same revocation; active records never change.
|
||||
store
|
||||
.save_iam_config(&tombstone, &path)
|
||||
.await
|
||||
.map_err(|_| DelegationError::Unavailable)?;
|
||||
let saved: Tombstone = store
|
||||
.load_iam_config_strict(path)
|
||||
.await
|
||||
.map_err(|_| DelegationError::Unavailable)?;
|
||||
if saved != tombstone {
|
||||
return Err(DelegationError::Unavailable);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn read_active<T: Store>(store: &T, owner: &str, id: &str, now: u64) -> Result<Delegation, DelegationError> {
|
||||
let record = load(store, id).await?;
|
||||
if record.owner_access_key != owner {
|
||||
return Err(DelegationError::OwnerRejected);
|
||||
}
|
||||
check_active(store, record, now).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_owned<T: Store>(store: &T, owner: &str, id: &str) -> Result<Delegation, DelegationError> {
|
||||
let record = load(store, id).await?;
|
||||
if record.owner_access_key != owner {
|
||||
return Err(DelegationError::OwnerRejected);
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_active_for_broker<T: Store>(store: &T, id: &str, now: u64) -> Result<Delegation, DelegationError> {
|
||||
let record = load(store, id).await?;
|
||||
check_active(store, record, now).await
|
||||
}
|
||||
async fn check_active<T: Store>(store: &T, record: Delegation, now: u64) -> Result<Delegation, DelegationError> {
|
||||
let id = &record.id;
|
||||
match store
|
||||
.load_iam_config_strict::<Tombstone>(format!("{PREFIX}/{id}/revoked.json"))
|
||||
.await
|
||||
{
|
||||
Ok(_) => return Err(DelegationError::Revoked),
|
||||
Err(error) if is_err_config_not_found(&error) => {}
|
||||
Err(_) => return Err(DelegationError::Unavailable),
|
||||
}
|
||||
if now >= record.restrictions.expires_at_unix_seconds {
|
||||
return Err(DelegationError::Expired);
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn delegation_v1_json_remains_compatible() {
|
||||
let legacy = serde_json::json!({"deployment":"runner-a","bucket":"photos",
|
||||
"input_prefix":"inputs/","output_prefix":"outputs/","expires_at_unix_seconds":200});
|
||||
let restriction: Restrictions = serde_json::from_value(legacy.clone()).expect("legacy restriction");
|
||||
restriction.validate(100).expect("legacy validation");
|
||||
assert_eq!(restriction.output_bucket().expect("v1 output"), "photos");
|
||||
assert_eq!(serde_json::to_value(restriction).expect("legacy serialization"), legacy);
|
||||
let record = serde_json::json!({"version":1,"id":"a".repeat(32),"revision":"a".repeat(32),
|
||||
"owner_access_key":"owner","created_at_unix_seconds":100,"restrictions":legacy});
|
||||
let decoded: Delegation = serde_json::from_value(record.clone()).expect("legacy record");
|
||||
assert_eq!(serde_json::to_value(decoded).expect("legacy record serialization"), record);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegation_v2_requires_explicit_output_bucket() {
|
||||
let valid = serde_json::json!({"version":2,"deployment":"runner-a","bucket":"photos",
|
||||
"input_prefix":"inputs/","output_bucket":"artifacts","output_prefix":"outputs/",
|
||||
"expires_at_unix_seconds":200});
|
||||
let restriction: Restrictions = serde_json::from_value(valid.clone()).expect("v2 restriction");
|
||||
restriction.validate(100).expect("explicit output bucket");
|
||||
assert_eq!(restriction.output_bucket().expect("v2 output"), "artifacts");
|
||||
assert_eq!(serde_json::to_value(restriction).expect("v2 serialization"), valid);
|
||||
for key in ["version", "output_bucket"] {
|
||||
let mut bad = valid.clone();
|
||||
bad.as_object_mut().expect("object").remove(key);
|
||||
assert!(
|
||||
serde_json::from_value::<Restrictions>(bad).map_or(true, |r| r.validate(100).is_err()),
|
||||
"v2 required {key}"
|
||||
);
|
||||
}
|
||||
for (key, value) in [
|
||||
("version", serde_json::json!(1)),
|
||||
("version", serde_json::json!(3)),
|
||||
("version", serde_json::Value::Null),
|
||||
("output_bucket", serde_json::Value::Null),
|
||||
("output_bucket", serde_json::json!("")),
|
||||
("output_bucket", serde_json::json!("other/*")),
|
||||
("output_bucket", serde_json::json!("${aws:username}")),
|
||||
("output_bucket", serde_json::json!("other?")),
|
||||
("output_bucket", serde_json::json!("other\n")),
|
||||
("unknown", serde_json::json!(true)),
|
||||
] {
|
||||
let mut bad = valid.clone();
|
||||
bad[key] = value;
|
||||
assert!(
|
||||
serde_json::from_value::<Restrictions>(bad).map_or(true, |r| r.validate(100).is_err()),
|
||||
"invalid v2 {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restrictions_are_literal_bounded_and_expiring() {
|
||||
let mut restriction = Restrictions {
|
||||
version: None,
|
||||
output_bucket: None,
|
||||
deployment: "runner-a".into(),
|
||||
bucket: "photos".into(),
|
||||
input_prefix: "inputs/".into(),
|
||||
output_prefix: "outputs/".into(),
|
||||
expires_at_unix_seconds: 200,
|
||||
};
|
||||
assert!(restriction.validate(100).is_ok());
|
||||
restriction.output_prefix = "outputs/*".into();
|
||||
assert!(matches!(restriction.validate(100), Err(DelegationError::Invalid)));
|
||||
restriction.output_prefix = "outputs/".into();
|
||||
assert!(matches!(restriction.validate(200), Err(DelegationError::Invalid)));
|
||||
restriction.expires_at_unix_seconds = 200;
|
||||
restriction.input_prefix = "inputs/${aws:username}/".into();
|
||||
assert!(
|
||||
matches!(restriction.validate(100), Err(DelegationError::Invalid)),
|
||||
"C07_LITERAL_SCOPE_BOUND: policy variables are not literal restrictions"
|
||||
);
|
||||
assert!(record_path("../../users").is_err());
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub(crate) const EVENT_OIDC_DIAGNOSTICS: &str = "oidc_diagnostics";
|
||||
const EVENT_OIDC_STATE: &str = "oidc_state";
|
||||
|
||||
pub mod cache;
|
||||
pub mod delegation;
|
||||
pub mod error;
|
||||
pub mod federation;
|
||||
pub mod keyring;
|
||||
|
||||
@@ -91,6 +91,7 @@ pub(crate) mod crate_boundary {
|
||||
|
||||
pub(crate) mod object_store {
|
||||
pub(crate) use super::storage_contracts::{HTTPPreconditions, ListOperations, ObjectInfoOrErr, ObjectOperations};
|
||||
pub(crate) use rustfs_ecstore::api::config::com::read_config_preserve_empty_with_metadata_opts;
|
||||
}
|
||||
|
||||
pub(crate) mod runtime {
|
||||
|
||||
@@ -26,6 +26,11 @@ pub trait Store: Clone + Send + Sync + 'static {
|
||||
fn has_watcher(&self) -> bool;
|
||||
async fn save_iam_config<Item: Serialize + Send>(&self, item: Item, path: impl AsRef<str> + Send) -> Result<()>;
|
||||
async fn load_iam_config<Item: DeserializeOwned>(&self, path: impl AsRef<str> + Send) -> Result<Item>;
|
||||
/// Only confirmed storage absence may return ConfigNotFound. Corrupt or
|
||||
/// undecryptable objects must remain errors, especially for revocations.
|
||||
async fn load_iam_config_strict<Item: DeserializeOwned>(&self, _path: impl AsRef<str> + Send) -> Result<Item> {
|
||||
Err(crate::error::Error::IamSysNotInitialized)
|
||||
}
|
||||
async fn delete_iam_config(&self, path: impl AsRef<str> + Send) -> Result<()>;
|
||||
|
||||
async fn save_user_identity(&self, name: &str, user_type: UserType, item: UserIdentity, ttl: Option<usize>) -> Result<()>;
|
||||
|
||||
@@ -42,6 +42,7 @@ use tracing::{debug, error, warn};
|
||||
|
||||
use crate::storage_api::object_store::{
|
||||
HTTPPreconditions, ListOperations, ObjectInfoOrErr as StorageObjectInfoOrErr, ObjectOperations,
|
||||
read_config_preserve_empty_with_metadata_opts,
|
||||
};
|
||||
|
||||
pub static IAM_CONFIG_PREFIX: LazyLock<String> = LazyLock::new(|| format!("{IAM_CONFIG_ROOT_PREFIX}/iam"));
|
||||
@@ -611,6 +612,11 @@ impl ObjectStore {
|
||||
Ok(serde_json::from_slice(&outcome.plain)?)
|
||||
}
|
||||
|
||||
fn decode_iam_config_strict<Item: DeserializeOwned>(data: &[u8]) -> Result<Item> {
|
||||
let outcome = Self::decrypt_data_with_source(data)?;
|
||||
Ok(serde_json::from_slice(&outcome.plain)?)
|
||||
}
|
||||
|
||||
/// Parameterized core of [`Store::load_user_identity`].
|
||||
async fn load_user_identity_with(&self, name: &str, user_type: UserType, mode: LoadMode) -> Result<UserIdentity> {
|
||||
let mut u: UserIdentity = self
|
||||
@@ -824,6 +830,12 @@ impl Store for ObjectStore {
|
||||
async fn load_iam_config<Item: DeserializeOwned>(&self, path: impl AsRef<str> + Send) -> Result<Item> {
|
||||
self.load_iam_config_with(path, LoadMode::Locked).await
|
||||
}
|
||||
async fn load_iam_config_strict<Item: DeserializeOwned>(&self, path: impl AsRef<str> + Send) -> Result<Item> {
|
||||
let (data, _) =
|
||||
read_config_preserve_empty_with_metadata_opts(self.object_api.clone(), path.as_ref(), &LoadMode::Locked.read_opts())
|
||||
.await?;
|
||||
Self::decode_iam_config_strict(&data)
|
||||
}
|
||||
/// Saves IAM configuration with a retry mechanism on failure.
|
||||
///
|
||||
/// Attempts to save the IAM configuration up to 5 times if the storage layer is not ready,
|
||||
@@ -1458,6 +1470,40 @@ mod tests {
|
||||
assert!(result.is_err(), "corrupt stream_io data should fail decrypt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_revocation_load_keeps_real_crypto_failure_distinct_from_absence() {
|
||||
let cred = test_cred();
|
||||
let key = format!("{}:{}", cred.access_key, cred.secret_key);
|
||||
let plain = br#"{"version":1,"id":"revoked"}"#;
|
||||
let mut encrypted = rustfs_crypto::encrypt_stream_io(key.as_bytes(), plain).expect("real stored ciphertext");
|
||||
// Exercise the exact decoder used after the actual object read.
|
||||
assert!(ObjectStore::decode_iam_config_strict::<serde_json::Value>(&encrypted).is_ok());
|
||||
encrypted[50] ^= 0xFF;
|
||||
let error = ObjectStore::decode_iam_config_strict::<serde_json::Value>(&encrypted)
|
||||
.expect_err("corrupt tombstone must not become missing");
|
||||
assert!(
|
||||
matches!(error, Error::CryptoError(_)),
|
||||
"C07_REVOCATION_FAIL_CLOSED: preserve actual crypto error"
|
||||
);
|
||||
assert!(
|
||||
!crate::error::is_err_config_not_found(&error),
|
||||
"C07_REVOCATION_FAIL_CLOSED: corrupted revocation is not absence"
|
||||
);
|
||||
// The preserve-empty reader keeps an existing zero-byte object as
|
||||
// Ok(empty); ecstore's read_config_preserve_empty_distinguishes_empty_object
|
||||
// covers that storage boundary. Its payload must fail here, not become
|
||||
// ConfigNotFound and reactivate a revoked delegation.
|
||||
let empty =
|
||||
ObjectStore::decode_iam_config_strict::<serde_json::Value>(&[]).expect_err("an existing empty tombstone is corrupt");
|
||||
assert!(
|
||||
!crate::error::is_err_config_not_found(&empty),
|
||||
"C07_REVOCATION_FAIL_CLOSED: empty existing revocation is not absence"
|
||||
);
|
||||
let invalid_json =
|
||||
ObjectStore::decode_iam_config_strict::<serde_json::Value>(br#"{"version": }"#).expect_err("invalid stored JSON");
|
||||
assert!(!crate::error::is_err_config_not_found(&invalid_json));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_data_short_data_fails() {
|
||||
let short = &[0x00u8; 40]; // less than 41-byte stream_io header, not valid JSON
|
||||
|
||||
+257
-7
@@ -75,6 +75,7 @@ pub const STATUS_ENABLED: &str = "enabled";
|
||||
pub const STATUS_DISABLED: &str = "disabled";
|
||||
|
||||
pub const POLICYNAME: &str = "policy";
|
||||
pub const PLUGIN_ATTEMPT_CREDENTIAL_CLAIM: &str = "rustfs:plugin-attempt-v1";
|
||||
pub const SESSION_POLICY_NAME: &str = "sessionPolicy";
|
||||
pub const SESSION_POLICY_NAME_EXTRACTED: &str = "sessionPolicy-extracted";
|
||||
const VERIFIED_FEDERATED_POLICY_CLAIM: &str = "x-rustfs-internal-federated-policy";
|
||||
@@ -168,6 +169,12 @@ enum PreparedServicePolicyMode {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum PreparedIamMode {
|
||||
TerminalDeny,
|
||||
PluginAttemptSts {
|
||||
is_owner: bool,
|
||||
combined_policy: Policy,
|
||||
session_policy: PreparedSessionPolicy,
|
||||
},
|
||||
Opa,
|
||||
Owner,
|
||||
Deny,
|
||||
@@ -202,7 +209,7 @@ impl PreparedIamAuth {
|
||||
pub async fn needs_existing_object_tag_for_args(&self, args: &Args<'_>) -> bool {
|
||||
match &self.mode {
|
||||
PreparedIamMode::Opa => true,
|
||||
PreparedIamMode::Owner | PreparedIamMode::Deny => false,
|
||||
PreparedIamMode::Owner | PreparedIamMode::Deny | PreparedIamMode::TerminalDeny => false,
|
||||
PreparedIamMode::Regular { combined_policy } => {
|
||||
policy_needs_existing_object_tag_for_args(combined_policy, args).await
|
||||
}
|
||||
@@ -210,6 +217,11 @@ impl PreparedIamAuth {
|
||||
combined_policy,
|
||||
session_policy,
|
||||
..
|
||||
}
|
||||
| PreparedIamMode::PluginAttemptSts {
|
||||
combined_policy,
|
||||
session_policy,
|
||||
..
|
||||
} => {
|
||||
policy_needs_existing_object_tag_for_args(combined_policy, args).await
|
||||
|| prepared_session_policy_needs_existing_object_tag_for_args(session_policy, args).await
|
||||
@@ -239,14 +251,56 @@ impl PreparedIamAuth {
|
||||
pub fn combined_policy_for_view(&self) -> Option<&Policy> {
|
||||
match &self.mode {
|
||||
PreparedIamMode::Regular { combined_policy } => Some(combined_policy),
|
||||
PreparedIamMode::Sts { combined_policy, .. } => Some(combined_policy),
|
||||
PreparedIamMode::Sts { combined_policy, .. } | PreparedIamMode::PluginAttemptSts { combined_policy, .. } => {
|
||||
Some(combined_policy)
|
||||
}
|
||||
PreparedIamMode::ServiceAccount { combined_policy, .. } => Some(combined_policy),
|
||||
PreparedIamMode::Opa | PreparedIamMode::Owner | PreparedIamMode::Deny => None,
|
||||
PreparedIamMode::Opa | PreparedIamMode::Owner | PreparedIamMode::Deny | PreparedIamMode::TerminalDeny => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Store> IamSys<T> {
|
||||
/// For the authenticated, explicitly configured broker handler only.
|
||||
pub async fn read_active_plugin_delegation_for_broker(
|
||||
&self,
|
||||
id: &str,
|
||||
now: u64,
|
||||
) -> std::result::Result<crate::delegation::Delegation, crate::delegation::DelegationError> {
|
||||
crate::delegation::read_active_for_broker(&self.store.api, id, now).await
|
||||
}
|
||||
pub async fn create_plugin_delegation(
|
||||
&self,
|
||||
owner: &str,
|
||||
restrictions: crate::delegation::Restrictions,
|
||||
now: u64,
|
||||
) -> std::result::Result<crate::delegation::Delegation, crate::delegation::DelegationError> {
|
||||
crate::delegation::create(&self.store.api, owner, restrictions, now).await
|
||||
}
|
||||
pub async fn revoke_plugin_delegation(
|
||||
&self,
|
||||
owner: &str,
|
||||
id: &str,
|
||||
) -> std::result::Result<(), crate::delegation::DelegationError> {
|
||||
crate::delegation::revoke(&self.store.api, owner, id).await
|
||||
}
|
||||
pub async fn read_active_plugin_delegation(
|
||||
&self,
|
||||
owner: &str,
|
||||
id: &str,
|
||||
now: u64,
|
||||
) -> std::result::Result<crate::delegation::Delegation, crate::delegation::DelegationError> {
|
||||
crate::delegation::read_active(&self.store.api, owner, id, now).await
|
||||
}
|
||||
/// Reads an owned delegation record for historical lookups, including expired or revoked records.
|
||||
pub async fn read_owned_plugin_delegation(
|
||||
&self,
|
||||
owner: &str,
|
||||
id: &str,
|
||||
) -> std::result::Result<crate::delegation::Delegation, crate::delegation::DelegationError> {
|
||||
crate::delegation::read_owned(&self.store.api, owner, id).await
|
||||
}
|
||||
|
||||
/// Create a new IamSys instance with the given IamCache store
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -281,6 +335,16 @@ impl<T: Store> IamSys<T> {
|
||||
*guard = PolicyPluginState::Ready(client);
|
||||
}
|
||||
|
||||
/// Restricted credentials require the built-in parent/session intersection.
|
||||
/// External authorization bypasses that path, so only Disabled is supported.
|
||||
pub async fn ensure_restricted_credential_vending() -> Result<()> {
|
||||
if matches!(Self::policy_plugin_state().await, PolicyPluginState::Disabled) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::IAMActionNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_policy_plugin_client() -> Option<rustfs_policy::policy::opa::AuthZPlugin> {
|
||||
let policy_plugin_state = get_policy_plugin_state();
|
||||
let guard = policy_plugin_state.read().await;
|
||||
@@ -1383,17 +1447,50 @@ impl<T: Store> IamSys<T> {
|
||||
/// 1) know whether policy evaluation may need `s3:ExistingObjectTag`, and
|
||||
/// 2) evaluate with final conditions without re-merging identity policies.
|
||||
pub async fn prepare_auth(&self, args: &Args<'_>) -> PreparedIamAuth {
|
||||
if args.is_owner {
|
||||
let marked = args.claims.contains_key(PLUGIN_ATTEMPT_CREDENTIAL_CLAIM);
|
||||
if args.is_owner && !marked {
|
||||
return PreparedIamAuth {
|
||||
needs_existing_object_tag: false,
|
||||
mode: PreparedIamMode::Owner,
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(prepared) = Self::policy_plugin_state().await.prepared_iam_auth() {
|
||||
// One observed state decides this request's path. Marked credentials
|
||||
// never enter OPA, including after a restart or on another node.
|
||||
let state = Self::policy_plugin_state().await;
|
||||
if marked {
|
||||
if args.is_owner
|
||||
|| args.claims.get(PLUGIN_ATTEMPT_CREDENTIAL_CLAIM) != Some(&Value::Bool(true))
|
||||
|| !matches!(state, PolicyPluginState::Disabled)
|
||||
{
|
||||
return PreparedIamAuth {
|
||||
needs_existing_object_tag: false,
|
||||
mode: PreparedIamMode::TerminalDeny,
|
||||
};
|
||||
}
|
||||
let prepared = self.prepare_builtin_auth(args).await;
|
||||
let mode = match prepared.mode {
|
||||
PreparedIamMode::Sts {
|
||||
is_owner,
|
||||
combined_policy,
|
||||
session_policy,
|
||||
} => PreparedIamMode::PluginAttemptSts {
|
||||
is_owner,
|
||||
combined_policy,
|
||||
session_policy,
|
||||
},
|
||||
_ => PreparedIamMode::TerminalDeny,
|
||||
};
|
||||
return PreparedIamAuth {
|
||||
needs_existing_object_tag: prepared.needs_existing_object_tag,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
if let Some(prepared) = state.prepared_iam_auth() {
|
||||
return prepared;
|
||||
}
|
||||
|
||||
self.prepare_builtin_auth(args).await
|
||||
}
|
||||
async fn prepare_builtin_auth(&self, args: &Args<'_>) -> PreparedIamAuth {
|
||||
let Ok((is_svc, parent_user)) = self.is_service_account(args.account).await else {
|
||||
return PreparedIamAuth {
|
||||
needs_existing_object_tag: false,
|
||||
@@ -1425,6 +1522,20 @@ impl<T: Store> IamSys<T> {
|
||||
/// bucket policies on an IAM denial. An error must not become an implicit deny.
|
||||
pub async fn try_eval_prepared(&self, prepared: &PreparedIamAuth, args: &Args<'_>) -> Result<bool> {
|
||||
Ok(match &prepared.mode {
|
||||
PreparedIamMode::TerminalDeny => return Err(Error::IAMActionNotAllowed),
|
||||
PreparedIamMode::PluginAttemptSts {
|
||||
is_owner,
|
||||
combined_policy,
|
||||
session_policy,
|
||||
} => {
|
||||
// A false IAM result permits bucket-policy fallback. Restricted
|
||||
// plugin credentials must instead terminate authorization.
|
||||
let session_allowed = evaluate_prepared_session_policy(session_policy, args).await == Some(true);
|
||||
if !session_allowed || !(*is_owner || combined_policy.is_allowed(args).await) {
|
||||
return Err(Error::IAMActionNotAllowed);
|
||||
}
|
||||
true
|
||||
}
|
||||
PreparedIamMode::Opa => {
|
||||
let Some(opa_enable) = Self::get_policy_plugin_client().await else {
|
||||
tracing::warn!("eval_prepared: OPA mode requested but plugin is unavailable");
|
||||
@@ -2127,6 +2238,145 @@ mod tests {
|
||||
assert!(prepared.combined_policy_for_view().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn marked_signed_sts_denial_is_terminal_before_public_bucket_fallback() {
|
||||
let iam = IamSys::new(IamCache::new(StsTestMockStore::new(false)).await.unwrap());
|
||||
let previous = IamSys::<StsTestMockStore>::policy_plugin_state().await;
|
||||
let signing_key = "c07-test-token-signing-key";
|
||||
let mut metadata=HashMap::from([
|
||||
("parent".into(),Value::String("sts-fallback-test-parent".into())),
|
||||
("exp".into(),serde_json::json!((OffsetDateTime::now_utc()+time::Duration::hours(1)).unix_timestamp())),
|
||||
(SESSION_POLICY_NAME.into(),Value::String(base64_simd::URL_SAFE_NO_PAD.encode_to_string(br#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/inside"]}]}"#))),
|
||||
]);
|
||||
metadata.insert(PLUGIN_ATTEMPT_CREDENTIAL_CLAIM.into(), Value::Bool(true));
|
||||
let mut marked = get_new_credentials_with_metadata(&metadata, signing_key).unwrap();
|
||||
marked.parent_user = "sts-fallback-test-parent".into();
|
||||
let marked_claims: HashMap<String, Value> = rustfs_policy::utils::extract_claims(&marked.session_token, signing_key)
|
||||
.expect("signed test credentials must decode")
|
||||
.claims;
|
||||
let marked_user = UserIdentity::from(marked.clone());
|
||||
iam.store
|
||||
.cache
|
||||
.add_or_update_sts_account(&marked.access_key, &marked_user, OffsetDateTime::now_utc());
|
||||
metadata.remove(PLUGIN_ATTEMPT_CREDENTIAL_CLAIM);
|
||||
let mut ordinary = get_new_credentials_with_metadata(&metadata, signing_key).unwrap();
|
||||
ordinary.parent_user = marked.parent_user.clone();
|
||||
let ordinary_claims: HashMap<String, Value> = rustfs_policy::utils::extract_claims(&ordinary.session_token, signing_key)
|
||||
.expect("signed test credentials must decode")
|
||||
.claims;
|
||||
iam.store.cache.add_or_update_sts_account(
|
||||
&ordinary.access_key,
|
||||
&UserIdentity::from(ordinary.clone()),
|
||||
OffsetDateTime::now_utc(),
|
||||
);
|
||||
let groups = None;
|
||||
let conditions = HashMap::new();
|
||||
let args = Args {
|
||||
account: &marked.access_key,
|
||||
groups: &groups,
|
||||
action: Action::S3Action(S3Action::GetObjectAction),
|
||||
bucket: "mybucket",
|
||||
object: "outside",
|
||||
conditions: &conditions,
|
||||
is_owner: false,
|
||||
claims: &marked_claims,
|
||||
deny_only: false,
|
||||
};
|
||||
*get_policy_plugin_state().write().await = PolicyPluginState::Disabled;
|
||||
let denied = iam.prepare_auth(&args).await;
|
||||
let disabled_terminal = matches!(iam.try_eval_prepared(&denied, &args).await, Err(Error::IAMActionNotAllowed));
|
||||
let inside = Args {
|
||||
object: "inside",
|
||||
..args.clone()
|
||||
};
|
||||
let allowed = iam.prepare_auth(&inside).await;
|
||||
let inside_allowed = matches!(iam.try_eval_prepared(&allowed, &inside).await, Ok(true));
|
||||
let unmarked_args = Args {
|
||||
account: &ordinary.access_key,
|
||||
claims: &ordinary_claims,
|
||||
..args.clone()
|
||||
};
|
||||
let unmarked = iam.prepare_auth(&unmarked_args).await;
|
||||
let ordinary_false = matches!(iam.try_eval_prepared(&unmarked, &unmarked_args).await, Ok(false));
|
||||
let public:rustfs_policy::policy::BucketPolicy=serde_json::from_value(serde_json::json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/*"]}]})).unwrap();
|
||||
let public_allows = public
|
||||
.is_allowed(&rustfs_policy::policy::BucketPolicyArgs {
|
||||
account: &marked.access_key,
|
||||
groups: &groups,
|
||||
action: args.action,
|
||||
bucket: args.bucket,
|
||||
object: args.object,
|
||||
conditions: &conditions,
|
||||
is_owner: false,
|
||||
})
|
||||
.await;
|
||||
IamSys::<StsTestMockStore>::set_policy_plugin_client(opa::AuthZPlugin::new(opa::Args {
|
||||
url: "http://127.0.0.1:8181/allow".into(),
|
||||
auth_token: String::new(),
|
||||
}))
|
||||
.await;
|
||||
let opa_marked = iam.prepare_auth(&args).await;
|
||||
let ready_terminal = matches!(iam.try_eval_prepared(&opa_marked, &args).await, Err(Error::IAMActionNotAllowed));
|
||||
let opa_ordinary = iam.prepare_auth(&unmarked_args).await;
|
||||
let ordinary_keeps_opa = matches!(opa_ordinary.mode, PreparedIamMode::Opa);
|
||||
// The captured Disabled preparation stays on built-in intersection even
|
||||
// if an external policy client becomes ready before evaluation.
|
||||
let captured_still_terminal = matches!(iam.try_eval_prepared(&denied, &args).await, Err(Error::IAMActionNotAllowed));
|
||||
*get_policy_plugin_state().write().await = PolicyPluginState::Initializing;
|
||||
let pending = iam.prepare_auth(&args).await;
|
||||
let initializing_terminal = matches!(iam.try_eval_prepared(&pending, &args).await, Err(Error::IAMActionNotAllowed));
|
||||
*get_policy_plugin_state().write().await = PolicyPluginState::Failed;
|
||||
let failed = iam.prepare_auth(&args).await;
|
||||
let failed_terminal = matches!(iam.try_eval_prepared(&failed, &args).await, Err(Error::IAMActionNotAllowed));
|
||||
*get_policy_plugin_state().write().await = previous;
|
||||
assert!(public_allows, "C07_PUBLIC_BUCKET_CONTROL: real public policy permits outside object");
|
||||
assert!(
|
||||
disabled_terminal && captured_still_terminal,
|
||||
"C07_SCOPE_TERMINAL_BOUND: bucket policy must not rescue restricted scope denial"
|
||||
);
|
||||
assert!(
|
||||
ready_terminal && initializing_terminal && failed_terminal,
|
||||
"C07_EXTERNAL_POLICY_BOUND: marked persisted STS cannot enter OPA"
|
||||
);
|
||||
assert!(
|
||||
inside_allowed && ordinary_false && ordinary_keeps_opa,
|
||||
"C07_UNMARKED_COMPAT: preserve ordinary STS and valid marked scope"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn restricted_vending_rejects_actual_external_policy_plugin_state() {
|
||||
let previous = IamSys::<StsTestMockStore>::policy_plugin_state().await;
|
||||
IamSys::<StsTestMockStore>::set_policy_plugin_client(opa::AuthZPlugin::new(opa::Args {
|
||||
url: "http://127.0.0.1:8181/v1/data/rustfs/authz/allow".into(),
|
||||
auth_token: String::new(),
|
||||
}))
|
||||
.await;
|
||||
let active_rejected = IamSys::<StsTestMockStore>::ensure_restricted_credential_vending()
|
||||
.await
|
||||
.is_err();
|
||||
*get_policy_plugin_state().write().await = PolicyPluginState::Initializing;
|
||||
let initializing_rejected = IamSys::<StsTestMockStore>::ensure_restricted_credential_vending()
|
||||
.await
|
||||
.is_err();
|
||||
*get_policy_plugin_state().write().await = PolicyPluginState::Failed;
|
||||
let failed_rejected = IamSys::<StsTestMockStore>::ensure_restricted_credential_vending()
|
||||
.await
|
||||
.is_err();
|
||||
*get_policy_plugin_state().write().await = PolicyPluginState::Disabled;
|
||||
let disabled_allowed = IamSys::<StsTestMockStore>::ensure_restricted_credential_vending()
|
||||
.await
|
||||
.is_ok();
|
||||
*get_policy_plugin_state().write().await = previous;
|
||||
assert!(
|
||||
active_rejected && initializing_rejected && failed_rejected,
|
||||
"C07_EXTERNAL_POLICY_BOUND: external authorization cannot bypass minted session scope"
|
||||
);
|
||||
assert!(disabled_allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_prepare_auth_requests_existing_object_tags_in_opa_mode() {
|
||||
|
||||
@@ -1,10 +1,67 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use metrics_util::debugging::DebuggingRecorder;
|
||||
use rustfs_io_metrics::{
|
||||
LocalBatchReadDecision, LocalBatchReadMetrics, LocalBatchReadStage, record_local_batch_read_decision,
|
||||
set_get_stage_metrics_enabled,
|
||||
};
|
||||
use rustfs_io_metrics::{MetricsCollector, PerformanceMetrics, record_get_object_timeout_operation_start};
|
||||
use rustfs_io_metrics::{record_s3_op, s3_http_metrics::S3HttpRequestGuard};
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use std::hint::black_box;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn local_batch_attribution_sample() {
|
||||
let prepare = rustfs_io_metrics::get_stage_metrics_enabled().then(Instant::now);
|
||||
record_local_batch_read_decision(LocalBatchReadDecision::Selected, prepare.map(|started| started.elapsed()));
|
||||
if let Some(metrics) = LocalBatchReadMetrics::capture() {
|
||||
metrics.record_batch(4);
|
||||
let execution = Instant::now();
|
||||
metrics.record_stage(LocalBatchReadStage::QueueWait, true, execution.elapsed());
|
||||
for _ in 0..4 {
|
||||
for stage in [
|
||||
LocalBatchReadStage::Metadata,
|
||||
LocalBatchReadStage::Open,
|
||||
LocalBatchReadStage::Read,
|
||||
] {
|
||||
let started = Instant::now();
|
||||
metrics.record_stage(stage, true, started.elapsed());
|
||||
}
|
||||
metrics.record_shard(true, 32 * 1024, 32 * 1024);
|
||||
}
|
||||
metrics.record_stage(LocalBatchReadStage::Execution, true, execution.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_local_batch_attribution(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("local_batch_attribution_4_shards_debug_recorder");
|
||||
for (name, enabled) in [("disabled", false), ("enabled", true)] {
|
||||
set_get_stage_metrics_enabled(enabled);
|
||||
group.bench_function(name, |b| {
|
||||
b.iter_custom(|iterations| {
|
||||
let mut remaining = iterations;
|
||||
let mut elapsed = Duration::ZERO;
|
||||
while remaining > 0 {
|
||||
// Bound captured histogram storage independently of Criterion's
|
||||
// iteration count. Recorder setup/teardown is not measured.
|
||||
let chunk = remaining.min(128);
|
||||
let recorder = DebuggingRecorder::new();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let started = Instant::now();
|
||||
for _ in 0..chunk {
|
||||
local_batch_attribution_sample();
|
||||
}
|
||||
elapsed += started.elapsed();
|
||||
});
|
||||
remaining -= chunk;
|
||||
}
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
set_get_stage_metrics_enabled(false);
|
||||
}
|
||||
|
||||
fn bench_record_get_object_timeout_operation_start(c: &mut Criterion) {
|
||||
c.bench_function("record_get_object_timeout_operation_start", |b| {
|
||||
@@ -54,6 +111,7 @@ criterion_group!(
|
||||
bench_record_get_object_timeout_operation_start,
|
||||
bench_s3_http_outcomes,
|
||||
bench_update_concurrent_requests,
|
||||
bench_metrics_collector_record_io_operation
|
||||
bench_metrics_collector_record_io_operation,
|
||||
bench_local_batch_attribution
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -49,6 +49,11 @@
|
||||
#[macro_use]
|
||||
extern crate metrics;
|
||||
|
||||
mod local_batch_read;
|
||||
pub use local_batch_read::{
|
||||
LocalBatchReadDecision, LocalBatchReadMetrics, LocalBatchReadStage, record_local_batch_read_decision,
|
||||
};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{
|
||||
LazyLock, Mutex,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// 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.
|
||||
|
||||
//! Opt-in attribution for the buffered local shard batch path.
|
||||
|
||||
use crate::get_stage_metrics_enabled;
|
||||
use metrics::{Counter, Histogram};
|
||||
use std::time::Duration;
|
||||
|
||||
/// One decision per reader-setup attempt, rather than per S3 request.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum LocalBatchReadDecision {
|
||||
Selected,
|
||||
MmapDisabled,
|
||||
CopySource,
|
||||
ReadCapExceeded,
|
||||
InlineData,
|
||||
NonLocalShard,
|
||||
InvalidPath,
|
||||
NoLocalShards,
|
||||
UnsupportedPlatform,
|
||||
}
|
||||
|
||||
impl LocalBatchReadDecision {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Selected => "selected",
|
||||
Self::MmapDisabled => "mmap_disabled",
|
||||
Self::CopySource => "copy_source",
|
||||
Self::ReadCapExceeded => "read_cap_exceeded",
|
||||
Self::InlineData => "inline_data",
|
||||
Self::NonLocalShard => "non_local_shard",
|
||||
Self::InvalidPath => "invalid_path",
|
||||
Self::NoLocalShards => "no_local_shards",
|
||||
Self::UnsupportedPlatform => "unsupported_platform",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record eligibility, including path preparation only when it actually ran.
|
||||
pub fn record_local_batch_read_decision(decision: LocalBatchReadDecision, preparation: Option<Duration>) {
|
||||
if !get_stage_metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
let decision = decision.as_str();
|
||||
counter!("rustfs_io_get_object_local_batch_decisions_total", "decision" => decision).increment(1);
|
||||
if let Some(duration) = preparation {
|
||||
histogram!("rustfs_io_get_object_local_batch_prepare_seconds", "decision" => decision).record(duration.as_secs_f64());
|
||||
}
|
||||
}
|
||||
|
||||
/// Stages of the existing buffered positioned-read implementation.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum LocalBatchReadStage {
|
||||
QueueWait,
|
||||
Execution,
|
||||
Metadata,
|
||||
Open,
|
||||
Read,
|
||||
}
|
||||
|
||||
impl LocalBatchReadStage {
|
||||
const fn index(self) -> usize {
|
||||
match self {
|
||||
Self::QueueWait => 0,
|
||||
Self::Execution => 1,
|
||||
Self::Metadata => 2,
|
||||
Self::Open => 3,
|
||||
Self::Read => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles captured before dispatch, kept alive by the actual blocking worker.
|
||||
///
|
||||
/// Capturing the recorder once avoids per-I/O registration and also preserves
|
||||
/// completion samples when the caller stops waiting. No timers or handles are
|
||||
/// created with GET-stage metrics disabled. The snapshot applies to the whole
|
||||
/// submitted batch, including work that finishes after the switch is disabled.
|
||||
pub struct LocalBatchReadMetrics {
|
||||
stages: [[Histogram; 2]; 5],
|
||||
shards: [Counter; 2],
|
||||
requested_bytes: [Counter; 2],
|
||||
read_bytes: [Counter; 2],
|
||||
batch_size: Histogram,
|
||||
}
|
||||
|
||||
fn outcome(success: bool) -> &'static str {
|
||||
if success { "success" } else { "error" }
|
||||
}
|
||||
|
||||
impl LocalBatchReadMetrics {
|
||||
/// Resolve bounded-label handles only for an explicitly instrumented batch.
|
||||
/// Boxing the enabled state keeps the blocking task small when disabled.
|
||||
pub fn capture() -> Option<Box<Self>> {
|
||||
if !get_stage_metrics_enabled() {
|
||||
return None;
|
||||
}
|
||||
let stages = ["queue_wait", "execution", "metadata", "open", "read"].map(|stage| {
|
||||
[false, true].map(|success| {
|
||||
histogram!("rustfs_io_get_object_local_batch_stage_seconds", "stage" => stage, "outcome" => outcome(success))
|
||||
})
|
||||
});
|
||||
Some(Box::new(Self {
|
||||
stages,
|
||||
shards: [false, true]
|
||||
.map(|success| counter!("rustfs_io_get_object_local_batch_shards_total", "outcome" => outcome(success))),
|
||||
requested_bytes: [false, true]
|
||||
.map(|success| counter!("rustfs_io_get_object_local_batch_requested_bytes_total", "outcome" => outcome(success))),
|
||||
read_bytes: [false, true]
|
||||
.map(|success| counter!("rustfs_io_get_object_local_batch_read_bytes_total", "outcome" => outcome(success))),
|
||||
batch_size: histogram!("rustfs_io_get_object_local_batch_shards"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Count shards actually submitted to this blocking batch.
|
||||
pub fn record_batch(&self, shards: usize) {
|
||||
self.batch_size.record(crate::shard_read_fanout_to_f64(shards));
|
||||
}
|
||||
|
||||
/// Record an executed stage; unattempted stages receive no sample.
|
||||
///
|
||||
/// Queue wait succeeds when the worker starts. Execution covers the whole
|
||||
/// batch, while metadata/open/read describe individual I/O operations.
|
||||
/// Read timing excludes buffer allocation; execution timing includes it.
|
||||
pub fn record_stage(&self, stage: LocalBatchReadStage, success: bool, duration: Duration) {
|
||||
self.stages[stage.index()][usize::from(success)].record(duration.as_secs_f64());
|
||||
}
|
||||
|
||||
/// Separate completed shard outcomes and bytes actually returned by read_at.
|
||||
pub fn record_shard(&self, success: bool, requested: usize, read: usize) {
|
||||
let index = usize::from(success);
|
||||
self.shards[index].increment(1);
|
||||
self.requested_bytes[index].increment(u64::try_from(requested).unwrap_or(u64::MAX));
|
||||
self.read_bytes[index].increment(u64::try_from(read).unwrap_or(u64::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::set_get_stage_metrics_enabled;
|
||||
use crate::tests::{METRICS_FLAG_LOCK, counter_total, histogram_samples};
|
||||
use metrics_util::debugging::DebuggingRecorder;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn local_batch_read_metrics_are_disabled_without_stage_opt_in() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
set_get_stage_metrics_enabled(false);
|
||||
assert!(LocalBatchReadMetrics::capture().is_none());
|
||||
record_local_batch_read_decision(LocalBatchReadDecision::Selected, Some(Duration::from_millis(1)));
|
||||
});
|
||||
assert!(snapshotter.snapshot().into_vec().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_batch_read_handles_record_worker_costs_and_actual_bytes() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|error| error.into_inner());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
let observation = metrics::with_local_recorder(&recorder, || {
|
||||
set_get_stage_metrics_enabled(true);
|
||||
record_local_batch_read_decision(LocalBatchReadDecision::Selected, Some(Duration::from_millis(2)));
|
||||
LocalBatchReadMetrics::capture().expect("enabled batch metrics must capture recorder handles")
|
||||
});
|
||||
set_get_stage_metrics_enabled(false);
|
||||
std::thread::spawn(move || {
|
||||
observation.record_stage(LocalBatchReadStage::QueueWait, true, Duration::from_millis(3));
|
||||
observation.record_stage(LocalBatchReadStage::Metadata, false, Duration::from_millis(4));
|
||||
observation.record_shard(false, 1024, 0);
|
||||
observation.record_shard(true, 16, 16);
|
||||
observation.record_batch(2);
|
||||
})
|
||||
.join()
|
||||
.expect("worker should record through the captured handles");
|
||||
set_get_stage_metrics_enabled(false);
|
||||
|
||||
let rows = snapshotter.snapshot().into_vec();
|
||||
assert_eq!(counter_total(&rows, "rustfs_io_get_object_local_batch_decisions_total"), Some(1));
|
||||
assert_eq!(counter_total(&rows, "rustfs_io_get_object_local_batch_shards_total"), Some(2));
|
||||
assert_eq!(counter_total(&rows, "rustfs_io_get_object_local_batch_requested_bytes_total"), Some(1040));
|
||||
assert_eq!(counter_total(&rows, "rustfs_io_get_object_local_batch_read_bytes_total"), Some(16));
|
||||
assert_eq!(histogram_samples(&rows, "rustfs_io_get_object_local_batch_prepare_seconds"), vec![0.002]);
|
||||
assert_eq!(
|
||||
histogram_samples(&rows, "rustfs_io_get_object_local_batch_stage_seconds"),
|
||||
vec![0.003, 0.004]
|
||||
);
|
||||
assert_eq!(histogram_samples(&rows, "rustfs_io_get_object_local_batch_shards"), vec![2.0]);
|
||||
for (key, _, _, _) in rows {
|
||||
for label in key.key().labels() {
|
||||
assert!(matches!(label.key(), "decision" | "stage" | "outcome"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,37 @@ struct GuardHolderKey {
|
||||
owner: Arc<str>,
|
||||
}
|
||||
|
||||
/// Lock shard to reduce global contention
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
enum TestPausePoint {
|
||||
FastExisting,
|
||||
BeforeFastWrite,
|
||||
FastWriteExisting,
|
||||
SlowLookup,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct TestPause {
|
||||
entered: std::sync::mpsc::Sender<()>,
|
||||
resume: std::sync::mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static TEST_PAUSES: std::sync::LazyLock<parking_lot::Mutex<HashMap<(TestPausePoint, ObjectKey), TestPause>>> =
|
||||
std::sync::LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_pause(point: TestPausePoint, key: &ObjectKey) {
|
||||
let pause = TEST_PAUSES.lock().remove(&(point, key.clone()));
|
||||
if let Some(pause) = pause {
|
||||
pause.entered.send(()).expect("pause receiver");
|
||||
pause.resume.recv_timeout(Duration::from_secs(5)).expect("resume signal");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock shard to reduce global contention.
|
||||
///
|
||||
/// When both are needed, lock `objects` before a state's owner metadata.
|
||||
#[derive(Debug)]
|
||||
pub struct LockShard {
|
||||
/// Object lock states - using parking_lot for better performance
|
||||
@@ -133,13 +163,16 @@ impl LockShard {
|
||||
let objects = self.objects.read();
|
||||
if let Some(state) = objects.get(&request.key) {
|
||||
let state = state.clone();
|
||||
drop(objects);
|
||||
|
||||
#[cfg(test)]
|
||||
test_pause(TestPausePoint::FastExisting, &request.key);
|
||||
|
||||
// Try atomic acquisition
|
||||
let success = match request.mode {
|
||||
LockMode::Shared => state.try_acquire_shared_fast(&request.owner, request.lock_timeout),
|
||||
LockMode::Exclusive => state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout),
|
||||
};
|
||||
drop(objects);
|
||||
|
||||
if success {
|
||||
return Some(state);
|
||||
@@ -150,14 +183,21 @@ impl LockShard {
|
||||
// If object doesn't exist and we're requesting exclusive lock,
|
||||
// try to create and acquire atomically
|
||||
if request.mode == LockMode::Exclusive {
|
||||
#[cfg(test)]
|
||||
test_pause(TestPausePoint::BeforeFastWrite, &request.key);
|
||||
let mut objects = self.objects.write();
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if let Some(state) = objects.get(&request.key) {
|
||||
let state = state.clone();
|
||||
drop(objects);
|
||||
let objects = parking_lot::RwLockWriteGuard::downgrade(objects);
|
||||
|
||||
if state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout) {
|
||||
#[cfg(test)]
|
||||
test_pause(TestPausePoint::FastWriteExisting, &request.key);
|
||||
|
||||
let acquired = state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout);
|
||||
drop(objects);
|
||||
if acquired {
|
||||
return Some(state);
|
||||
}
|
||||
} else {
|
||||
@@ -193,10 +233,10 @@ impl LockShard {
|
||||
const NOTIFY_WAIT_CAP: Duration = Duration::from_millis(50);
|
||||
|
||||
loop {
|
||||
// Get or create object state
|
||||
let state = {
|
||||
// Keep the state in the map until its acquisition attempt completes.
|
||||
let (state, success) = {
|
||||
let mut objects = self.objects.write();
|
||||
match objects.get(&request.key) {
|
||||
let state = match objects.get(&request.key) {
|
||||
Some(state) => state.clone(),
|
||||
None => {
|
||||
let state_box = self.object_pool.acquire();
|
||||
@@ -204,13 +244,17 @@ impl LockShard {
|
||||
objects.insert(request.key.clone(), state.clone());
|
||||
state
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
let objects = parking_lot::RwLockWriteGuard::downgrade(objects);
|
||||
|
||||
// Try acquisition again
|
||||
let success = match request.mode {
|
||||
LockMode::Shared => state.try_acquire_shared_fast(&request.owner, request.lock_timeout),
|
||||
LockMode::Exclusive => state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout),
|
||||
#[cfg(test)]
|
||||
test_pause(TestPausePoint::SlowLookup, &request.key);
|
||||
let success = match request.mode {
|
||||
LockMode::Shared => state.try_acquire_shared_fast(&request.owner, request.lock_timeout),
|
||||
LockMode::Exclusive => state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout),
|
||||
};
|
||||
drop(objects);
|
||||
(state, success)
|
||||
};
|
||||
|
||||
if success {
|
||||
@@ -905,6 +949,184 @@ impl LockShard {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fast_lock::types::{LockPriority, ObjectKey};
|
||||
use crate::fast_lock::{FastLockGuard, FastObjectLockManager};
|
||||
|
||||
fn install_pause(point: TestPausePoint, key: &ObjectKey) -> (std::sync::mpsc::Receiver<()>, std::sync::mpsc::Sender<()>) {
|
||||
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
|
||||
let (resume_tx, resume_rx) = std::sync::mpsc::channel();
|
||||
let previous = TEST_PAUSES.lock().insert(
|
||||
(point, key.clone()),
|
||||
TestPause {
|
||||
entered: entered_tx,
|
||||
resume: resume_rx,
|
||||
},
|
||||
);
|
||||
assert!(previous.is_none());
|
||||
(entered_rx, resume_tx)
|
||||
}
|
||||
|
||||
fn test_manager() -> Arc<FastObjectLockManager> {
|
||||
// Construct outside a runtime so no background cleanup races the test.
|
||||
Arc::new(
|
||||
std::thread::spawn(|| {
|
||||
FastObjectLockManager::with_config(crate::fast_lock::types::LockConfig {
|
||||
shard_count: 4,
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.join()
|
||||
.expect("manager construction"),
|
||||
)
|
||||
}
|
||||
|
||||
fn start_acquisition(
|
||||
manager: &Arc<FastObjectLockManager>,
|
||||
request: ObjectLockRequest,
|
||||
) -> tokio::task::JoinHandle<Result<FastLockGuard, LockResult>> {
|
||||
let manager = manager.clone();
|
||||
tokio::spawn(async move { manager.acquire_lock(request).await })
|
||||
}
|
||||
|
||||
async fn assert_cleanup_cannot_split_state(
|
||||
manager: Arc<FastObjectLockManager>,
|
||||
key: ObjectKey,
|
||||
entered: std::sync::mpsc::Receiver<()>,
|
||||
resume: std::sync::mpsc::Sender<()>,
|
||||
pending: tokio::task::JoinHandle<Result<FastLockGuard, LockResult>>,
|
||||
seed: Option<FastLockGuard>,
|
||||
) {
|
||||
entered.recv_timeout(Duration::from_secs(5)).expect("state lookup");
|
||||
drop(seed);
|
||||
|
||||
// Idle timestamps have one-second precision, while cleanup compares milliseconds.
|
||||
let initial_millis = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis();
|
||||
while SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() == initial_millis {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
let can_write_map = manager.get_shard(&key).objects.try_write().is_some();
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let mut cleanup = {
|
||||
let manager = manager.clone();
|
||||
let key = key.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
started_tx.send(()).expect("cleanup start receiver");
|
||||
manager.get_shard(&key).cleanup_expired_millis(0)
|
||||
})
|
||||
};
|
||||
started_rx.recv_timeout(Duration::from_secs(5)).expect("cleanup started");
|
||||
if can_write_map {
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), &mut cleanup)
|
||||
.await
|
||||
.expect("cleanup completed")
|
||||
.expect("cleanup task"),
|
||||
1
|
||||
);
|
||||
let successor = manager
|
||||
.acquire_write_lock(key.clone(), "second")
|
||||
.await
|
||||
.expect("successor should acquire after cleanup");
|
||||
resume.send(()).expect("resume acquisition");
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), pending)
|
||||
.await
|
||||
.expect("acquisition completed")
|
||||
.expect("acquisition task");
|
||||
assert!(result.is_err(), "a removed state must not grant a conflicting lock");
|
||||
drop(successor);
|
||||
} else {
|
||||
resume.send(()).expect("resume acquisition");
|
||||
let first = tokio::time::timeout(Duration::from_secs(5), pending)
|
||||
.await
|
||||
.expect("acquisition completed")
|
||||
.expect("acquisition task")
|
||||
.expect("first acquisition");
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), cleanup)
|
||||
.await
|
||||
.expect("cleanup completed")
|
||||
.expect("cleanup task"),
|
||||
0
|
||||
);
|
||||
let second = ObjectLockRequest::new_write(key.clone(), "second").with_acquire_timeout(Duration::from_millis(100));
|
||||
assert!(
|
||||
manager.acquire_lock(second).await.is_err(),
|
||||
"conflicting locks must remain mutually exclusive"
|
||||
);
|
||||
drop(first);
|
||||
let successor = manager
|
||||
.acquire_write_lock(key, "second")
|
||||
.await
|
||||
.expect("successor after release");
|
||||
drop(successor);
|
||||
}
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cleanup_during_fast_existing_state_acquisition() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "fast-existing-cleanup");
|
||||
drop(manager.acquire_write_lock(key.clone(), "seed").await.expect("seed"));
|
||||
let (entered, resume) = install_pause(TestPausePoint::FastExisting, &key);
|
||||
let pending = start_acquisition(&manager, ObjectLockRequest::new_write(key.clone(), "first"));
|
||||
assert_cleanup_cannot_split_state(manager, key, entered, resume, pending, None).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cleanup_during_fast_write_recheck_acquisition() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "fast-recheck-cleanup");
|
||||
let (first_entered, first_resume) = install_pause(TestPausePoint::BeforeFastWrite, &key);
|
||||
let pending = start_acquisition(&manager, ObjectLockRequest::new_write(key.clone(), "first"));
|
||||
first_entered
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("before write recheck");
|
||||
drop(manager.acquire_write_lock(key.clone(), "seed").await.expect("seed"));
|
||||
let (entered, resume) = install_pause(TestPausePoint::FastWriteExisting, &key);
|
||||
first_resume.send(()).expect("resume before write recheck");
|
||||
assert_cleanup_cannot_split_state(manager, key, entered, resume, pending, None).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cleanup_during_slow_existing_state_acquisition() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "slow-existing-cleanup");
|
||||
let seed = manager.acquire_write_lock(key.clone(), "seed").await.expect("seed");
|
||||
let (entered, resume) = install_pause(TestPausePoint::SlowLookup, &key);
|
||||
let pending = start_acquisition(&manager, ObjectLockRequest::new_write(key.clone(), "first"));
|
||||
assert_cleanup_cannot_split_state(manager, key, entered, resume, pending, Some(seed)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cleanup_during_slow_new_state_acquisition() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "slow-new-cleanup");
|
||||
let (entered, resume) = install_pause(TestPausePoint::SlowLookup, &key);
|
||||
let pending = start_acquisition(&manager, ObjectLockRequest::new_read(key.clone(), "first"));
|
||||
assert_cleanup_cannot_split_state(manager, key, entered, resume, pending, None).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_keeps_new_fast_state_while_held() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "fast-new-cleanup");
|
||||
let first = manager
|
||||
.acquire_write_lock(key.clone(), "first")
|
||||
.await
|
||||
.expect("first acquisition");
|
||||
assert_eq!(manager.get_shard(&key).cleanup_expired_millis(0), 0);
|
||||
let second = ObjectLockRequest::new_write(key.clone(), "second").with_acquire_timeout(Duration::from_millis(100));
|
||||
assert!(manager.acquire_lock(second).await.is_err());
|
||||
drop(first);
|
||||
drop(
|
||||
manager
|
||||
.acquire_write_lock(key, "second")
|
||||
.await
|
||||
.expect("successor after release"),
|
||||
);
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shard_fast_path() {
|
||||
|
||||
@@ -83,6 +83,16 @@ const GET_OBJECT_DURATION_HISTOGRAM_BUCKETS: &[f64] = &[
|
||||
0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
|
||||
];
|
||||
|
||||
const LOCAL_BATCH_DURATION_HISTOGRAM_METRICS: &[&str] = &[
|
||||
"rustfs_io_get_object_local_batch_prepare_seconds",
|
||||
"rustfs_io_get_object_local_batch_stage_seconds",
|
||||
];
|
||||
|
||||
const LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS: &[f64] = &[
|
||||
0.0000001, 0.00000025, 0.0000005, 0.000001, 0.0000025, 0.000005, 0.00001, 0.000025, 0.00005, 0.0001, 0.00025, 0.0005, 0.001,
|
||||
0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
|
||||
];
|
||||
|
||||
const DEFAULT_OTLP_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const OTEL_EXPORTER_OTLP_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_TIMEOUT";
|
||||
const OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: &str = "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT";
|
||||
@@ -481,21 +491,25 @@ fn build_meter_provider(
|
||||
}
|
||||
|
||||
fn get_object_duration_histogram_view(instrument: &Instrument) -> Option<Stream> {
|
||||
if !is_get_object_duration_histogram_metric(instrument.name()) {
|
||||
return None;
|
||||
}
|
||||
let boundaries = get_object_duration_histogram_buckets(instrument.name())?;
|
||||
|
||||
Stream::builder()
|
||||
.with_aggregation(Aggregation::ExplicitBucketHistogram {
|
||||
boundaries: GET_OBJECT_DURATION_HISTOGRAM_BUCKETS.to_vec(),
|
||||
boundaries: boundaries.to_vec(),
|
||||
record_min_max: true,
|
||||
})
|
||||
.build()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn is_get_object_duration_histogram_metric(name: &str) -> bool {
|
||||
GET_OBJECT_DURATION_HISTOGRAM_METRICS.contains(&name)
|
||||
fn get_object_duration_histogram_buckets(name: &str) -> Option<&'static [f64]> {
|
||||
if LOCAL_BATCH_DURATION_HISTOGRAM_METRICS.contains(&name) {
|
||||
Some(LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS)
|
||||
} else if GET_OBJECT_DURATION_HISTOGRAM_METRICS.contains(&name) {
|
||||
Some(GET_OBJECT_DURATION_HISTOGRAM_BUCKETS)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an optional [`SdkLoggerProvider`] for the given log endpoint.
|
||||
@@ -1098,10 +1112,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_object_duration_histogram_metric_match_is_scoped() {
|
||||
assert!(is_get_object_duration_histogram_metric("rustfs_io_get_object_stage_duration_seconds"));
|
||||
assert!(is_get_object_duration_histogram_metric("rustfs_io_get_object_request_duration_seconds"));
|
||||
assert!(!is_get_object_duration_histogram_metric("rustfs_io_put_object_request_duration_seconds"));
|
||||
assert!(!is_get_object_duration_histogram_metric("rustfs_io_get_object_response_size_bytes"));
|
||||
for name in [
|
||||
"rustfs_io_get_object_stage_duration_seconds",
|
||||
"rustfs_io_get_object_request_duration_seconds",
|
||||
] {
|
||||
assert_eq!(get_object_duration_histogram_buckets(name), Some(GET_OBJECT_DURATION_HISTOGRAM_BUCKETS));
|
||||
}
|
||||
for name in LOCAL_BATCH_DURATION_HISTOGRAM_METRICS {
|
||||
assert_eq!(get_object_duration_histogram_buckets(name), Some(LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS));
|
||||
}
|
||||
for name in [
|
||||
"rustfs_io_put_object_request_duration_seconds",
|
||||
"rustfs_io_get_object_response_size_bytes",
|
||||
"rustfs_io_get_object_local_batch_shards",
|
||||
] {
|
||||
assert!(get_object_duration_histogram_buckets(name).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1111,6 +1137,17 @@ mod tests {
|
||||
assert_eq!(GET_OBJECT_DURATION_HISTOGRAM_BUCKETS.last(), Some(&10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_batch_duration_buckets_resolve_sub_microsecond_operations() {
|
||||
assert!(
|
||||
LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS
|
||||
.windows(2)
|
||||
.all(|pair| pair[0] < pair[1])
|
||||
);
|
||||
assert_eq!(LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS.first(), Some(&0.0000001));
|
||||
assert_eq!(LOCAL_BATCH_DURATION_HISTOGRAM_BUCKETS.last(), Some(&10.0));
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
feature = "pyroscope",
|
||||
any(target_os = "macos", all(target_os = "linux", target_env = "gnu", target_arch = "x86_64"))
|
||||
|
||||
@@ -568,6 +568,24 @@ pub enum AdminAction {
|
||||
GetTableLifecycleAction,
|
||||
#[strum(serialize = "admin:SetTableLifecycle")]
|
||||
SetTableLifecycleAction,
|
||||
#[strum(serialize = "admin:CreatePluginDelegation")]
|
||||
CreatePluginDelegationAction,
|
||||
#[strum(serialize = "admin:IssuePluginAttempt")]
|
||||
IssuePluginAttemptAction,
|
||||
#[strum(serialize = "admin:SubmitPluginJob")]
|
||||
SubmitPluginJobAction,
|
||||
#[strum(serialize = "admin:GetPluginRun")]
|
||||
GetPluginRunAction,
|
||||
#[strum(serialize = "admin:CancelPluginRun")]
|
||||
CancelPluginRunAction,
|
||||
#[strum(serialize = "admin:ListPluginRuns")]
|
||||
ListPluginRunsAction,
|
||||
#[strum(serialize = "admin:ReplayPluginRun")]
|
||||
ReplayPluginRunAction,
|
||||
#[strum(serialize = "admin:GetPluginDelegation")]
|
||||
GetPluginDelegationAction,
|
||||
#[strum(serialize = "admin:RevokePluginDelegation")]
|
||||
RevokePluginDelegationAction,
|
||||
#[strum(serialize = "admin:GetTableCredentials")]
|
||||
GetTableCredentialsAction,
|
||||
#[strum(serialize = "admin:RunTableMaintenance")]
|
||||
@@ -713,6 +731,15 @@ impl AdminAction {
|
||||
| AdminAction::GetTableLifecycleAction
|
||||
| AdminAction::SetTableLifecycleAction
|
||||
| AdminAction::GetTableCredentialsAction
|
||||
| AdminAction::IssuePluginAttemptAction
|
||||
| AdminAction::SubmitPluginJobAction
|
||||
| AdminAction::GetPluginRunAction
|
||||
| AdminAction::CancelPluginRunAction
|
||||
| AdminAction::ListPluginRunsAction
|
||||
| AdminAction::ReplayPluginRunAction
|
||||
| AdminAction::GetPluginDelegationAction
|
||||
| AdminAction::CreatePluginDelegationAction
|
||||
| AdminAction::RevokePluginDelegationAction
|
||||
| AdminAction::RunTableMaintenanceAction
|
||||
| AdminAction::GetTableMetadataLocationAction
|
||||
| AdminAction::SetTableMetadataLocationAction
|
||||
@@ -913,6 +940,35 @@ mod tests {
|
||||
assert_eq!(arr[0].as_str().unwrap(), "s3:*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_delegation_actions_are_explicit_and_unscoped() {
|
||||
for (name, expected) in [
|
||||
("admin:GetPluginDelegation", AdminAction::GetPluginDelegationAction),
|
||||
("admin:CreatePluginDelegation", AdminAction::CreatePluginDelegationAction),
|
||||
("admin:RevokePluginDelegation", AdminAction::RevokePluginDelegationAction),
|
||||
("admin:SubmitPluginJob", AdminAction::SubmitPluginJobAction),
|
||||
("admin:GetPluginRun", AdminAction::GetPluginRunAction),
|
||||
("admin:CancelPluginRun", AdminAction::CancelPluginRunAction),
|
||||
("admin:ListPluginRuns", AdminAction::ListPluginRunsAction),
|
||||
("admin:ReplayPluginRun", AdminAction::ReplayPluginRunAction),
|
||||
] {
|
||||
assert_eq!(AdminAction::try_from(name).unwrap(), expected);
|
||||
assert!(expected.is_valid());
|
||||
assert!(!expected.is_table_resource_scoped());
|
||||
}
|
||||
assert_eq!(
|
||||
AdminAction::try_from("admin:IssuePluginAttempt").unwrap(),
|
||||
AdminAction::IssuePluginAttemptAction
|
||||
);
|
||||
assert!(AdminAction::IssuePluginAttemptAction.is_valid());
|
||||
assert!(AdminAction::SubmitPluginJobAction.is_valid());
|
||||
assert!(AdminAction::GetPluginRunAction.is_valid());
|
||||
assert!(AdminAction::CancelPluginRunAction.is_valid());
|
||||
assert!(AdminAction::ListPluginRunsAction.is_valid());
|
||||
assert!(AdminAction::ReplayPluginRunAction.is_valid());
|
||||
assert!(AdminAction::try_from("admin:IssuePluginCredentials").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_replication_metrics_admin_action_is_valid() {
|
||||
assert!(AdminAction::GetReplicationMetricsAction.is_valid());
|
||||
|
||||
@@ -111,7 +111,7 @@ pub trait StorageBackend: Send + Sync {
|
||||
///
|
||||
/// Backends that implement this must apply per-bucket authorization. The default denies the
|
||||
/// request so existing backends cannot expose unfiltered bucket names.
|
||||
#[cfg(feature = "webdav")]
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
async fn list_buckets_for_session(
|
||||
&self,
|
||||
_session_context: &SessionContext,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
#[cfg(feature = "webdav")]
|
||||
use crate::common::client::s3::SessionCapacityView;
|
||||
use crate::common::client::s3::StorageBackend;
|
||||
#[cfg(feature = "webdav")]
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
use crate::common::session::SessionContext;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
@@ -254,6 +254,9 @@ impl Inner {
|
||||
pub struct DummyBackend {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
deny_authorization: bool,
|
||||
stall_authorization: bool,
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
stall_session_listing: bool,
|
||||
}
|
||||
|
||||
// Drivers whose trait bounds require `Debug` (for example FtpsDriver) cannot be
|
||||
@@ -278,6 +281,9 @@ impl DummyBackend {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Inner::new())),
|
||||
deny_authorization: false,
|
||||
stall_authorization: false,
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
stall_session_listing: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +300,19 @@ impl DummyBackend {
|
||||
self
|
||||
}
|
||||
|
||||
/// Make every authorize_operation await std::future::pending, so a
|
||||
/// caller's authorization deadline elapses instead of an allow or deny.
|
||||
pub fn stall_authorization(mut self) -> Self {
|
||||
self.stall_authorization = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
pub fn stall_session_listing(mut self) -> Self {
|
||||
self.stall_session_listing = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Opt in to consuming upload bodies for byte-for-byte protocol tests.
|
||||
pub fn capture_upload_bodies(&self) {
|
||||
self.inner.lock().expect("lock").capture_upload_bodies = true;
|
||||
@@ -695,6 +714,9 @@ impl StorageBackend for DummyBackend {
|
||||
if self.deny_authorization {
|
||||
return Err(crate::common::gateway::AuthorizationError::AccessDenied);
|
||||
}
|
||||
if self.stall_authorization {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
crate::common::gateway::authorize_operation(session, action, bucket, object).await
|
||||
}
|
||||
|
||||
@@ -848,7 +870,7 @@ impl StorageBackend for DummyBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "webdav")]
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
async fn list_buckets_for_session(
|
||||
&self,
|
||||
session_context: &SessionContext,
|
||||
@@ -856,12 +878,19 @@ impl StorageBackend for DummyBackend {
|
||||
secure_transport: bool,
|
||||
) -> s3s::S3Result<ListBucketsOutput> {
|
||||
let _ = session_context;
|
||||
if self.stall_session_listing {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.last_session_list_context = Some((request_headers.clone(), secure_transport));
|
||||
inner
|
||||
.session_list_buckets
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Ok(ListBucketsOutput::default()))
|
||||
// Fail closed when nothing is queued, like the trait default for
|
||||
// backends without session-aware listing.
|
||||
inner.session_list_buckets.pop_front().unwrap_or_else(|| {
|
||||
Err(s3s::S3Error::with_message(
|
||||
s3s::S3ErrorCode::AccessDenied,
|
||||
"Session-aware bucket listing is not supported",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Capacity reporting is opt-in; an empty queue models a backend without
|
||||
|
||||
@@ -225,16 +225,32 @@ impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
|
||||
/// OPENDIR so a client without ListAllMyBuckets can still open the
|
||||
/// root directory handle.
|
||||
///
|
||||
/// A principal without ListAllMyBuckets (a bucket-scoped user) gets
|
||||
/// the session-aware ListBuckets instead, which IAM filters to the
|
||||
/// buckets the principal may ListBucket or GetBucketLocation, the
|
||||
/// same as S3 ListBuckets and the WebDAV root. It carries the
|
||||
/// session's request headers and transport security, like every
|
||||
/// other SFTP authorization.
|
||||
///
|
||||
/// ListBuckets is not batched in the S3 API. A single response
|
||||
/// carries the full set. Truncate at ROOT_LISTING_MAX_ENTRIES so a
|
||||
/// principal with many visible buckets produces a bounded Vec and
|
||||
/// does not exceed the SSH channel window with a single response.
|
||||
pub(super) async fn fetch_bucket_list(&self) -> Result<Vec<File>, SftpError> {
|
||||
self.authorize(&S3Action::ListBuckets, "", None).await?;
|
||||
|
||||
let out = self
|
||||
.run_backend("list_buckets", self.storage.list_buckets(self.credentials()))
|
||||
.await?;
|
||||
let out = if self.is_authorized(&S3Action::ListBuckets, "", None).await? {
|
||||
self.run_backend("list_buckets", self.storage.list_buckets(self.credentials()))
|
||||
.await?
|
||||
} else {
|
||||
self.run_backend(
|
||||
"list_buckets",
|
||||
self.storage.list_buckets_for_session(
|
||||
&self.session_context,
|
||||
&self.session_context.request_headers,
|
||||
self.session_context.secure_transport,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut truncated_at: Option<usize> = None;
|
||||
@@ -444,15 +460,174 @@ impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::state::{DirCursor, HandleState, ListingContinuation};
|
||||
use super::super::test_support::{TEST_PART_SIZE, build_driver, capture_tracing_at};
|
||||
use super::super::test_support::{TEST_PART_SIZE, build_driver, build_driver_with_timeout, capture_tracing_at};
|
||||
use crate::common::dummy_storage::{DummyBackend, DummyError};
|
||||
use crate::common::gateway::with_test_auth_override;
|
||||
use crate::common::gateway::{with_test_auth_override, with_test_iam_unavailable};
|
||||
use russh_sftp::protocol::StatusCode;
|
||||
use russh_sftp::server::Handler;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Notify;
|
||||
use tracing::Level;
|
||||
|
||||
fn buckets(names: &[&str]) -> s3s::dto::ListBucketsOutput {
|
||||
s3s::dto::ListBucketsOutput {
|
||||
buckets: Some(
|
||||
names
|
||||
.iter()
|
||||
.map(|n| s3s::dto::Bucket {
|
||||
name: Some((*n).to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn names(files: &[russh_sftp::protocol::File]) -> Vec<&str> {
|
||||
files.iter().map(|f| f.filename.as_str()).collect()
|
||||
}
|
||||
|
||||
fn deny_list_all_my_buckets(action: &crate::common::gateway::S3Action, _: &str, _: Option<&str>) -> bool {
|
||||
!matches!(action, crate::common::gateway::S3Action::ListBuckets)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_with_list_all_my_buckets_lists_every_bucket() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_list_buckets_ok(buckets(&["alpha", "beta"]));
|
||||
backend.queue_session_list_buckets_ok(buckets(&["beta"]));
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let files = with_test_auth_override(|_, _, _| true, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect("global listing");
|
||||
|
||||
assert_eq!(names(&files), ["alpha", "beta"]);
|
||||
assert!(backend.last_session_list_context().is_none(), "session listing must not run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_without_list_all_my_buckets_lists_only_visible_buckets() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_list_buckets_ok(buckets(&["alpha", "beta"]));
|
||||
backend.queue_session_list_buckets_ok(buckets(&["beta"]));
|
||||
let mut driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
driver
|
||||
.session_context
|
||||
.request_headers
|
||||
.insert("user-agent", http::HeaderValue::from_static("sftp-listing-test"));
|
||||
driver.session_context.secure_transport = false;
|
||||
|
||||
let files = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect("bucket-scoped listing");
|
||||
|
||||
assert_eq!(names(&files), ["beta"]);
|
||||
let (headers, secure) = backend.last_session_list_context().expect("session listing ran");
|
||||
assert_eq!(headers, driver.session_context.request_headers, "same headers as every other SFTP check");
|
||||
assert_eq!(
|
||||
secure, driver.session_context.secure_transport,
|
||||
"same transport as every other SFTP check"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_without_any_visible_bucket_is_permission_denied() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_session_list_buckets_err(s3s::S3Error::new(s3s::S3ErrorCode::AccessDenied));
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let err = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("no visible bucket");
|
||||
|
||||
assert_eq!(err.0, StatusCode::PermissionDenied);
|
||||
assert!(backend.last_session_list_context().is_some(), "the session listing ran");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_fails_without_fallback_when_iam_is_unavailable() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_session_list_buckets_ok(buckets(&["beta"]));
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let err = with_test_iam_unavailable(driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("IAM outage");
|
||||
|
||||
assert_eq!(err.0, StatusCode::Failure);
|
||||
assert!(backend.last_session_list_context().is_none(), "an IAM outage is not a policy deny");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn root_listing_fails_without_fallback_when_the_global_check_times_out() {
|
||||
let backend = Arc::new(DummyBackend::new().stall_authorization());
|
||||
backend.queue_session_list_buckets_ok(buckets(&["beta"]));
|
||||
let driver = build_driver_with_timeout(backend.clone(), TEST_PART_SIZE, 1);
|
||||
|
||||
let err = driver.fetch_bucket_list().await.expect_err("authorization deadline");
|
||||
|
||||
assert_eq!(err.0, StatusCode::Failure);
|
||||
assert!(backend.last_session_list_context().is_none(), "a timeout is not a policy deny");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn root_listing_fallback_respects_the_backend_deadline() {
|
||||
let backend = Arc::new(DummyBackend::new().stall_session_listing());
|
||||
let driver = build_driver_with_timeout(backend, TEST_PART_SIZE, 1);
|
||||
|
||||
let err = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("session listing deadline");
|
||||
|
||||
assert_eq!(err.0, StatusCode::Failure);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_session_error_other_than_access_denied_is_failure() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_session_list_buckets_err(s3s::S3Error::with_message(
|
||||
s3s::S3ErrorCode::InternalError,
|
||||
"AccessDenied in the message is not an access denial",
|
||||
));
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let err = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("backend failure");
|
||||
|
||||
assert_eq!(err.0, StatusCode::Failure);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_fallback_is_truncated_at_the_root_listing_limit() {
|
||||
for count in [super::ROOT_LISTING_MAX_ENTRIES, super::ROOT_LISTING_MAX_ENTRIES + 1] {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
let many: Vec<String> = (0..count).map(|i| format!("bucket-{i:05}")).collect();
|
||||
backend.queue_session_list_buckets_ok(buckets(&many.iter().map(String::as_str).collect::<Vec<_>>()));
|
||||
let driver = build_driver(backend, TEST_PART_SIZE);
|
||||
|
||||
let files = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect("bucket-scoped listing");
|
||||
|
||||
assert_eq!(files.len(), super::ROOT_LISTING_MAX_ENTRIES, "bucket count {count}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_fails_closed_when_the_backend_has_no_session_listing() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let err = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("no session-aware listing");
|
||||
|
||||
assert_eq!(err.0, StatusCode::PermissionDenied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opendir_and_readdir_authorize_the_same_listing_parameters() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
|
||||
@@ -301,6 +301,28 @@ impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
|
||||
self.await_authorization(action, bucket, key, auth_fut).await
|
||||
}
|
||||
|
||||
/// Like authorize, but a policy deny returns Ok(false) instead of
|
||||
/// PermissionDenied, without the SFTP driver's access_denied warn,
|
||||
/// for callers that fall back to a narrower operation when the
|
||||
/// action is denied. The storage layer may still log the denial.
|
||||
/// IamUnavailable and the deadline are handled exactly as in
|
||||
/// authorize.
|
||||
pub(super) async fn is_authorized(&self, action: &S3Action, bucket: &str, key: Option<&str>) -> Result<bool, SftpError> {
|
||||
let auth_fut = self.storage.authorize_operation(&self.session_context, action, bucket, key);
|
||||
let mut denied = false;
|
||||
let deny_as_ok = async {
|
||||
match auth_fut.await {
|
||||
Err(AuthorizationError::AccessDenied) => {
|
||||
denied = true;
|
||||
Ok(())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
};
|
||||
self.await_authorization(action, bucket, key, deny_as_ok).await?;
|
||||
Ok(!denied)
|
||||
}
|
||||
|
||||
pub(super) async fn authorize_list_objects(&self, input: &ListObjectsV2Input) -> Result<(), SftpError> {
|
||||
let auth_fut = self.storage.authorize_list_objects(&self.session_context, input);
|
||||
self.await_authorization(&S3Action::ListBucket, &input.bucket, input.prefix.as_deref(), auth_fut)
|
||||
|
||||
@@ -53,6 +53,10 @@ pub enum ScannerError {
|
||||
#[error("Remote namespace scanner disk is already active")]
|
||||
RemoteDiskBusy,
|
||||
|
||||
/// Node resource pressure deferred work before its first storage poll.
|
||||
#[error("Scanner node resource admission deferred")]
|
||||
ResourceDeferred,
|
||||
|
||||
/// Partial data usage cache produced before the scanner stopped.
|
||||
#[error("Scanner stopped with partial data usage cache")]
|
||||
PartialCache(Box<DataUsageCache>),
|
||||
|
||||
+73
-14
@@ -50,9 +50,9 @@ use storage_api::owner::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use storage_api::owner::{
|
||||
EcstoreDiskOption, EcstoreDiskStore, EcstoreEndpoint, EcstoreEndpointServerPools, EcstoreEndpoints, EcstoreInstanceContext,
|
||||
EcstorePoolEndpoints, ecstore_config_init, ecstore_init_bucket_metadata_sys, ecstore_init_local_disks_with_instance_ctx,
|
||||
ecstore_new_disk,
|
||||
EcstoreDiskOption, EcstoreDiskStore, EcstoreEndpoint, EcstoreEndpointServerPools, EcstoreEndpoints, EcstoreExpiryState,
|
||||
EcstoreInstanceContext, EcstorePoolEndpoints, ecstore_config_init, ecstore_init_bucket_metadata_sys,
|
||||
ecstore_init_local_disks_with_instance_ctx, ecstore_new_disk,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -123,7 +123,7 @@ pub use storage_api::scan::{
|
||||
SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION, SCANNER_DIRTY_USAGE_SNAPSHOT_RPC_MAX_MESSAGE_SIZE,
|
||||
SCANNER_SCOPED_DIRTY_USAGE_ACK_MAX_ENTRIES,
|
||||
};
|
||||
pub use workload_admission::set_scanner_workload_admission_snapshot_provider;
|
||||
pub use workload_admission::{set_scanner_node_admission, set_scanner_workload_admission_snapshot_provider};
|
||||
|
||||
static SCANNER_ACTIVE_WORK_UNITS: AtomicU64 = AtomicU64::new(0);
|
||||
static SCANNER_RUNTIME_INSTANCES: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -678,8 +678,37 @@ fn reset_tier_name_cache_for_test() {
|
||||
TIER_REGISTRY_GENERATION.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_runtime_free_version(oi: ScannerObjectInfo) {
|
||||
ecstore_expiry_state_handle().write().await.enqueue_free_version(oi);
|
||||
// Only a task running an isolated unit test can override these handles.
|
||||
#[cfg(test)]
|
||||
struct ExpiryLockTestContext {
|
||||
state: Arc<tokio::sync::RwLock<EcstoreExpiryState>>,
|
||||
store: Option<Arc<ECStore>>,
|
||||
completed_incarnation_reads: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
static EXPIRY_LOCK_TEST_CONTEXT: ExpiryLockTestContext;
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_runtime_free_version(oi: ScannerObjectInfo, cancellation: Option<&CancellationToken>) {
|
||||
#[cfg(test)]
|
||||
let state = EXPIRY_LOCK_TEST_CONTEXT
|
||||
.try_with(|context| context.state.clone())
|
||||
.unwrap_or_else(|_| ecstore_expiry_state_handle());
|
||||
#[cfg(not(test))]
|
||||
let state = ecstore_expiry_state_handle();
|
||||
let state = match cancellation {
|
||||
Some(cancel) => tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return,
|
||||
state = state.write() => state,
|
||||
},
|
||||
None => state.write().await,
|
||||
};
|
||||
if !cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
state.enqueue_free_version(oi);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn enqueue_runtime_newer_noncurrent(
|
||||
@@ -687,20 +716,50 @@ pub(crate) async fn enqueue_runtime_newer_noncurrent(
|
||||
to_delete_objs: Vec<ObjectToDelete>,
|
||||
event: Event,
|
||||
src: &LcEventSrc,
|
||||
cancellation: Option<&CancellationToken>,
|
||||
) -> bool {
|
||||
let Some(store) = ecstore_resolve_object_store_handle() else {
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return false;
|
||||
}
|
||||
#[cfg(test)]
|
||||
let store = EXPIRY_LOCK_TEST_CONTEXT
|
||||
.try_with(|context| context.store.clone())
|
||||
.unwrap_or_else(|_| ecstore_resolve_object_store_handle());
|
||||
#[cfg(not(test))]
|
||||
let store = ecstore_resolve_object_store_handle();
|
||||
let Some(store) = store else {
|
||||
return false;
|
||||
};
|
||||
let Ok(bucket_incarnation_id) = store.bucket_incarnation_id(bucket).await else {
|
||||
return false;
|
||||
};
|
||||
ecstore_expiry_state_handle().write().await.enqueue_by_newer_noncurrent(
|
||||
bucket,
|
||||
to_delete_objs,
|
||||
event,
|
||||
src,
|
||||
bucket_incarnation_id,
|
||||
)
|
||||
#[cfg(test)]
|
||||
let _ = EXPIRY_LOCK_TEST_CONTEXT.try_with(|context| {
|
||||
context
|
||||
.completed_incarnation_reads
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
});
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return false;
|
||||
}
|
||||
#[cfg(test)]
|
||||
let state = EXPIRY_LOCK_TEST_CONTEXT
|
||||
.try_with(|context| context.state.clone())
|
||||
.unwrap_or_else(|_| ecstore_expiry_state_handle());
|
||||
#[cfg(not(test))]
|
||||
let state = ecstore_expiry_state_handle();
|
||||
let mut state = match cancellation {
|
||||
Some(cancel) => tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return false,
|
||||
state = state.write() => state,
|
||||
},
|
||||
None => state.write().await,
|
||||
};
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return false;
|
||||
}
|
||||
state.enqueue_by_newer_noncurrent(bucket, to_delete_objs, event, src, bucket_incarnation_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn queue_replication_heal(
|
||||
|
||||
@@ -705,7 +705,7 @@ async fn scan_and_persist_local_bucket(
|
||||
RemoteScannerServerError::worker("remote namespace scanner cache publication is blocked by data movement")
|
||||
})?;
|
||||
let cache_name = path_join_buf(&[&bucket, DATA_USAGE_CACHE_NAME]);
|
||||
let guard = acquire_scanner_cache_locks(set.as_ref(), &cache_name, source)
|
||||
let mut guard = acquire_scanner_cache_locks(set.as_ref(), &cache_name, source)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.is_contention() {
|
||||
@@ -791,19 +791,23 @@ async fn scan_and_persist_local_bucket(
|
||||
|
||||
let set_disks = scanner_set_disk_inventory(set.as_ref()).await;
|
||||
let (checkpoint_tx, mut checkpoint_rx) = mpsc::channel::<DataUsageCache>(1);
|
||||
let (node_tx, mut node_rx) = crate::workload_admission::scanner_node_request_channel();
|
||||
let scan_ctx = ctx.child_token();
|
||||
let scan = ScannerIODisk::nsscanner_disk(
|
||||
disk.clone(),
|
||||
scan_ctx.clone(),
|
||||
budget,
|
||||
set_disks,
|
||||
cache,
|
||||
None,
|
||||
ScannerDiskScanOptions {
|
||||
scan_mode,
|
||||
prefix_scan_scope: None,
|
||||
checkpoint_tx: Some(checkpoint_tx),
|
||||
},
|
||||
let scan = crate::workload_admission::with_scanner_node_requests(
|
||||
node_tx,
|
||||
ScannerIODisk::nsscanner_disk(
|
||||
disk.clone(),
|
||||
scan_ctx.clone(),
|
||||
budget,
|
||||
set_disks,
|
||||
cache,
|
||||
None,
|
||||
ScannerDiskScanOptions {
|
||||
scan_mode,
|
||||
prefix_scan_scope: None,
|
||||
checkpoint_tx: Some(checkpoint_tx),
|
||||
},
|
||||
),
|
||||
);
|
||||
tokio::pin!(scan);
|
||||
let fence_watch = watch_remote_scanner_request_fence(next_cycle, leader_epoch, store.clone(), NS_SCANNER_FENCE_POLL_INTERVAL);
|
||||
@@ -813,6 +817,27 @@ async fn scan_and_persist_local_bucket(
|
||||
let mut checkpoint_channel_closed = false;
|
||||
let outcome = loop {
|
||||
tokio::select! {
|
||||
request = async {
|
||||
match node_rx.as_mut() {
|
||||
Some(receiver) => receiver.recv().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => {
|
||||
let Some(request) = request else {
|
||||
node_rx = None;
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = wait_for_remote_node_resumption(
|
||||
guard.resume_node_operation(set.clone(), &cache_name, source, &revisions, request),
|
||||
fence_watch.as_mut(),
|
||||
).await {
|
||||
scan_ctx.cancel();
|
||||
let _ = tokio::time::timeout(NS_SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT, scan.as_mut()).await;
|
||||
return Err(RemoteScannerServerError::retry_bucket(format!(
|
||||
"remote namespace scanner could not resume node admission: {error}",
|
||||
)));
|
||||
}
|
||||
}
|
||||
result = &mut scan => {
|
||||
match result {
|
||||
Ok(outcome) => break outcome,
|
||||
@@ -1121,6 +1146,22 @@ pub(crate) async fn scan_remote_bucket(
|
||||
result
|
||||
}
|
||||
|
||||
/// Resource waits must keep checking the persisted leader fence.
|
||||
async fn wait_for_remote_node_resumption<F, G>(resume: F, fence_watch: Pin<&mut G>) -> Result<(), StorageError>
|
||||
where
|
||||
F: Future<Output = Result<(), StorageError>>,
|
||||
G: Future<Output = Result<(), ScannerError>>,
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = fence_watch => Err(match result {
|
||||
Ok(()) => StorageError::other("remote namespace scanner fence watcher stopped unexpectedly"),
|
||||
Err(error) => StorageError::other(error),
|
||||
}),
|
||||
result = resume => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_remote_scanner_stream(
|
||||
result: RemoteScannerStreamResult<RemoteScannerOutcome>,
|
||||
budget: &ScannerCycleBudget,
|
||||
|
||||
@@ -1006,6 +1006,58 @@ async fn fence_watcher_stops_work_after_persisted_leader_changes() {
|
||||
assert!(err.to_string().contains("requested_epoch=9"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_admission_wait_does_not_hide_replacement_leader() {
|
||||
use rustfs_concurrency::adaptive::{FeedbackController, FeedbackObservation, NodeAdmission, ResourcePressure};
|
||||
use std::num::NonZeroU16;
|
||||
|
||||
let store = Arc::new(MutableCycleStateStore {
|
||||
state: RwLock::new(crate::scanner::encode_scanner_cycle_fence_for_test(7, 9)),
|
||||
});
|
||||
let cache = Mutex::new(None);
|
||||
let refresh = AsyncMutex::new(());
|
||||
let watcher = watch_remote_scanner_request_fence_with_cache(
|
||||
7,
|
||||
9,
|
||||
store.clone(),
|
||||
Duration::from_millis(5),
|
||||
&cache,
|
||||
&refresh,
|
||||
Duration::from_millis(5),
|
||||
);
|
||||
tokio::pin!(watcher);
|
||||
let gate = NodeAdmission::new(NonZeroU16::new(1).expect("one shared node unit"));
|
||||
let storage_polled = AtomicBool::new(false);
|
||||
{
|
||||
let resume = async {
|
||||
let _permit = gate.acquire().await;
|
||||
storage_polled.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
};
|
||||
let waiting = wait_for_remote_node_resumption(resume, watcher.as_mut());
|
||||
tokio::pin!(waiting);
|
||||
assert!(futures::poll!(&mut waiting).is_pending(), "no resource sample grants a node unit");
|
||||
*store.state.write().expect("persist replacement leader") = crate::scanner::encode_scanner_cycle_fence_for_test(7, 10);
|
||||
let error = tokio::time::timeout(Duration::from_millis(100), waiting)
|
||||
.await
|
||||
.expect("resource pause must not hide a changed leader")
|
||||
.expect_err("old leader cannot resume storage work");
|
||||
assert!(error.to_string().contains("requested_epoch=9"));
|
||||
}
|
||||
assert!(!storage_polled.load(Ordering::Relaxed));
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
gate.update(
|
||||
&mut FeedbackController::new(),
|
||||
Some(FeedbackObservation {
|
||||
generation: 1,
|
||||
max_concurrency: 1,
|
||||
pressure: ResourcePressure::Unknown,
|
||||
completed_units: None,
|
||||
}),
|
||||
);
|
||||
assert!(gate.try_acquire().is_some(), "failed fencing drops the old node queue ticket");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_cycle_cache_misses_share_one_backend_read() {
|
||||
let request = RemoteScannerRequest(test_request(Uuid::new_v4()));
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::storage_api::owner::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::storage_api::owner::{EcstoreExpirationStatus as ExpirationStatus, EcstoreLifecycleRule as LifecycleRule};
|
||||
use crate::workload_admission::run_scanner_node_operation;
|
||||
use metrics::{counter, describe_counter};
|
||||
use rustfs_common::trace_bus::{TraceEvent, TraceFunc, TraceKind, trace_emit, trace_subscriber_count};
|
||||
use rustfs_filemeta::{
|
||||
@@ -795,6 +796,7 @@ pub struct FolderScanner {
|
||||
resolved_failed_object_paths: HashSet<String>,
|
||||
|
||||
sleeper: DynamicSleeper,
|
||||
node_admission: Option<Arc<rustfs_concurrency::adaptive::NodeAdmission>>,
|
||||
// should_heal: Arc<dyn Fn() -> bool + Send + Sync>,
|
||||
disks: Vec<Arc<Disk>>,
|
||||
disks_quorum: usize,
|
||||
@@ -828,6 +830,8 @@ pub struct FolderScanner {
|
||||
pending_size_reconciliation_truncated: bool,
|
||||
#[cfg(test)]
|
||||
list_path_raw_options_observer: Option<mpsc::UnboundedSender<ListPathRawTimeoutSnapshot>>,
|
||||
#[cfg(test)]
|
||||
defer_abandoned_entries_until_finished: bool,
|
||||
}
|
||||
|
||||
struct RawEnumerationProgress {
|
||||
@@ -998,12 +1002,13 @@ async fn next_scanner_dir_entry(
|
||||
ctx: &CancellationToken,
|
||||
sleeper: &DynamicSleeper,
|
||||
entries: &mut ScannerDirectory,
|
||||
admission: Option<&Arc<rustfs_concurrency::adaptive::NodeAdmission>>,
|
||||
) -> Result<Option<tokio::fs::DirEntry>, ScannerError> {
|
||||
if entries.batch_entries == SCANNER_DIRECTORY_BATCH_ENTRIES {
|
||||
wait_for_scanner_io(ctx, tokio::task::yield_now()).await?;
|
||||
entries.batch_entries = 0;
|
||||
}
|
||||
let entry = wait_for_scanner_io(ctx, entries.next_entry()).await??;
|
||||
let entry = wait_for_scanner_io(ctx, run_scanner_node_operation(admission, ctx, entries.next_entry())).await???;
|
||||
if entry.is_some() {
|
||||
// Tokio prefetches bounded chunks. Pacing consumption also paces the
|
||||
// next refill, without charging an extra entry for EOF.
|
||||
@@ -1923,7 +1928,12 @@ impl FolderScanner {
|
||||
// directory that vanished during iteration instead of failing the
|
||||
// whole parent subtree. The bucket root (no parent) still propagates
|
||||
// NotFound so namespace removal is detected by the caller.
|
||||
let mut dir_reader = match wait_for_scanner_io(&ctx, ScannerDirectory::open(&dir_path)).await? {
|
||||
let mut dir_reader = match wait_for_scanner_io(
|
||||
&ctx,
|
||||
run_scanner_node_operation(self.node_admission.as_ref(), &ctx, ScannerDirectory::open(&dir_path)),
|
||||
)
|
||||
.await??
|
||||
{
|
||||
Ok(dir_reader) => Some(dir_reader),
|
||||
Err(e)
|
||||
if folder.parent.is_some()
|
||||
@@ -1961,7 +1971,7 @@ impl FolderScanner {
|
||||
// Hold at most one metadata entry until a candidate reaches
|
||||
// EOF: a later part must not leave object accounting or ILM work.
|
||||
let (entry, replayed_metadata) = if let Some(reader) = dir_reader.as_mut() {
|
||||
let entry = match next_scanner_dir_entry(&ctx, &self.sleeper, reader).await {
|
||||
let entry = match next_scanner_dir_entry(&ctx, &self.sleeper, reader, self.node_admission.as_ref()).await {
|
||||
Ok(Some(entry)) => entry,
|
||||
Ok(None) => {
|
||||
raw_enumeration_complete = true;
|
||||
@@ -2072,7 +2082,12 @@ impl FolderScanner {
|
||||
|
||||
// Ignore entries that disappeared during traversal or hit symlink
|
||||
// loops, but propagate other walk errors.
|
||||
let mut entry_type = match wait_for_scanner_io(&ctx, entry.file_type()).await? {
|
||||
let mut entry_type = match wait_for_scanner_io(
|
||||
&ctx,
|
||||
run_scanner_node_operation(self.node_admission.as_ref(), &ctx, entry.file_type()),
|
||||
)
|
||||
.await??
|
||||
{
|
||||
Ok(entry_type) => entry_type,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => {
|
||||
debug!(
|
||||
@@ -2123,7 +2138,12 @@ impl FolderScanner {
|
||||
let mut entry_metadata_len = None;
|
||||
if entry_type.is_symlink() {
|
||||
wait_for_scanner_io(&ctx, self.sleeper.admit_disk_io(1, 0)).await?;
|
||||
let metadata = match wait_for_scanner_io(&ctx, tokio::fs::metadata(&file_path)).await? {
|
||||
let metadata = match wait_for_scanner_io(
|
||||
&ctx,
|
||||
run_scanner_node_operation(self.node_admission.as_ref(), &ctx, tokio::fs::metadata(&file_path)),
|
||||
)
|
||||
.await??
|
||||
{
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => {
|
||||
debug!(
|
||||
@@ -2274,22 +2294,30 @@ impl FolderScanner {
|
||||
len
|
||||
} else {
|
||||
wait_for_scanner_io(&ctx, self.sleeper.admit_disk_io(1, 0)).await?;
|
||||
wait_for_scanner_io(&ctx, tokio::fs::metadata(&item.path))
|
||||
.await?
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0)
|
||||
wait_for_scanner_io(
|
||||
&ctx,
|
||||
run_scanner_node_operation(self.node_admission.as_ref(), &ctx, tokio::fs::metadata(&item.path)),
|
||||
)
|
||||
.await??
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
wait_for_scanner_io(&ctx, self.sleeper.admit_disk_io(1, budgeted_bytes)).await?;
|
||||
|
||||
self.object_metadata_visits.increment(1);
|
||||
let sz = match self
|
||||
.local_disk
|
||||
.get_size_with_tier_names(item.clone(), &self.tier_registry.names)
|
||||
.await
|
||||
{
|
||||
let size_result = run_scanner_node_operation(self.node_admission.as_ref(), &ctx, async {
|
||||
self.object_metadata_visits.increment(1);
|
||||
self.local_disk
|
||||
.get_size_with_tier_names(item.clone(), &self.tier_registry.names)
|
||||
.await
|
||||
})
|
||||
.await?;
|
||||
if self.node_admission.is_some() && ctx.is_cancelled() {
|
||||
return Err(ScannerError::Other("Operation cancelled".to_string()));
|
||||
}
|
||||
let sz = match size_result {
|
||||
Ok(sz) => sz,
|
||||
Err(e) => {
|
||||
let failure_action = classify_get_size_failure(&item, &e);
|
||||
@@ -2606,7 +2634,7 @@ impl FolderScanner {
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => {
|
||||
if ctx.is_cancelled() {
|
||||
if ctx.is_cancelled() || matches!(&e, ScannerError::ResourceDeferred) {
|
||||
self.preserve_partial_child_progress(&folder_item.parent, &h, into, &dst)
|
||||
.await;
|
||||
return Err(e);
|
||||
@@ -2847,19 +2875,23 @@ impl FolderScanner {
|
||||
let mut seen_truncated_objects: HashSet<String> = HashSet::new();
|
||||
|
||||
loop {
|
||||
if agreed_closed && partial_closed && finished_closed {
|
||||
if child_ctx.is_cancelled() || (agreed_closed && partial_closed && finished_closed) {
|
||||
break;
|
||||
}
|
||||
#[cfg(test)]
|
||||
let defer_entries = self.defer_abandoned_entries_until_finished && !finished_closed;
|
||||
#[cfg(not(test))]
|
||||
let defer_entries = false;
|
||||
|
||||
select! {
|
||||
entry_name = agreed_rx.recv(), if !agreed_closed => {
|
||||
entry_name = agreed_rx.recv(), if !agreed_closed && !defer_entries => {
|
||||
let Some(entry_name) = entry_name else {
|
||||
agreed_closed = true;
|
||||
continue;
|
||||
};
|
||||
(self.update_current_path)(&entry_name).await;
|
||||
}
|
||||
entries = partial_rx.recv(), if !partial_closed => {
|
||||
entries = partial_rx.recv(), if !partial_closed && !defer_entries => {
|
||||
let Some(entries) = entries else {
|
||||
partial_closed = true;
|
||||
continue;
|
||||
@@ -2985,6 +3017,7 @@ impl FolderScanner {
|
||||
finished_closed = true;
|
||||
continue;
|
||||
};
|
||||
finished_closed = true;
|
||||
if disk_errors_are_only_missing_paths(&errs) {
|
||||
debug!(
|
||||
target: "rustfs::scanner::folder",
|
||||
@@ -3006,7 +3039,9 @@ impl FolderScanner {
|
||||
"Scanner list_path_raw finished with disk errors"
|
||||
);
|
||||
}
|
||||
child_ctx.cancel();
|
||||
// The producer awaited callback sends, not their
|
||||
// consumption. Drain the remaining candidate and
|
||||
// agreed frames before closing this child walk.
|
||||
}
|
||||
_ = child_ctx.cancelled() => {
|
||||
break;
|
||||
@@ -3024,7 +3059,10 @@ impl FolderScanner {
|
||||
if into.compacted {
|
||||
// In compacted mode child totals are accumulated directly into the parent entry.
|
||||
let fut = Box::pin(self.scan_folder(ctx.clone(), folder_item.clone(), into));
|
||||
fut.await.map_err(|e| ScannerError::Other(e.to_string()))?;
|
||||
fut.await.map_err(|error| match error {
|
||||
ScannerError::ResourceDeferred => ScannerError::ResourceDeferred,
|
||||
other => ScannerError::Other(other.to_string()),
|
||||
})?;
|
||||
self.send_update_for_entry(&this_hash, &folder.parent, into).await;
|
||||
tokio::task::yield_now().await;
|
||||
} else {
|
||||
@@ -3043,7 +3081,7 @@ impl FolderScanner {
|
||||
)
|
||||
.await
|
||||
{
|
||||
if ctx.is_cancelled() {
|
||||
if ctx.is_cancelled() || matches!(&e, ScannerError::ResourceDeferred) {
|
||||
self.preserve_partial_child_progress(&folder_item.parent, &h, into, &dst)
|
||||
.await;
|
||||
return Err(e);
|
||||
@@ -3217,6 +3255,7 @@ pub(crate) async fn scan_data_folder_scoped(
|
||||
failed_object_paths_seen: HashSet::new(),
|
||||
resolved_failed_object_paths: HashSet::new(),
|
||||
sleeper,
|
||||
node_admission: crate::workload_admission::scanner_node_admission(),
|
||||
disks,
|
||||
disks_quorum,
|
||||
usage_updates,
|
||||
@@ -3244,6 +3283,8 @@ pub(crate) async fn scan_data_folder_scoped(
|
||||
pending_size_reconciliation_truncated: false,
|
||||
#[cfg(test)]
|
||||
list_path_raw_options_observer: None,
|
||||
#[cfg(test)]
|
||||
defer_abandoned_entries_until_finished: false,
|
||||
};
|
||||
|
||||
let now = FolderScanner::now_secs();
|
||||
@@ -3333,7 +3374,7 @@ pub(crate) async fn scan_data_folder_scoped(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if ctx.is_cancelled() {
|
||||
if ctx.is_cancelled() || matches!(&e, ScannerError::ResourceDeferred) {
|
||||
let root_hash = hash_path(&bucket_name);
|
||||
let root_has_progress = data_usage_root_has_progress(&root);
|
||||
let pending_heals_changed = scanner.pending_heals_changed;
|
||||
@@ -3408,6 +3449,19 @@ pub(crate) async fn scan_data_folder_scoped(
|
||||
close_disk_guard.close().await;
|
||||
return Err(ScannerError::NamespaceNotFoundCache(Box::new(partial_cache)));
|
||||
}
|
||||
if matches!(&e, ScannerError::ResourceDeferred) {
|
||||
// Admission pressure is not disk failure or an empty census.
|
||||
// Keep the previous tree when no new useful progress exists.
|
||||
let mut partial_cache = scanner.old_cache;
|
||||
if partial_cache.cache.is_empty() {
|
||||
partial_cache
|
||||
.cache
|
||||
.insert(hash_path(&bucket_name).0, DataUsageEntry::default());
|
||||
}
|
||||
partial_cache.info.snapshot_complete = false;
|
||||
close_disk_guard.close().await;
|
||||
return Err(ScannerError::PartialCache(Box::new(partial_cache)));
|
||||
}
|
||||
close_disk_guard.close().await;
|
||||
// No useful information, return original cache
|
||||
Err(e)
|
||||
@@ -3427,3 +3481,15 @@ pub(crate) use tests::checkpoint_fixture::run_checkpoint_fixture;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
pub(crate) static TEST_SIZE_DISPATCH_BOUNDARIES: tokio::sync::mpsc::UnboundedSender<&'static str>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn observe_test_size_dispatch_boundary(stage: &'static str) -> bool {
|
||||
TEST_SIZE_DISPATCH_BOUNDARIES
|
||||
.try_with(|sender| sender.send(stage).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -628,6 +628,22 @@ impl ScannerItem {
|
||||
tier_names: &[String],
|
||||
size_summary: &mut SizeSummary,
|
||||
) {
|
||||
self.apply_actions_cancellable(object_infos, lock_retention, versioning_config, tier_names, size_summary, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_actions_cancellable(
|
||||
&mut self,
|
||||
object_infos: Vec<ObjectInfo>,
|
||||
lock_retention: Option<Arc<ObjectLockConfiguration>>,
|
||||
versioning_config: VersioningConfiguration,
|
||||
tier_names: &[String],
|
||||
size_summary: &mut SizeSummary,
|
||||
cancellation: Option<&CancellationToken>,
|
||||
) {
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return;
|
||||
}
|
||||
let object_path = self.object_path();
|
||||
if object_infos.is_empty() {
|
||||
debug!(
|
||||
@@ -671,6 +687,9 @@ impl ScannerItem {
|
||||
let Some(lifecycle) = self.lifecycle.clone() else {
|
||||
let mut cumulative_size: i64 = 0;
|
||||
for (oi, resolved_size) in object_infos.iter().zip(resolved_sizes.iter()) {
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return;
|
||||
}
|
||||
let accounting_size = match resolved_size {
|
||||
SizeResolution::Known { logical, .. } => *logical,
|
||||
// A valid compressed legacy sentinel has no logical size,
|
||||
@@ -678,7 +697,7 @@ impl ScannerItem {
|
||||
// physical size is only an input to those operations; it
|
||||
// is not folded into the logical total below.
|
||||
SizeResolution::Unknown { physical, .. } => {
|
||||
self.heal_actions(oi, *physical, size_summary).await;
|
||||
self.heal_actions(oi, *physical, size_summary, cancellation).await;
|
||||
size_summary.actions_accounting_unknown(oi);
|
||||
continue;
|
||||
}
|
||||
@@ -688,13 +707,16 @@ impl ScannerItem {
|
||||
}
|
||||
};
|
||||
|
||||
let size = self.heal_actions(oi, accounting_size, size_summary).await;
|
||||
let size = self.heal_actions(oi, accounting_size, size_summary, cancellation).await;
|
||||
|
||||
size_summary.actions_accounting(oi, size, accounting_size);
|
||||
|
||||
cumulative_size = cumulative_size.saturating_add(size);
|
||||
}
|
||||
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return;
|
||||
}
|
||||
self.alert_excessive_versions(object_infos.len(), cumulative_size);
|
||||
|
||||
debug!(
|
||||
@@ -736,6 +758,10 @@ impl ScannerItem {
|
||||
}
|
||||
};
|
||||
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Every version handed to the evaluator counts as an ILM-checked
|
||||
// version, whether or not a rule matched (NoneAction included). This is
|
||||
// reached only for buckets with lifecycle rules; it feeds
|
||||
@@ -754,6 +780,9 @@ impl ScannerItem {
|
||||
.all(|candidate| Self::tier_is_known(candidate, tier_names));
|
||||
'eventLoop: {
|
||||
for (i, event) in events.iter().enumerate() {
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return;
|
||||
}
|
||||
let oi = &object_infos[i];
|
||||
let known_size = resolved_sizes[i].known_size();
|
||||
if has_corrupt_size
|
||||
@@ -772,7 +801,7 @@ impl ScannerItem {
|
||||
// accounting. Size-filtered or deferred events remain
|
||||
// pending, so retain the version-only physical counters.
|
||||
if let SizeResolution::Unknown { physical, .. } = &resolved_sizes[i] {
|
||||
self.heal_actions(oi, *physical, size_summary).await;
|
||||
self.heal_actions(oi, *physical, size_summary, cancellation).await;
|
||||
size_summary.actions_accounting_unknown(oi);
|
||||
}
|
||||
continue;
|
||||
@@ -829,7 +858,7 @@ impl ScannerItem {
|
||||
noncurrent_unknown.push(oi);
|
||||
} else {
|
||||
if let SizeResolution::Unknown { physical, .. } = &resolved_sizes[i] {
|
||||
self.heal_actions(oi, *physical, size_summary).await;
|
||||
self.heal_actions(oi, *physical, size_summary, cancellation).await;
|
||||
}
|
||||
size_summary.actions_accounting_unknown(oi);
|
||||
}
|
||||
@@ -846,7 +875,7 @@ impl ScannerItem {
|
||||
}
|
||||
IlmAction::NoneAction | IlmAction::ActionCount => {
|
||||
if let SizeResolution::Unknown { physical, .. } = &resolved_sizes[i] {
|
||||
self.heal_actions(oi, *physical, size_summary).await;
|
||||
self.heal_actions(oi, *physical, size_summary, cancellation).await;
|
||||
}
|
||||
size_summary.actions_accounting_unknown(oi);
|
||||
}
|
||||
@@ -864,7 +893,7 @@ impl ScannerItem {
|
||||
// side-effecting tier actions fail closed until the registry
|
||||
// recognizes the source again.
|
||||
if Self::action_blocked_by_unknown_tier(event.action, oi, all_versions_known, tier_names, &event.storage_class) {
|
||||
size = self.heal_actions(oi, actual_size, size_summary).await;
|
||||
size = self.heal_actions(oi, actual_size, size_summary, cancellation).await;
|
||||
size_summary.actions_accounting(oi, size, actual_size);
|
||||
cumulative_size += size;
|
||||
continue;
|
||||
@@ -973,7 +1002,7 @@ impl ScannerItem {
|
||||
}
|
||||
|
||||
IlmAction::NoneAction | IlmAction::ActionCount => {
|
||||
size = self.heal_actions(oi, actual_size, size_summary).await;
|
||||
size = self.heal_actions(oi, actual_size, size_summary, cancellation).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,6 +1013,9 @@ impl ScannerItem {
|
||||
}
|
||||
}
|
||||
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return;
|
||||
}
|
||||
if !to_delete_objs.is_empty()
|
||||
&& let Some(event) = noncurrent_events.first().cloned()
|
||||
{
|
||||
@@ -991,7 +1023,8 @@ impl ScannerItem {
|
||||
let count = u64::try_from(to_delete_objs.len()).unwrap_or(u64::MAX);
|
||||
let done_ilm = Metrics::time_ilm(action);
|
||||
let trace_started_at = trace_start_instant();
|
||||
let queued = enqueue_runtime_newer_noncurrent(&self.bucket, to_delete_objs, event, &LcEventSrc::Scanner).await;
|
||||
let queued =
|
||||
enqueue_runtime_newer_noncurrent(&self.bucket, to_delete_objs, event, &LcEventSrc::Scanner, cancellation).await;
|
||||
if let Some(trace_started_at) = trace_started_at {
|
||||
let state = if queued { "queued" } else { "not_queued" };
|
||||
trace_emit(|| {
|
||||
@@ -1022,15 +1055,28 @@ impl ScannerItem {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.alert_excessive_versions(remaining_versions, cumulative_size);
|
||||
if !cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
self.alert_excessive_versions(remaining_versions, cumulative_size);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn heal_actions(&mut self, oi: &ObjectInfo, actual_size: i64, size_summary: &mut SizeSummary) -> i64 {
|
||||
pub(super) async fn heal_actions(
|
||||
&mut self,
|
||||
oi: &ObjectInfo,
|
||||
actual_size: i64,
|
||||
size_summary: &mut SizeSummary,
|
||||
cancellation: Option<&CancellationToken>,
|
||||
) -> i64 {
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return actual_size;
|
||||
}
|
||||
if self.heal_enabled {
|
||||
self.enqueue_heal(oi).await;
|
||||
}
|
||||
|
||||
self.heal_replication(oi, size_summary).await;
|
||||
if !cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
self.heal_replication(oi, size_summary).await;
|
||||
}
|
||||
|
||||
actual_size
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ use std::sync::Mutex;
|
||||
pub(super) mod checkpoint_fixture;
|
||||
pub(super) mod enumeration_restart;
|
||||
mod incremental_enumeration;
|
||||
mod listing_completion;
|
||||
mod metadata_visits;
|
||||
mod node_admission;
|
||||
mod usage_updates;
|
||||
|
||||
/// Reset the process-global alert cooldown map; test-only.
|
||||
@@ -364,6 +366,7 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
|
||||
failed_object_paths_seen: HashSet::new(),
|
||||
resolved_failed_object_paths: HashSet::new(),
|
||||
sleeper: SCANNER_SLEEPER.clone(),
|
||||
node_admission: None,
|
||||
disks: Vec::new(),
|
||||
disks_quorum: 0,
|
||||
usage_updates: None,
|
||||
@@ -394,6 +397,7 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
|
||||
pending_size_reconciliation_scopes: HashSet::new(),
|
||||
pending_size_reconciliation_truncated: false,
|
||||
list_path_raw_options_observer: None,
|
||||
defer_abandoned_entries_until_finished: false,
|
||||
};
|
||||
|
||||
(scanner, temp_dir)
|
||||
@@ -2262,7 +2266,7 @@ async fn test_heal_actions_returns_actual_size_without_inline_heal() {
|
||||
};
|
||||
let mut size_summary = SizeSummary::default();
|
||||
|
||||
let size = item.heal_actions(&object_info, 123, &mut size_summary).await;
|
||||
let size = item.heal_actions(&object_info, 123, &mut size_summary, None).await;
|
||||
assert_eq!(size, 123);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn abandoned_listing_drains_candidate_buffered_before_finished() {
|
||||
let (mut scanner, root) = build_test_scanner().await;
|
||||
let _guard = TestGuard {
|
||||
temp_dir: Some(root.clone()),
|
||||
};
|
||||
let bucket = "bucket";
|
||||
let object = "abandoned/object";
|
||||
tokio::fs::create_dir_all(root.join(bucket)).await.expect("local bucket");
|
||||
let mut disks = vec![scanner.local_disk.clone()];
|
||||
let mut original_metadata = Vec::new();
|
||||
for (index, version) in [(2, 0x1935), (3, 0x1935), (4, 0x1934)] {
|
||||
let disk_root = root.join(format!("disk-{index}"));
|
||||
let metadata = metadata_for_object_version(bucket, object, Some(Uuid::from_u128(version)));
|
||||
write_test_object_metadata_bytes(&disk_root, bucket, object, &metadata).await;
|
||||
original_metadata.push((disk_root.join(bucket).join(object).join(STORAGE_FORMAT_FILE), metadata));
|
||||
let endpoint = Endpoint::try_from(disk_root.to_string_lossy().as_ref()).expect("listing fixture endpoint");
|
||||
disks.push(
|
||||
new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("listing fixture disk"),
|
||||
);
|
||||
}
|
||||
for cache in [&mut scanner.old_cache, &mut scanner.new_cache] {
|
||||
cache.info.name = bucket.to_owned();
|
||||
}
|
||||
scanner.old_cache.replace(
|
||||
"bucket/abandoned/object",
|
||||
bucket,
|
||||
DataUsageEntry {
|
||||
objects: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
scanner.heal_object_select = 1;
|
||||
scanner.disks = disks;
|
||||
scanner.disks_quorum = 2;
|
||||
scanner.sleeper = DynamicSleeper::new(rustfs_config::ScannerSpeed::Fastest);
|
||||
// Force the valid select ordering that receives terminal errors before the
|
||||
// final candidate. One candidate frame fits in the bounded partial channel.
|
||||
scanner.defer_abandoned_entries_until_finished = true;
|
||||
let (_commands, mut heal_rx) = rustfs_heal_contracts::heal_channel::init_heal_channels().expect("isolated heal channel");
|
||||
let (captured_tx, mut captured_rx) = mpsc::unbounded_channel();
|
||||
let responder = tokio::spawn(async move {
|
||||
while let Some(command) = heal_rx.recv().await {
|
||||
let rustfs_heal_contracts::heal_channel::HealReceiptCommand { request, response_tx } = command;
|
||||
captured_tx
|
||||
.send((request.object_prefix.clone(), request.object_version_id.clone()))
|
||||
.expect("candidate capture remains open");
|
||||
let _ = response_tx.send(Ok(rustfs_heal_contracts::heal_channel::HealAdmissionReceipt {
|
||||
result: HealAdmissionResult::Accepted,
|
||||
task_id: request.id,
|
||||
}));
|
||||
}
|
||||
});
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
scanner.scan_folder(
|
||||
CancellationToken::new(),
|
||||
CachedFolder {
|
||||
name: bucket.to_owned(),
|
||||
parent: None,
|
||||
object_heal_prob_div: 1,
|
||||
},
|
||||
&mut DataUsageEntry::default(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
responder.abort();
|
||||
result
|
||||
.expect("listing completion must close every callback channel")
|
||||
.expect("complete folder scan");
|
||||
let mut candidates = HashSet::new();
|
||||
while let Ok((object, version)) = captured_rx.try_recv() {
|
||||
if let (Some(object), Some(version)) = (object, version) {
|
||||
candidates.insert((object, version));
|
||||
}
|
||||
}
|
||||
let expected = [0x1934, 0x1935]
|
||||
.into_iter()
|
||||
// Raw base-object listings include the trailing separator. Preserve
|
||||
// the producer's candidate identity when draining its callback frame.
|
||||
.map(|version| (format!("{object}/"), Uuid::from_u128(version).to_string()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
candidates, expected,
|
||||
"terminal disk errors must not discard buffered exact-version candidates"
|
||||
);
|
||||
for (path, metadata) in original_metadata {
|
||||
assert_eq!(tokio::fs::read(path).await.expect("retained metadata"), metadata);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,12 +172,55 @@ fn record_checkpoint_save_failure(cache_name: &str, checkpoint: &DataUsageCache,
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScannerCacheLockGuards {
|
||||
scoped: NamespaceLockGuard,
|
||||
scoped: Option<NamespaceLockGuard>,
|
||||
}
|
||||
|
||||
impl ScannerCacheLockGuards {
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
self.scoped.is_lock_lost()
|
||||
self.scoped.as_ref().is_none_or(NamespaceLockGuard::is_lock_lost)
|
||||
}
|
||||
|
||||
/// Suspend the same traversal while yielding its cache lease. Lock order on
|
||||
/// resumption is node admission -> cache lease -> cache revision reads.
|
||||
pub(crate) async fn resume_node_operation(
|
||||
&mut self,
|
||||
store: Arc<SetDisks>,
|
||||
cache_name: &str,
|
||||
source: DataUsageCacheSource,
|
||||
revisions: &DataUsageCacheRevisions,
|
||||
request: crate::workload_admission::ScannerNodeRequest,
|
||||
) -> Result<()> {
|
||||
if self.is_lock_lost() {
|
||||
return Err(Error::other("scanner cache lock was lost before node admission"));
|
||||
}
|
||||
if request.respond.is_closed() {
|
||||
return Ok(());
|
||||
}
|
||||
let crate::workload_admission::ScannerNodeRequest { owner, respond, cancel } = request;
|
||||
drop(self.scoped.take());
|
||||
let resume = async {
|
||||
let permit = owner.acquire().await;
|
||||
*self = acquire_scanner_cache_locks(store.as_ref(), cache_name, source)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
let current = crate::storage_api::ecstore_with_background_disk_io(
|
||||
cancel.clone(),
|
||||
DataUsageCache::read_revisions(store, cache_name),
|
||||
)
|
||||
.await?;
|
||||
if self.is_lock_lost() || ¤t != revisions {
|
||||
return Err(Error::other("scanner cache changed while waiting for node admission"));
|
||||
}
|
||||
// If the walk was dropped, send returns ownership and drops the
|
||||
// permit without recording completed work.
|
||||
let _ = respond.send(permit);
|
||||
Ok(())
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => Err(Error::other("scanner node admission cancelled")),
|
||||
result = resume => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +258,15 @@ impl std::fmt::Display for ScannerCacheLockError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ScannerCacheLockError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Create { source, .. } => Some(source),
|
||||
Self::Acquire { source, .. } => Some(source),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_scanner_cache_locks(
|
||||
store: &SetDisks,
|
||||
cache_name: &str,
|
||||
@@ -237,7 +289,7 @@ pub(crate) async fn acquire_scanner_cache_locks(
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(ScannerCacheLockGuards { scoped })
|
||||
Ok(ScannerCacheLockGuards { scoped: Some(scoped) })
|
||||
}
|
||||
|
||||
pub(super) async fn await_scanner_disk_shutdown<F>(scan: Pin<&mut F>)
|
||||
@@ -688,6 +740,15 @@ pub(super) fn completed_data_usage_info(
|
||||
.map(|candidate| (candidate.data, candidate.last_update))
|
||||
}
|
||||
|
||||
/// Certify an incarnation only when every contributing set reports the same non-nil identity.
|
||||
fn certified_bucket_incarnation(results: &[DataUsageCache], bucket: &str) -> Option<uuid::Uuid> {
|
||||
let mut identities = results
|
||||
.iter()
|
||||
.map(|result| result.info.scan_bucket_incarnations.get(bucket).copied());
|
||||
let first = identities.next().flatten().filter(|id| !id.is_nil())?;
|
||||
identities.all(|id| id == Some(first)).then_some(first)
|
||||
}
|
||||
|
||||
pub(super) fn completed_usage_candidate(
|
||||
results: &[DataUsageCache],
|
||||
scope: &ScannerSnapshotScope<'_>,
|
||||
@@ -740,7 +801,11 @@ pub(super) fn completed_usage_candidate(
|
||||
let merged_last_update = validated.last_update;
|
||||
let buckets_usage = bucket_entries
|
||||
.into_iter()
|
||||
.map(|(bucket, entry)| Ok((bucket, checked_bucket_usage_info(entry).ok_or(CandidateOutcome::UsageCounters)?)))
|
||||
.map(|(bucket, entry)| {
|
||||
let mut usage = checked_bucket_usage_info(entry).ok_or(CandidateOutcome::UsageCounters)?;
|
||||
usage.bucket_incarnation = certified_bucket_incarnation(results, &bucket).map(|id| id.to_string());
|
||||
Ok((bucket, usage))
|
||||
})
|
||||
.collect::<std::result::Result<HashMap<_, _>, CandidateOutcome>>()?;
|
||||
let bucket_sizes = buckets_usage
|
||||
.iter()
|
||||
@@ -1098,6 +1163,24 @@ pub(super) async fn send_cache_root_entry(
|
||||
.map_err(|err| ScannerError::Other(format!("scanner cache root channel closed: {err}")))
|
||||
}
|
||||
|
||||
fn log_missing_set_snapshot_metadata(store: &SetDisks, info: &DataUsageCacheInfo, reason: &'static str) {
|
||||
warn!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "snapshot_metadata_missing",
|
||||
reason,
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = info.source.map(|source| source.pool_index),
|
||||
snapshot_source_set = info.source.map(|source| source.set_index),
|
||||
snapshot_cycle = info.next_cycle,
|
||||
snapshot_leader_epoch = info.leader_epoch,
|
||||
"Scanner set snapshot is missing required metadata"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
store: Arc<SetDisks>,
|
||||
updates: &mpsc::Sender<DataUsageCache>,
|
||||
@@ -1106,9 +1189,20 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
cache_cycle_floor: &AtomicU64,
|
||||
expected_publication_epoch: u64,
|
||||
) -> Option<SystemTime> {
|
||||
let source = cache_snapshot.info.source?;
|
||||
let coverage_digest = cache_snapshot.info.scan_coverage_digest?;
|
||||
let execution_digest = cache_snapshot.info.scan_execution_digest?;
|
||||
let snapshot_cycle = cache_snapshot.info.next_cycle;
|
||||
let snapshot_leader_epoch = cache_snapshot.info.leader_epoch;
|
||||
let Some(source) = cache_snapshot.info.source else {
|
||||
log_missing_set_snapshot_metadata(&store, &cache_snapshot.info, "source");
|
||||
return None;
|
||||
};
|
||||
let Some(coverage_digest) = cache_snapshot.info.scan_coverage_digest else {
|
||||
log_missing_set_snapshot_metadata(&store, &cache_snapshot.info, "coverage_digest");
|
||||
return None;
|
||||
};
|
||||
let Some(execution_digest) = cache_snapshot.info.scan_execution_digest else {
|
||||
log_missing_set_snapshot_metadata(&store, &cache_snapshot.info, "execution_digest");
|
||||
return None;
|
||||
};
|
||||
let guard = match acquire_scanner_cache_locks(store.as_ref(), DATA_USAGE_CACHE_NAME, source).await {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
@@ -1117,8 +1211,14 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = err.state(),
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
error = %err,
|
||||
"Scanner cache snapshot lock acquisition failed"
|
||||
);
|
||||
@@ -1135,15 +1235,24 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "load_or_revision_lookup_failed",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
error = %err,
|
||||
"Scanner cache snapshot load or revision lookup failed"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let scan_plan_digest = cache_snapshot.info.scan_plan_digest?;
|
||||
let Some(scan_plan_digest) = cache_snapshot.info.scan_plan_digest else {
|
||||
log_missing_set_snapshot_metadata(&store, &cache_snapshot.info, "plan_digest");
|
||||
return None;
|
||||
};
|
||||
if persisted.info.next_cycle > cache_snapshot.info.next_cycle {
|
||||
cache_cycle_floor.fetch_max(persisted.info.next_cycle, Ordering::AcqRel);
|
||||
warn!(
|
||||
@@ -1151,10 +1260,16 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "stale_cycle_rejected",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
requested_cycle = cache_snapshot.info.next_cycle,
|
||||
persisted_cycle = persisted.info.next_cycle,
|
||||
state = "stale_cycle_rejected",
|
||||
"Scanner rejected a set cache cycle regression"
|
||||
);
|
||||
return None;
|
||||
@@ -1165,10 +1280,16 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "stale_leader_rejected",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
requested_epoch = cache_snapshot.info.leader_epoch,
|
||||
persisted_epoch = persisted.info.leader_epoch,
|
||||
state = "stale_leader_rejected",
|
||||
"Scanner rejected a set cache snapshot from an older leader epoch"
|
||||
);
|
||||
return None;
|
||||
@@ -1199,6 +1320,12 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "scan_baseline_revision_changed",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
"Scanner skipped set snapshot without an unchanged baseline revision"
|
||||
);
|
||||
@@ -1210,8 +1337,14 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "lock_lost",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
"Scanner cache snapshot save skipped after lock loss"
|
||||
);
|
||||
return None;
|
||||
@@ -1227,8 +1360,14 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "save_failed",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
error = %e,
|
||||
"Scanner cache snapshot persistence failed"
|
||||
);
|
||||
@@ -1243,8 +1382,14 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "lock_lost_after_save",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
"Scanner cache snapshot publish skipped after lock loss"
|
||||
);
|
||||
return None;
|
||||
@@ -1261,8 +1406,14 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "publication_epoch_changed_before_publish",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
"Scanner cache root publish skipped after movement epoch change"
|
||||
);
|
||||
return None;
|
||||
@@ -1276,8 +1427,14 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "publish_failed",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
error = %e,
|
||||
"Scanner cache snapshot publish failed"
|
||||
);
|
||||
|
||||
@@ -238,6 +238,71 @@ pub enum ScannerDurableDirtyUsageReplayScope {
|
||||
TopLevelEntries { entries: BTreeSet<String> },
|
||||
}
|
||||
|
||||
impl ScannerDurableDirtyUsageReplayRecord {
|
||||
/// Replays decoded producer hints with the canonical byte limit and semantic checks.
|
||||
///
|
||||
/// Callers must retain their framing and owner checks. This performs the same
|
||||
/// state update as [`replay_durable_dirty_usage_producer_record`]; it does not
|
||||
/// establish storage-verified source authority.
|
||||
pub fn replay(self) -> std::result::Result<ScannerDirtyUsageState, ScannerDurableDirtyUsageReplayError> {
|
||||
check_durable_dirty_usage_replay_bytes(&self)?;
|
||||
replay_decoded_dirty_usage_record(&self)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplayByteLimit {
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
impl std::io::Write for ReplayByteLimit {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.remaining = self
|
||||
.remaining
|
||||
.checked_sub(bytes.len())
|
||||
.ok_or_else(|| std::io::Error::other("dirty replay byte limit"))?;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_durable_dirty_usage_replay_bytes(
|
||||
record: &ScannerDurableDirtyUsageReplayRecord,
|
||||
) -> std::result::Result<(), ScannerDurableDirtyUsageReplayError> {
|
||||
// Bound structure traversal and string scanning before counting escaped JSON.
|
||||
// Each entry or prefix contributes at least one additional serialized byte.
|
||||
let mut remaining = SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES;
|
||||
let mut charge = |size: usize| -> std::result::Result<(), ScannerDurableDirtyUsageReplayError> {
|
||||
remaining = remaining
|
||||
.checked_sub(size)
|
||||
.ok_or(ScannerDurableDirtyUsageReplayError::ByteLimit)?;
|
||||
Ok(())
|
||||
};
|
||||
charge(record.writer_epoch.len())?;
|
||||
charge(record.entries.len())?;
|
||||
for entry in &record.entries {
|
||||
charge(entry.bucket.len())?;
|
||||
if let ScannerDurableDirtyUsageReplayScope::TopLevelEntries { entries } = &entry.scope {
|
||||
charge(entries.len())?;
|
||||
for entry in entries {
|
||||
charge(entry.len())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut limit = ReplayByteLimit {
|
||||
remaining: SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES,
|
||||
};
|
||||
serde_json::to_writer(&mut limit, record).map_err(|error| {
|
||||
if error.is_io() {
|
||||
ScannerDurableDirtyUsageReplayError::ByteLimit
|
||||
} else {
|
||||
ScannerDurableDirtyUsageReplayError::InvalidJson
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_durable_dirty_usage_producer_replay_record(
|
||||
entries: Vec<ScannerDurableDirtyUsageReplayEntry>,
|
||||
) -> std::result::Result<Vec<u8>, ScannerDurableDirtyUsageReplayError> {
|
||||
@@ -263,7 +328,13 @@ pub fn replay_durable_dirty_usage_producer_record(
|
||||
}
|
||||
let record: ScannerDurableDirtyUsageReplayRecord =
|
||||
serde_json::from_slice(bytes).map_err(|_| ScannerDurableDirtyUsageReplayError::InvalidJson)?;
|
||||
let replay = validate_durable_dirty_usage_replay_record(&record)?;
|
||||
replay_decoded_dirty_usage_record(&record)
|
||||
}
|
||||
|
||||
fn replay_decoded_dirty_usage_record(
|
||||
record: &ScannerDurableDirtyUsageReplayRecord,
|
||||
) -> std::result::Result<ScannerDirtyUsageState, ScannerDurableDirtyUsageReplayError> {
|
||||
let replay = validate_durable_dirty_usage_replay_record(record)?;
|
||||
let max_generation = replay.iter().map(|entry| entry.generation).max().unwrap_or(0);
|
||||
set_dirty_usage_generation_floor(max_generation)?;
|
||||
let pending = {
|
||||
@@ -772,6 +843,109 @@ mod scoped_dirty_usage_tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn decoded_replay_fixture() -> ScannerDurableDirtyUsageReplayRecord {
|
||||
ScannerDurableDirtyUsageReplayRecord {
|
||||
schema: SCANNER_DURABLE_DIRTY_USAGE_REPLAY_SCHEMA,
|
||||
cache_key_format: crate::DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
writer_epoch: "writer".to_string(),
|
||||
entries: vec![ScannerDurableDirtyUsageReplayEntry {
|
||||
bucket: "photos".to_string(),
|
||||
generation: 7,
|
||||
scope: ScannerDurableDirtyUsageReplayScope::TopLevelEntries {
|
||||
entries: BTreeSet::from(["2026".to_string(), "archive".to_string()]),
|
||||
},
|
||||
producers: SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION.into_iter().collect(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn durable_dirty_usage_decoded_replay_matches_byte_replay_at_size_boundary() {
|
||||
let record = decoded_replay_fixture();
|
||||
let fixed_bytes = serde_json::to_vec(&record).expect("fixture encoding").len() - record.writer_epoch.len();
|
||||
for size in [
|
||||
SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES - 1,
|
||||
SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES,
|
||||
SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES + 1,
|
||||
] {
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let mut record = record.clone();
|
||||
record.writer_epoch = "x".repeat(size - fixed_bytes);
|
||||
let bytes = serde_json::to_vec(&record).expect("bounded fixture encoding");
|
||||
assert_eq!(bytes.len(), size);
|
||||
let expected = replay_durable_dirty_usage_producer_record(&bytes);
|
||||
let buckets = dirty_usage_buckets_for_tests();
|
||||
let scopes = dirty_usage_bucket_scopes_for_tests();
|
||||
let identities = dirty_usage_producer_identities().clone();
|
||||
let coverage = DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire);
|
||||
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
assert_eq!(record.replay(), expected, "canonical size {size}");
|
||||
assert_eq!(dirty_usage_buckets_for_tests(), buckets);
|
||||
assert_eq!(dirty_usage_bucket_scopes_for_tests(), scopes);
|
||||
assert_eq!(*dirty_usage_producer_identities(), identities);
|
||||
assert_eq!(DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire), coverage);
|
||||
}
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_dirty_usage_decoded_size_counts_escaped_utf8() {
|
||||
for repeats in [6000, 8000] {
|
||||
let mut record = decoded_replay_fixture();
|
||||
record.writer_epoch = "\"\n雪\\".repeat(repeats);
|
||||
let bytes = serde_json::to_vec(&record).expect("escaped fixture encoding");
|
||||
let expected = if bytes.len() > SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES {
|
||||
Err(ScannerDurableDirtyUsageReplayError::ByteLimit)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
assert_eq!(check_durable_dirty_usage_replay_bytes(&record), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn durable_dirty_usage_decoded_replay_rejects_large_values_before_state_changes() {
|
||||
for field in ["epoch", "prefix", "entries"] {
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let before = scanner_dirty_usage_state();
|
||||
let mut record = decoded_replay_fixture();
|
||||
match field {
|
||||
"epoch" => record.writer_epoch = "x".repeat(1024 * 1024),
|
||||
"prefix" => {
|
||||
record.entries[0].scope = ScannerDurableDirtyUsageReplayScope::TopLevelEntries {
|
||||
entries: BTreeSet::from(["x".repeat(1024 * 1024)]),
|
||||
};
|
||||
}
|
||||
_ => record.entries = vec![record.entries[0].clone(); SCANNER_DURABLE_DIRTY_USAGE_REPLAY_MAX_BYTES + 1],
|
||||
}
|
||||
assert_eq!(record.replay(), Err(ScannerDurableDirtyUsageReplayError::ByteLimit), "{field}");
|
||||
assert_eq!(scanner_dirty_usage_state(), before);
|
||||
assert!(dirty_usage_buckets_for_tests().is_empty());
|
||||
assert!(dirty_usage_bucket_scopes_for_tests().is_empty());
|
||||
assert!(dirty_usage_producer_identities().is_empty());
|
||||
assert_eq!(DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire), 0);
|
||||
}
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn durable_dirty_usage_decoded_replay_preserves_generation_exhaustion() {
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let generation = DIRTY_USAGE_BUCKET_GENERATION.swap(u64::MAX, Ordering::AcqRel);
|
||||
let result = decoded_replay_fixture().replay();
|
||||
DIRTY_USAGE_BUCKET_GENERATION.store(generation, Ordering::Release);
|
||||
assert_eq!(result, Err(ScannerDurableDirtyUsageReplayError::InvalidGeneration));
|
||||
assert!(dirty_usage_buckets_for_tests().is_empty());
|
||||
assert!(dirty_usage_bucket_scopes_for_tests().is_empty());
|
||||
assert!(dirty_usage_producer_identities().is_empty());
|
||||
assert_eq!(DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire), 0);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn durable_dirty_usage_replay_rejects_invalid_records_without_partial_state() {
|
||||
@@ -843,9 +1017,17 @@ mod scoped_dirty_usage_tests {
|
||||
|
||||
for (bytes, expected_error) in invalid_cases {
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
assert_eq!(replay_durable_dirty_usage_producer_record(&bytes), Err(expected_error));
|
||||
let before = scanner_dirty_usage_state();
|
||||
assert_eq!(replay_durable_dirty_usage_producer_record(&bytes), Err(expected_error.clone()));
|
||||
assert!(dirty_usage_buckets_for_tests().is_empty());
|
||||
assert!(dirty_usage_bucket_scopes_for_tests().is_empty());
|
||||
let record: ScannerDurableDirtyUsageReplayRecord = serde_json::from_slice(&bytes).expect("typed invalid record");
|
||||
assert_eq!(record.replay(), Err(expected_error));
|
||||
assert_eq!(scanner_dirty_usage_state(), before);
|
||||
assert!(dirty_usage_buckets_for_tests().is_empty());
|
||||
assert!(dirty_usage_bucket_scopes_for_tests().is_empty());
|
||||
assert!(dirty_usage_producer_identities().is_empty());
|
||||
assert_eq!(DIRTY_USAGE_PRODUCER_COVERAGE.load(Ordering::Acquire), 0);
|
||||
}
|
||||
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
|
||||
@@ -21,15 +21,41 @@ pub(super) async fn collect_completed_bucket_results(
|
||||
) -> (DataUsageCache, HashSet<String>) {
|
||||
let mut cancelled = false;
|
||||
let mut completed = HashSet::new();
|
||||
let mut received_roots = 0usize;
|
||||
let mut duplicate_applied_roots = 0usize;
|
||||
let mut rejected_roots = 0usize;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ctx.cancelled(), if !cancelled => cancelled = true,
|
||||
result = results.recv() => {
|
||||
let Some(result) = result else {
|
||||
debug!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_SET_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "bucket_root_collector_drained",
|
||||
collector_cache_pool = cache.info.source.map(|source| source.pool_index),
|
||||
collector_cache_set = cache.info.source.map(|source| source.set_index),
|
||||
collector_cache_cycle = cache.info.next_cycle,
|
||||
collector_cache_leader_epoch = cache.info.leader_epoch,
|
||||
received_roots,
|
||||
distinct_applied_roots = completed.len(),
|
||||
duplicate_applied_roots,
|
||||
rejected_roots,
|
||||
cancellation_observed = cancelled,
|
||||
cancelled_at_drain = ctx.is_cancelled(),
|
||||
"Scanner bucket root collector drained"
|
||||
);
|
||||
return (cache, completed);
|
||||
};
|
||||
received_roots = received_roots.saturating_add(1);
|
||||
if let Some(bucket) = apply_bucket_result_to_cache(&mut cache, result, SystemTime::now()) {
|
||||
completed.insert(bucket);
|
||||
if !completed.insert(bucket) {
|
||||
duplicate_applied_roots = duplicate_applied_roots.saturating_add(1);
|
||||
}
|
||||
} else {
|
||||
rejected_roots = rejected_roots.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1064,7 +1090,7 @@ impl ScannerIOCache for SetDisks {
|
||||
|
||||
// Lock order: scanner leader fence -> set-scoped per-bucket cache lock ->
|
||||
// cache object read/write.
|
||||
let cache_guard = match acquire_scanner_cache_locks(store_clone_clone.as_ref(), &cache_name, source).await {
|
||||
let mut cache_guard = match acquire_scanner_cache_locks(store_clone_clone.as_ref(), &cache_name, source).await {
|
||||
Ok(guard) => guard,
|
||||
Err(e) => {
|
||||
if e.is_contention() {
|
||||
@@ -1294,9 +1320,10 @@ impl ScannerIOCache for SetDisks {
|
||||
);
|
||||
|
||||
let (checkpoint_tx, mut checkpoint_rx) = mpsc::channel::<DataUsageCache>(1);
|
||||
let (node_tx, mut node_rx) = crate::workload_admission::scanner_node_request_channel();
|
||||
|
||||
let scan_ctx = ctx_clone.child_token();
|
||||
let scan = disk.clone().nsscanner_disk(
|
||||
let scan = crate::workload_admission::with_scanner_node_requests(node_tx, disk.clone().nsscanner_disk(
|
||||
scan_ctx.clone(),
|
||||
budget_clone.clone(),
|
||||
set_disk_inventory_clone.as_ref().clone(),
|
||||
@@ -1307,7 +1334,7 @@ impl ScannerIOCache for SetDisks {
|
||||
prefix_scan_scope,
|
||||
checkpoint_tx: Some(checkpoint_tx),
|
||||
},
|
||||
);
|
||||
));
|
||||
tokio::pin!(scan);
|
||||
let mut lock_watch = tokio::time::interval(SCANNER_CACHE_LOCK_POLL_INTERVAL);
|
||||
lock_watch.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
@@ -1315,6 +1342,24 @@ impl ScannerIOCache for SetDisks {
|
||||
let scan_result = loop {
|
||||
tokio::select! {
|
||||
result = &mut scan => break result,
|
||||
request = async {
|
||||
match node_rx.as_mut() {
|
||||
Some(receiver) => receiver.recv().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => {
|
||||
let Some(request) = request else {
|
||||
node_rx = None;
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = cache_guard.resume_node_operation(
|
||||
store_clone_clone.clone(), &cache_name, source, &revisions, request,
|
||||
).await {
|
||||
scan_ctx.cancel();
|
||||
await_scanner_disk_shutdown(scan.as_mut()).await;
|
||||
break Err(error);
|
||||
}
|
||||
}
|
||||
_ = lock_watch.tick() => {
|
||||
if cache_guard.is_lock_lost() {
|
||||
scan_ctx.cancel();
|
||||
@@ -1807,6 +1852,7 @@ impl ScannerIOCache for SetDisks {
|
||||
pool = source.pool_index,
|
||||
set = source.set_index,
|
||||
cycle = want_cycle,
|
||||
leader_epoch,
|
||||
completed_buckets = completed_count,
|
||||
carried_buckets = carried_buckets.len(),
|
||||
total_buckets = buckets.len(),
|
||||
|
||||
@@ -38,6 +38,13 @@ pub(super) fn tier_stats_template(tier_names: &[String]) -> HashMap<String, Tier
|
||||
tier_stats
|
||||
}
|
||||
|
||||
fn ensure_scanner_node_operation_active(cancellation: Option<&CancellationToken>) -> Result<()> {
|
||||
if cancellation.is_some_and(CancellationToken::is_cancelled) {
|
||||
return Err(StorageError::other("Scanner operation cancelled"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ScannerIODisk for Disk {
|
||||
async fn get_size(&self, item: ScannerItem) -> Result<SizeSummary> {
|
||||
@@ -45,14 +52,18 @@ impl ScannerIODisk for Disk {
|
||||
}
|
||||
|
||||
async fn get_size_with_tier_names(&self, mut item: ScannerItem, tier_names: &[String]) -> Result<SizeSummary> {
|
||||
let cancellation = crate::workload_admission::scanner_node_operation_cancellation();
|
||||
let done_object = Metrics::time(Metric::ScanObject);
|
||||
|
||||
if !is_xl_meta_path(&item.path) {
|
||||
return Err(StorageError::other(SCANNER_SKIP_FILE_ERROR.to_string()));
|
||||
}
|
||||
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
let metadata_object_path = item.object_path();
|
||||
let data = match self.read_metadata(&item.bucket, &metadata_object_path).await {
|
||||
let metadata_result = self.read_metadata(&item.bucket, &metadata_object_path).await;
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
let data = match metadata_result {
|
||||
Ok(data) => data,
|
||||
Err(e) if DiskError::is_err_object_not_found(&e) || DiskError::is_err_version_not_found(&e) => {
|
||||
return Err(StorageError::other(SCANNER_SKIP_FILE_ERROR.to_string()));
|
||||
@@ -86,7 +97,10 @@ impl ScannerIODisk for Disk {
|
||||
// (which used to query it a second time). On failure keep the
|
||||
// historical fallback: default configuration (versioned = false) plus
|
||||
// the warn that `apply_actions` used to emit.
|
||||
let versioning_config = match BucketVersioningSys::get(&item.bucket).await {
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
let versioning_result = BucketVersioningSys::get(&item.bucket).await;
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
let versioning_config = match versioning_result {
|
||||
Ok(versioning_config) => versioning_config,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
@@ -122,7 +136,9 @@ impl ScannerIODisk for Disk {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
let lock_config = object_lock_config_for_scanner_item(&item).await;
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
|
||||
// Count every version this object contributes to the scan, independent
|
||||
// of any lifecycle configuration, so scan-coverage metrics stay honest
|
||||
@@ -130,17 +146,33 @@ impl ScannerIODisk for Disk {
|
||||
// `object_infos`.
|
||||
global_metrics().record_scanner_versions_scanned(object_infos.len() as u64);
|
||||
|
||||
item.apply_actions(object_infos, lock_config, versioning_config, tier_names, &mut size_summary)
|
||||
.await;
|
||||
#[cfg(test)]
|
||||
let _ = crate::scanner_folder::observe_test_size_dispatch_boundary("apply_actions");
|
||||
item.apply_actions_cancellable(
|
||||
object_infos,
|
||||
lock_config,
|
||||
versioning_config,
|
||||
tier_names,
|
||||
&mut size_summary,
|
||||
cancellation.as_ref(),
|
||||
)
|
||||
.await;
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
|
||||
if !free_version_infos.is_empty() {
|
||||
for oi in free_version_infos {
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
if ScannerItem::tier_is_known(&oi, tier_names) {
|
||||
enqueue_runtime_free_version(oi).await;
|
||||
#[cfg(test)]
|
||||
if crate::scanner_folder::observe_test_size_dispatch_boundary("free_version_dispatch") {
|
||||
continue;
|
||||
}
|
||||
enqueue_runtime_free_version(oi, cancellation.as_ref()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensure_scanner_node_operation_active(cancellation.as_ref())?;
|
||||
done_object();
|
||||
|
||||
Ok(size_summary)
|
||||
|
||||
@@ -996,6 +996,73 @@ fn completed_data_usage_info_adds_same_bucket_across_unique_sets() {
|
||||
assert_eq!(bucket.delete_markers_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_usage_incarnation_is_optional_and_old_readers_ignore_it() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LegacyBucketUsage {
|
||||
size: u64,
|
||||
}
|
||||
let legacy = BucketUsageInfo {
|
||||
size: 42,
|
||||
..Default::default()
|
||||
};
|
||||
let without = serde_json::to_value(&legacy).expect("encode legacy usage");
|
||||
assert!(without.get("bucket_incarnation").is_none());
|
||||
let restored: BucketUsageInfo = serde_json::from_value(without).expect("decode old usage");
|
||||
assert_eq!(restored.bucket_incarnation, None);
|
||||
let certified = BucketUsageInfo {
|
||||
size: 42,
|
||||
bucket_incarnation: Some(uuid::Uuid::from_u128(7).to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_vec(&certified).expect("encode certified usage");
|
||||
let old: LegacyBucketUsage = serde_json::from_slice(&encoded).expect("old schema ignores additive field");
|
||||
assert_eq!(old.size, 42);
|
||||
let named = rmp_serde::to_vec_named(&certified).expect("encode certified named msgpack");
|
||||
let old: LegacyBucketUsage = rmp_serde::from_slice(&named).expect("old named reader ignores additive field");
|
||||
assert_eq!(old.size, 42);
|
||||
let old_bytes = rmp_serde::to_vec_named(&legacy).expect("encode old shape");
|
||||
let new: BucketUsageInfo = rmp_serde::from_slice(&old_bytes).expect("new reader accepts old shape");
|
||||
assert_eq!(new.bucket_incarnation, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_usage_certifies_only_matching_non_nil_bucket_incarnations() {
|
||||
let buckets = vec!["bucket".to_string()];
|
||||
let id = uuid::Uuid::from_u128(7);
|
||||
let mut a = completed_root_cache("bucket", 2, 10, DataUsageCacheSource::new(0, 0));
|
||||
let mut b = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(1, 0));
|
||||
a.info.scan_bucket_incarnations.insert("bucket".to_string(), id);
|
||||
b.info.scan_bucket_incarnations.insert("bucket".to_string(), id);
|
||||
let published = completed_data_usage_info_for_test(&[a.clone(), b.clone()], &buckets, false, false)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(
|
||||
published.buckets_usage["bucket"].bucket_incarnation.as_deref(),
|
||||
Some(id.to_string().as_str())
|
||||
);
|
||||
let encoded = serde_json::to_vec(&published).expect("serialize certified usage");
|
||||
let decoded: DataUsageInfo = serde_json::from_slice(&encoded).expect("read certified usage");
|
||||
assert_eq!(decoded.buckets_usage["bucket"].bucket_incarnation, Some(id.to_string()));
|
||||
b.info
|
||||
.scan_bucket_incarnations
|
||||
.insert("bucket".to_string(), uuid::Uuid::from_u128(8));
|
||||
let mismatch = completed_data_usage_info_for_test(&[a.clone(), b.clone()], &buckets, false, false)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(mismatch.buckets_usage["bucket"].bucket_incarnation, None);
|
||||
b.info.scan_bucket_incarnations.remove("bucket");
|
||||
let missing = completed_data_usage_info_for_test(&[a.clone(), b.clone()], &buckets, false, false)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(missing.buckets_usage["bucket"].bucket_incarnation, None);
|
||||
b.info
|
||||
.scan_bucket_incarnations
|
||||
.insert("bucket".to_string(), uuid::Uuid::nil());
|
||||
let nil = completed_data_usage_info_for_test(&[a, b], &buckets, false, false).unwrap().0;
|
||||
assert_eq!(nil.buckets_usage["bucket"].bucket_incarnation, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_bucket_usage_info_moves_replication_keys_and_preserves_the_snapshot() {
|
||||
let first_target = "arn:rustfs:replication:local::target-a".to_string();
|
||||
@@ -1057,6 +1124,7 @@ fn checked_bucket_usage_info_moves_replication_keys_and_preserves_the_snapshot()
|
||||
}
|
||||
let expected = BucketUsageInfo {
|
||||
size: 32768,
|
||||
bucket_incarnation: None,
|
||||
replication_pending_size_v1: 0,
|
||||
replication_failed_size_v1: 0,
|
||||
replicated_size_v1: 0,
|
||||
|
||||
@@ -46,7 +46,9 @@ use uuid::Uuid;
|
||||
|
||||
mod checkpoint_coverage_identity;
|
||||
mod collector_lifecycle;
|
||||
mod expiry_lock;
|
||||
mod listing_window_scope;
|
||||
mod node_admission;
|
||||
mod partial_bucket_carry;
|
||||
mod per_bucket_certification;
|
||||
mod scoped_entry_fallback;
|
||||
@@ -2211,6 +2213,147 @@ fn test_bucket_incarnations(buckets: &[&str]) -> HashMap<String, Uuid> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SetCacheLogWriter(Arc<StdMutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for SetCacheLogWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().expect("set cache log buffer").extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn capture_set_cache_logs<F: std::future::Future>(future: F) -> (F::Output, Vec<serde_json::Value>) {
|
||||
use tracing::instrument::WithSubscriber as _;
|
||||
|
||||
let buffer = Arc::new(StdMutex::new(Vec::new()));
|
||||
let writer = Arc::clone(&buffer);
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.json()
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_writer(move || SetCacheLogWriter(Arc::clone(&writer)))
|
||||
.finish();
|
||||
// Avoid a cached Never interest when other tests use an unsubscribed dispatcher.
|
||||
let _interest_guard = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default());
|
||||
let result = future.with_subscriber(subscriber).await;
|
||||
let logs = buffer.lock().expect("set cache log buffer");
|
||||
let events = std::str::from_utf8(&logs)
|
||||
.expect("set cache logs should be UTF-8")
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).expect("set cache log should be JSON"))
|
||||
.collect();
|
||||
(result, events)
|
||||
}
|
||||
|
||||
fn set_cache_event<'a>(events: &'a [serde_json::Value], state: &str) -> &'a serde_json::Value {
|
||||
let matching = events
|
||||
.iter()
|
||||
.filter(|event| event["fields"]["state"] == state)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(matching.len(), 1, "expected one {state} event: {events:?}");
|
||||
&matching[0]["fields"]
|
||||
}
|
||||
|
||||
fn assert_set_cache_context(fields: &serde_json::Value, source: Option<(usize, usize)>) {
|
||||
assert_eq!(fields["store_pool"], 0);
|
||||
assert_eq!(fields["store_set"], 0);
|
||||
assert_eq!(fields["snapshot_cycle"], 7);
|
||||
assert_eq!(fields["snapshot_leader_epoch"], 11);
|
||||
if let Some((pool, set)) = source {
|
||||
assert_eq!(fields["snapshot_source_pool"], pool);
|
||||
assert_eq!(fields["snapshot_source_set"], set);
|
||||
} else {
|
||||
assert!(fields.get("snapshot_source_pool").is_none());
|
||||
assert!(fields.get("snapshot_source_set").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_cache_log_fixture() -> (tempfile::TempDir, Arc<ECStore>, DataUsageCache, u64) {
|
||||
let (temp_dir, store) = setup_two_pool_scanner_store().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let epoch = scanner_publication_epoch(Arc::clone(&set)).await.expect("idle set admission");
|
||||
let mut cache = complete_set_usage_cache(&[("photos", 5)], DataUsageScanPlanDigest([1; 32]));
|
||||
cache.info.source = Some(DataUsageCacheSource::new(0, 0));
|
||||
cache.info.scan_execution_digest = Some(DataUsageScanPlanDigest([2; 32]));
|
||||
cache.save(set, DATA_USAGE_CACHE_NAME).await.expect("seed set cache");
|
||||
(temp_dir, store, cache, epoch)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_cache_missing_metadata_logs_receiver_and_snapshot() {
|
||||
let (_temp_dir, store, cache, epoch) = set_cache_log_fixture().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let cycle_floor = AtomicU64::new(cache.info.next_cycle);
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
|
||||
for missing in ["source", "coverage_digest", "execution_digest", "plan_digest"] {
|
||||
let mut candidate = cache.clone();
|
||||
match missing {
|
||||
"source" => candidate.info.source = None,
|
||||
"coverage_digest" => candidate.info.scan_coverage_digest = None,
|
||||
"execution_digest" => {
|
||||
candidate.info.source = Some(DataUsageCacheSource::new(1, 2));
|
||||
candidate.info.scan_execution_digest = None;
|
||||
}
|
||||
"plan_digest" => candidate.info.scan_plan_digest = None,
|
||||
_ => unreachable!("fixture only uses the four checked metadata fields"),
|
||||
}
|
||||
let source = candidate.info.source.map(|source| (source.pool_index, source.set_index));
|
||||
let (result, events) = capture_set_cache_logs(persist_and_publish_cache_snapshot(
|
||||
Arc::clone(&set),
|
||||
&tx,
|
||||
candidate,
|
||||
None,
|
||||
&cycle_floor,
|
||||
epoch,
|
||||
))
|
||||
.await;
|
||||
assert!(result.is_none(), "missing {missing} should retain the existing rejection");
|
||||
let fields = set_cache_event(&events, "snapshot_metadata_missing");
|
||||
assert_eq!(fields["reason"], missing);
|
||||
assert_set_cache_context(fields, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_cache_revision_rejection_logs_receiver_and_snapshot() {
|
||||
let (_temp_dir, store, mut cache, epoch) = set_cache_log_fixture().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
cache.info.scan_execution_digest = Some(DataUsageScanPlanDigest([3; 32]));
|
||||
let cycle_floor = AtomicU64::new(cache.info.next_cycle);
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let (result, events) =
|
||||
capture_set_cache_logs(persist_and_publish_cache_snapshot(set, &tx, cache, None, &cycle_floor, epoch)).await;
|
||||
assert!(result.is_none());
|
||||
assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)));
|
||||
assert_set_cache_context(set_cache_event(&events, "scan_baseline_revision_changed"), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_cache_send_failure_logs_receiver_and_snapshot_without_changing_return() {
|
||||
let (_temp_dir, store, cache, epoch) = set_cache_log_fixture().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let cycle_floor = AtomicU64::new(cache.info.next_cycle);
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
drop(rx);
|
||||
let (result, events) =
|
||||
capture_set_cache_logs(persist_and_publish_cache_snapshot(set, &tx, cache, None, &cycle_floor, epoch)).await;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"the existing return value must not be treated as receiver acknowledgement"
|
||||
);
|
||||
assert_set_cache_context(set_cache_event(&events, "publish_failed"), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers() {
|
||||
|
||||
@@ -354,3 +354,202 @@ async fn collector_worker_panic_waits_for_admitted_resume_and_preserves_checkpoi
|
||||
async fn collector_parent_cancel_drains_accepted_root_and_preserves_checkpoint() {
|
||||
run_collector_lifecycle(Stop::ParentCancellation).await;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CollectorLogWriter(Arc<StdMutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for CollectorLogWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().expect("collector log buffer").extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn capture_collector_logs<F: std::future::Future>(future: F) -> (F::Output, Vec<serde_json::Value>) {
|
||||
use tracing::instrument::WithSubscriber as _;
|
||||
|
||||
let buffer = Arc::new(StdMutex::new(Vec::new()));
|
||||
let writer = Arc::clone(&buffer);
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.json()
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.with_writer(move || CollectorLogWriter(Arc::clone(&writer)))
|
||||
.finish();
|
||||
let _interest_guard = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default());
|
||||
let result = future.with_subscriber(subscriber).await;
|
||||
let logs = buffer.lock().expect("collector log buffer");
|
||||
let events = std::str::from_utf8(&logs)
|
||||
.expect("collector logs should be UTF-8")
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).expect("collector event should be JSON"))
|
||||
.collect();
|
||||
(result, events)
|
||||
}
|
||||
|
||||
fn bucket_root(name: &str, generation: u64, size: usize) -> DataUsageEntryInfo {
|
||||
DataUsageEntryInfo {
|
||||
name: name.to_string(),
|
||||
parent: DATA_USAGE_ROOT.to_string(),
|
||||
entry: DataUsageEntry {
|
||||
size,
|
||||
..Default::default()
|
||||
},
|
||||
bucket_incarnation: None,
|
||||
tier_registry_generation: Some(generation),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn collector_reports_distinct_duplicate_and_rejected_roots_after_drain() {
|
||||
for (cancel, recover_missing) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let ctx = CancellationToken::new();
|
||||
let mut cache = DataUsageCache {
|
||||
info: DataUsageCacheInfo {
|
||||
name: DATA_USAGE_ROOT.to_string(),
|
||||
source: Some(DataUsageCacheSource::new(2, 3)),
|
||||
next_cycle: 17,
|
||||
leader_epoch: 23,
|
||||
tier_registry_generation: Some(7),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
cache.replace(
|
||||
"bucket-b",
|
||||
DATA_USAGE_ROOT,
|
||||
DataUsageEntry {
|
||||
size: 19,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let (tx, rx) = mpsc::channel(4);
|
||||
if cancel {
|
||||
ctx.cancel();
|
||||
}
|
||||
let run = async {
|
||||
let collector = super::super::io_cache::collect_completed_bucket_results(ctx.clone(), cache, rx);
|
||||
tokio::pin!(collector);
|
||||
if cancel {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = &mut collector => panic!("collector cannot close while the sender is open"),
|
||||
_ = tokio::task::yield_now() => {}
|
||||
}
|
||||
}
|
||||
tx.send(bucket_root("bucket-a", 7, 1)).await.expect("first root send");
|
||||
tx.send(bucket_root("bucket-a", 7, 2)).await.expect("duplicate root send");
|
||||
tx.send(bucket_root("bucket-b", 8, 3)).await.expect("wrong-tier root send");
|
||||
if recover_missing {
|
||||
tx.send(bucket_root("bucket-b", 7, 4)).await.expect("current root send");
|
||||
}
|
||||
drop(tx);
|
||||
collector.await
|
||||
};
|
||||
let ((cache, completed), events) = capture_collector_logs(run).await;
|
||||
assert_eq!(completed.len(), if recover_missing { 2 } else { 1 });
|
||||
assert_eq!(cache.find("bucket-a").expect("applied duplicate root").size, 2);
|
||||
assert_eq!(
|
||||
cache.find("bucket-b").expect("retained or recovered root").size,
|
||||
if recover_missing { 4 } else { 19 }
|
||||
);
|
||||
assert_eq!(should_publish_completed_snapshot(completed.len(), 2, false, false), recover_missing);
|
||||
assert!(!should_publish_completed_snapshot(completed.len(), 2, false, true));
|
||||
|
||||
let events = events
|
||||
.iter()
|
||||
.filter(|event| event["fields"]["state"] == "bucket_root_collector_drained")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(events.len(), 1, "each collector drain should report one bounded summary");
|
||||
let fields = &events[0]["fields"];
|
||||
assert_eq!(fields["collector_cache_pool"], 2);
|
||||
assert_eq!(fields["collector_cache_set"], 3);
|
||||
assert_eq!(fields["collector_cache_cycle"], 17);
|
||||
assert_eq!(fields["collector_cache_leader_epoch"], 23);
|
||||
assert_eq!(fields["received_roots"], if recover_missing { 4 } else { 3 });
|
||||
assert_eq!(fields["distinct_applied_roots"], if recover_missing { 2 } else { 1 });
|
||||
assert_eq!(fields["duplicate_applied_roots"], 1);
|
||||
assert_eq!(fields["rejected_roots"], 1);
|
||||
assert_eq!(fields["cancellation_observed"], cancel);
|
||||
assert_eq!(fields["cancelled_at_drain"], cancel);
|
||||
assert!(fields.get("bucket").is_none());
|
||||
assert!(fields.get("object").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn collector_reports_cancellation_when_close_and_cancel_are_both_ready() {
|
||||
let ctx = CancellationToken::new();
|
||||
ctx.cancel();
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
drop(tx);
|
||||
let ((_, completed), events) = capture_collector_logs(super::super::io_cache::collect_completed_bucket_results(
|
||||
ctx,
|
||||
DataUsageCache::default(),
|
||||
rx,
|
||||
))
|
||||
.await;
|
||||
assert!(completed.is_empty());
|
||||
let events = events
|
||||
.iter()
|
||||
.filter(|event| event["fields"]["state"] == "bucket_root_collector_drained")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(events.len(), 1);
|
||||
let fields = &events[0]["fields"];
|
||||
assert_eq!(fields["cancelled_at_drain"], true);
|
||||
assert!(fields["cancellation_observed"].is_boolean(), "either ready branch may be selected first");
|
||||
assert_eq!(fields["received_roots"], 0);
|
||||
assert_eq!(fields["distinct_applied_roots"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn incomplete_set_event_reports_requested_cycle_and_epoch() {
|
||||
let ((), events) = capture_collector_logs(async {
|
||||
let (_dir, store) = setup_local_scanner_store(1, 1).await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
create_bucket(&store, PARTIAL_BUCKET, 4).await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let parent = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(
|
||||
&parent,
|
||||
ScannerCycleBudgetConfig {
|
||||
max_objects: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let (updates, mut results) = mpsc::channel(1);
|
||||
set.clone()
|
||||
.nsscanner_cache(
|
||||
budget.token(),
|
||||
Arc::clone(&budget),
|
||||
scan_plan(&set, &[PARTIAL_BUCKET], 1).await,
|
||||
updates,
|
||||
1,
|
||||
HealScanMode::Normal,
|
||||
)
|
||||
.await
|
||||
.expect("budgeted fixture should return partial progress");
|
||||
assert!(budget.budget_elapsed());
|
||||
assert!(!results.recv().await.expect("incomplete set result").info.snapshot_complete);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
})
|
||||
.await;
|
||||
let events = events
|
||||
.iter()
|
||||
.filter(|event| event["fields"]["state"] == "set_cache_publish_skipped")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(events.len(), 1);
|
||||
let fields = &events[0]["fields"];
|
||||
assert_eq!(fields["pool"], 0);
|
||||
assert_eq!(fields["set"], 0);
|
||||
assert_eq!(fields["cycle"], 1);
|
||||
assert_eq!(fields["leader_epoch"], 11);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::storage_api::owner::EcstoreExpiryState;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
|
||||
// Return Some(result) if the operation ended too early, None only if it was
|
||||
// actually polled pending after the prerequisite read had finished.
|
||||
async fn observe_queued<F: Future>(
|
||||
mut operation: Pin<&mut F>,
|
||||
read_complete: impl Fn() -> bool,
|
||||
observation: Duration,
|
||||
) -> std::result::Result<Option<F::Output>, tokio::time::error::Elapsed> {
|
||||
tokio::time::timeout(
|
||||
observation,
|
||||
std::future::poll_fn(|cx| match operation.as_mut().poll(cx) {
|
||||
Poll::Ready(value) => Poll::Ready(Some(value)),
|
||||
Poll::Pending if read_complete() => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn free_version_cancelled_while_waiting_for_expiry_write_lock_never_queues() {
|
||||
let state = EcstoreExpiryState::new_with_unconsumed_worker_channel(4);
|
||||
let observation = Duration::from_secs(rustfs_config::DEFAULT_DRIVE_METADATA_TIMEOUT_SECS);
|
||||
let context = crate::ExpiryLockTestContext {
|
||||
state: state.clone(),
|
||||
store: None,
|
||||
completed_incarnation_reads: Arc::new(AtomicUsize::new(0)),
|
||||
};
|
||||
crate::EXPIRY_LOCK_TEST_CONTEXT
|
||||
.scope(context, async {
|
||||
let object = crate::ScannerObjectInfo {
|
||||
bucket: "expiry-lock-bucket".to_string(),
|
||||
name: "free-object".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let cancellation = CancellationToken::new();
|
||||
let guard = state.write().await;
|
||||
let mut pending = Box::pin(crate::enqueue_runtime_free_version(object.clone(), Some(&cancellation)));
|
||||
let first = observe_queued(pending.as_mut(), || true, observation).await;
|
||||
let observed_wait = matches!(&first, Ok(None));
|
||||
let already_finished = matches!(&first, Ok(Some(())));
|
||||
cancellation.cancel();
|
||||
let cancelled_while_locked = if already_finished {
|
||||
false
|
||||
} else {
|
||||
tokio::time::timeout(observation, pending.as_mut()).await.is_ok()
|
||||
};
|
||||
let pending_while_locked = guard.pending_tasks();
|
||||
drop(guard);
|
||||
if !already_finished && !cancelled_while_locked {
|
||||
pending.as_mut().await;
|
||||
}
|
||||
drop(pending);
|
||||
let pending_after_cancel = state.read().await.pending_tasks();
|
||||
|
||||
// Positive control queues a real free-version task while using the same
|
||||
// implementation and isolated, unconsumed worker channel.
|
||||
let mut positive = Box::pin(crate::enqueue_runtime_free_version(object, None));
|
||||
let positive_in_time = tokio::time::timeout(observation, positive.as_mut()).await.is_ok();
|
||||
if !positive_in_time {
|
||||
positive.as_mut().await;
|
||||
}
|
||||
drop(positive);
|
||||
let pending_after_control = state.read().await.pending_tasks();
|
||||
assert!(observed_wait, "must poll the real enqueue while the write lock is held");
|
||||
assert!(cancelled_while_locked, "cancellation must finish before the lock is released");
|
||||
assert_eq!(pending_while_locked, 0);
|
||||
assert_eq!(pending_after_cancel, 0);
|
||||
assert!(positive_in_time, "positive control must finish within the observation limit");
|
||||
assert_eq!(pending_after_control, 1, "positive control must queue a task");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn newer_noncurrent_cancelled_after_real_read_while_waiting_for_expiry_write_lock_never_queues() {
|
||||
let (_temp_dir, store) = setup_local_scanner_store(1, 4).await;
|
||||
let bucket = "expiry-lock-bucket";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let expected_incarnation = store.bucket_incarnation_id(bucket).await.expect("real incarnation metadata");
|
||||
assert!(!expected_incarnation.is_nil());
|
||||
let state = EcstoreExpiryState::new_with_unconsumed_worker_channel(4);
|
||||
let reads = Arc::new(AtomicUsize::new(0));
|
||||
let context = crate::ExpiryLockTestContext {
|
||||
state: state.clone(),
|
||||
store: Some(store.clone()),
|
||||
completed_incarnation_reads: reads.clone(),
|
||||
};
|
||||
let observation = Duration::from_secs(rustfs_config::DEFAULT_DRIVE_METADATA_TIMEOUT_SECS);
|
||||
crate::EXPIRY_LOCK_TEST_CONTEXT
|
||||
.scope(context, async {
|
||||
let versions = vec![crate::ScannerObjectToDelete {
|
||||
object_name: "noncurrent-object".to_string(),
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
}];
|
||||
let event = crate::Event::default();
|
||||
let src = crate::LcEventSrc::Scanner;
|
||||
let cancellation = CancellationToken::new();
|
||||
let guard = state.write().await;
|
||||
let mut pending = Box::pin(crate::enqueue_runtime_newer_noncurrent(
|
||||
bucket,
|
||||
versions.clone(),
|
||||
event.clone(),
|
||||
&src,
|
||||
Some(&cancellation),
|
||||
));
|
||||
let first = observe_queued(pending.as_mut(), || reads.load(Ordering::SeqCst) == 1, observation).await;
|
||||
let observed_wait_after_read = matches!(&first, Ok(None));
|
||||
let mut result = match first {
|
||||
Ok(Some(value)) => Some(value),
|
||||
_ => None,
|
||||
};
|
||||
cancellation.cancel();
|
||||
let cancelled_while_locked = if result.is_some() {
|
||||
false
|
||||
} else {
|
||||
match tokio::time::timeout(observation, pending.as_mut()).await {
|
||||
Ok(value) => {
|
||||
result = Some(value);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
let pending_while_locked = guard.pending_tasks();
|
||||
drop(guard);
|
||||
let result = match result {
|
||||
Some(value) => value,
|
||||
None => pending.as_mut().await,
|
||||
};
|
||||
drop(pending);
|
||||
let pending_after_cancel = state.read().await.pending_tasks();
|
||||
let mut positive = Box::pin(crate::enqueue_runtime_newer_noncurrent(bucket, versions, event, &src, None));
|
||||
let bounded_positive = tokio::time::timeout(observation, positive.as_mut()).await;
|
||||
let positive_in_time = bounded_positive.is_ok();
|
||||
let positive_result = match bounded_positive {
|
||||
Ok(value) => value,
|
||||
Err(_) => positive.as_mut().await,
|
||||
};
|
||||
drop(positive);
|
||||
let pending_after_control = state.read().await.pending_tasks();
|
||||
assert!(
|
||||
observed_wait_after_read,
|
||||
"real incarnation read must finish before waiting on the held lock"
|
||||
);
|
||||
assert!(cancelled_while_locked, "cancellation must finish before the lock is released");
|
||||
assert!(!result);
|
||||
assert_eq!(pending_while_locked, 0);
|
||||
assert_eq!(pending_after_cancel, 0);
|
||||
assert!(positive_in_time, "positive control must finish within the observation limit");
|
||||
assert!(positive_result, "positive control must enqueue the nonempty version batch");
|
||||
assert_eq!(pending_after_control, 1);
|
||||
assert_eq!(reads.load(Ordering::SeqCst), 2);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use super::*;
|
||||
use crate::workload_admission::ScannerNodeRequest;
|
||||
use rustfs_concurrency::adaptive::{FeedbackController, FeedbackObservation, NodeAdmission, ResourcePressure};
|
||||
use std::num::NonZeroU16;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ResumeCase {
|
||||
Unchanged,
|
||||
Updated,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
async fn check_cache_lease_resume(case: ResumeCase) {
|
||||
let (_directory, store) = setup_local_scanner_store(1, 4).await;
|
||||
let set = store.pools[0].disk_set[0].clone();
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let cache_name = "node-resume/.usage-cache.bin";
|
||||
let cache = DataUsageCache::default();
|
||||
cache.save(set.clone(), cache_name).await.expect("seed cache");
|
||||
let revisions = DataUsageCache::read_revisions(set.clone(), cache_name)
|
||||
.await
|
||||
.expect("initial revisions");
|
||||
let gate = NodeAdmission::new(NonZeroU16::new(1).expect("one shared unit"));
|
||||
gate.update(
|
||||
&mut FeedbackController::new(),
|
||||
Some(FeedbackObservation {
|
||||
generation: 1,
|
||||
max_concurrency: 1,
|
||||
pressure: ResourcePressure::Unknown,
|
||||
completed_units: None,
|
||||
}),
|
||||
);
|
||||
let mut held = Some(gate.acquire().await);
|
||||
let mut guard = acquire_scanner_cache_locks(set.as_ref(), cache_name, source)
|
||||
.await
|
||||
.expect("initial cache lease");
|
||||
let ctx = CancellationToken::new();
|
||||
let (respond, response) = oneshot::channel();
|
||||
let request = ScannerNodeRequest {
|
||||
owner: gate.clone(),
|
||||
respond,
|
||||
cancel: ctx.clone(),
|
||||
};
|
||||
let mut expected = revisions.clone();
|
||||
let result = {
|
||||
let resume = guard.resume_node_operation(set.clone(), cache_name, source, &revisions, request);
|
||||
tokio::pin!(resume);
|
||||
assert!(futures::poll!(&mut resume).is_pending(), "shared node is occupied");
|
||||
let other = tokio::time::timeout(Duration::from_secs(1), acquire_scanner_cache_locks(set.as_ref(), cache_name, source))
|
||||
.await
|
||||
.expect("cache lock must be released before joining the node queue")
|
||||
.expect("another cache worker can acquire the lease");
|
||||
if case == ResumeCase::Updated {
|
||||
let mut newer = cache;
|
||||
newer.info.next_cycle = 1;
|
||||
newer
|
||||
.save(set.clone(), cache_name)
|
||||
.await
|
||||
.expect("another worker updates the cache");
|
||||
expected = DataUsageCache::read_revisions(set.clone(), cache_name)
|
||||
.await
|
||||
.expect("new cache revisions");
|
||||
assert_ne!(expected, revisions);
|
||||
}
|
||||
drop(other);
|
||||
if case == ResumeCase::Cancelled {
|
||||
ctx.cancel();
|
||||
} else {
|
||||
held.take().expect("held node unit").complete();
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), resume)
|
||||
.await
|
||||
.expect("node resumption must finish or observe cancellation")
|
||||
};
|
||||
if case == ResumeCase::Unchanged {
|
||||
result.expect("unchanged cache resumes the same walk");
|
||||
let permit = response.await.expect("coordinator returns the node permit");
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
assert_eq!(gate.completed(), Some(1), "admitting scanner work must not count it as completed");
|
||||
assert!(!guard.is_lock_lost());
|
||||
let lock = set
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &scanner_cache_lock_resource(cache_name, source))
|
||||
.await
|
||||
.expect("competing cache lock");
|
||||
assert!(
|
||||
lock.get_write_lock_quiet(Duration::from_millis(20)).await.is_err(),
|
||||
"cache lease must be restored before the operation resumes"
|
||||
);
|
||||
permit.complete();
|
||||
} else {
|
||||
result.expect_err("cancelled or stale work cannot resume");
|
||||
assert!(response.await.is_err(), "failed resumption must not authorize storage work");
|
||||
if case == ResumeCase::Cancelled {
|
||||
assert!(guard.is_lock_lost());
|
||||
assert_eq!(gate.snapshot().active, 1, "the running heal keeps its unit");
|
||||
drop(held.take());
|
||||
assert_eq!(gate.completed(), Some(0));
|
||||
} else {
|
||||
assert_eq!(gate.completed(), Some(1));
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
assert!(gate.try_acquire().is_some(), "dropped requests must leave no queue or capacity leak");
|
||||
assert_eq!(
|
||||
DataUsageCache::read_revisions(set.clone(), cache_name)
|
||||
.await
|
||||
.expect("final revisions"),
|
||||
expected
|
||||
);
|
||||
acquire_scanner_cache_locks(set.as_ref(), cache_name, source)
|
||||
.await
|
||||
.expect("final cache lease is available");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_node_wait_yields_cache_lease_before_fifo_admission() {
|
||||
check_cache_lease_resume(ResumeCase::Unchanged).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_node_wait_rejects_cache_changed_while_unlocked() {
|
||||
check_cache_lease_resume(ResumeCase::Updated).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_node_wait_cancellation_releases_queue_without_stealing_running_unit() {
|
||||
check_cache_lease_resume(ResumeCase::Cancelled).await;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user