mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-30 07:46:36 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 495e1d3b87 | |||
| 7a3bf8cdbd | |||
| 3d3957a248 | |||
| b4026b43b2 | |||
| 851c295753 | |||
| f879946ead | |||
| d88f524d9f | |||
| 54ac37ec0a | |||
| 2b56666123 | |||
| a24d305665 | |||
| c47c8f9f74 | |||
| 3a00e9bd42 | |||
| c0c60dd91d | |||
| b5c253f3ed | |||
| 63d97f0877 | |||
| 4f04338ee1 | |||
| f96c94f950 | |||
| 5b5d68a444 | |||
| 0fb650a408 | |||
| b9f01162b2 |
@@ -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-linux=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
|
||||
sha256-darwin=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
|
||||
sha256-linux=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
|
||||
sha256-darwin=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
|
||||
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
packages=()
|
||||
command -v clang >/dev/null || packages+=(clang)
|
||||
command -v llvm-objcopy >/dev/null || packages+=(llvm)
|
||||
command -v gcc >/dev/null || packages+=(gcc)
|
||||
select_bpftool >/dev/null || packages+=(linux-tools-generic)
|
||||
if (( ${#packages[@]} )); then
|
||||
sudo apt-get update
|
||||
@@ -63,8 +64,12 @@ jobs:
|
||||
selected_bpftool="$(select_bpftool)"
|
||||
"$selected_bpftool" version
|
||||
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
|
||||
- name: Compare compiled declarations with bpftool
|
||||
- name: Compare declarations and layouts with independent references
|
||||
run: |
|
||||
clang -target bpf -O2 -g -c scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/prototypes.o"
|
||||
llvm-objcopy --dump-section ".BTF=$RUNNER_TEMP/prototypes.btf" "$RUNNER_TEMP/prototypes.o"
|
||||
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf"
|
||||
gcc -std=c11 -Wall -Wextra -Werror -DRUSTFS_BTF_LAYOUT_REFERENCE \
|
||||
scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/layout-reference"
|
||||
"$RUNNER_TEMP/layout-reference" > "$RUNNER_TEMP/layout-reference.json"
|
||||
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf" \
|
||||
--layout-reference "$RUNNER_TEMP/layout-reference.json"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,5 +40,6 @@ mod scanner_heal_topology_fixture;
|
||||
mod scanner_heal_topology_test;
|
||||
mod scanner_heal_topology_tier_test;
|
||||
mod site_replication_test;
|
||||
mod table_catalog_test;
|
||||
mod upgrade_test;
|
||||
mod versioning_test;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, get_object_bytes, put_object, unique_bucket};
|
||||
use crate::common::{init_logging, signed_request};
|
||||
use http::{Method, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn catalog_request(
|
||||
dist: &DistCluster,
|
||||
node: usize,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
expected: StatusCode,
|
||||
) -> TestResult<Value> {
|
||||
let response = signed_request(
|
||||
method.clone(),
|
||||
&format!("{}{path}", dist.cluster.nodes[node].url),
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
body.map(|value| serde_json::to_vec(&value)).transpose()?,
|
||||
Some("application/json"),
|
||||
)
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
assert_eq!(status, expected, "node {node}: {method} {path}: {text}");
|
||||
if text.is_empty() {
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_bucket_state(dist: &DistCluster, bucket: &str, enabled: bool) -> TestResult {
|
||||
for node in 0..dist.cluster.nodes.len() {
|
||||
let response =
|
||||
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
|
||||
assert_eq!(response["enabled"], enabled, "node {node}: {response}");
|
||||
assert_eq!(response["table-bucket"], bucket);
|
||||
let expected = if enabled { StatusCode::OK } else { StatusCode::BAD_REQUEST };
|
||||
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/{bucket}/namespaces"), None, expected).await?;
|
||||
assert_eq!(
|
||||
get_object_bytes(&dist.client(node)?, bucket, "ordinary.txt").await?,
|
||||
b"ordinary bucket object",
|
||||
"node {node}: toggling table support changed an ordinary object"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_table_state(dist: &DistCluster, table_path: &str, created: &Value, pointer: &Value) -> TestResult {
|
||||
for node in 0..dist.cluster.nodes.len() {
|
||||
let loaded = catalog_request(dist, node, Method::GET, table_path, None, StatusCode::OK).await?;
|
||||
assert_eq!(loaded["metadata-location"], created["metadata-location"], "node {node}");
|
||||
assert_eq!(loaded["metadata"], created["metadata"], "node {node}");
|
||||
let actual_pointer =
|
||||
catalog_request(dist, node, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
|
||||
assert_eq!(&actual_pointer, pointer, "node {node}: catalog pointer changed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_bucket_toggles_reload_cached_metadata_on_every_peer() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_TABLE_CATALOG_BACKING", "object")]).await?;
|
||||
let bucket = unique_bucket("table-peer-reload");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
put_object(&dist.client(0)?, &bucket, "ordinary.txt", b"ordinary bucket object".to_vec()).await?;
|
||||
let bucket_path = format!("/iceberg/v1/buckets/{bucket}");
|
||||
|
||||
// Warm each node's false cache before changing the marker. Do not poll:
|
||||
// successful toggles must notify peers before their response is returned.
|
||||
assert_bucket_state(&dist, &bucket, false).await?;
|
||||
for (node, method, enabled) in [(0, Method::PUT, true), (1, Method::DELETE, false), (2, Method::PUT, true)] {
|
||||
let response = catalog_request(&dist, node, method, &bucket_path, None, StatusCode::OK).await?;
|
||||
assert_eq!(response["enabled"], enabled);
|
||||
assert_bucket_state(&dist, &bucket, enabled).await?;
|
||||
}
|
||||
|
||||
let namespaces = format!("/iceberg/v1/{bucket}/namespaces");
|
||||
catalog_request(&dist, 3, Method::POST, &namespaces, Some(json!({"namespace": ["lab"]})), StatusCode::OK).await?;
|
||||
let tables = format!("{namespaces}/lab/tables");
|
||||
let created = catalog_request(
|
||||
&dist,
|
||||
0,
|
||||
Method::POST,
|
||||
&tables,
|
||||
Some(json!({
|
||||
"name": "events",
|
||||
"schema": {
|
||||
"type": "struct",
|
||||
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
|
||||
},
|
||||
"stage-create": false
|
||||
})),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await?;
|
||||
let uuid = created["metadata"]["table-uuid"]
|
||||
.as_str()
|
||||
.ok_or("create response omitted table UUID")?;
|
||||
assert!(!uuid::Uuid::parse_str(uuid)?.is_nil());
|
||||
assert!(
|
||||
created["metadata-location"]
|
||||
.as_str()
|
||||
.is_some_and(|location| location.starts_with(&format!("s3://{bucket}/")))
|
||||
);
|
||||
let table_path = format!("{tables}/events");
|
||||
let pointer =
|
||||
catalog_request(&dist, 3, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
|
||||
assert_eq!(pointer["metadata-location"], created["metadata-location"]);
|
||||
assert!(pointer["version-token"].as_str().is_some_and(|token| !token.is_empty()));
|
||||
assert!(pointer["generation"].as_u64().is_some());
|
||||
assert_eq!(pointer["warehouse-location"], created["metadata"]["location"]);
|
||||
assert_table_state(&dist, &table_path, &created, &pointer).await?;
|
||||
|
||||
// A rejected toggle must preserve both peer protections and catalog state.
|
||||
catalog_request(&dist, 1, Method::DELETE, &bucket_path, None, StatusCode::CONFLICT).await?;
|
||||
assert_bucket_state(&dist, &bucket, true).await?;
|
||||
assert_table_state(&dist, &table_path, &created, &pointer).await?;
|
||||
|
||||
catalog_request(&dist, 2, Method::DELETE, &table_path, None, StatusCode::NO_CONTENT).await?;
|
||||
catalog_request(&dist, 3, Method::DELETE, &format!("{namespaces}/lab"), None, StatusCode::NO_CONTENT).await?;
|
||||
// The cluster fixture owns all remaining catalog objects and data roots.
|
||||
Ok(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3720,6 +3720,46 @@ fn is_decommission_copy_quorum_deferral(err: &Error) -> bool {
|
||||
data_movement::data_movement_stage_source_as::<DecommissionCopyQuorumFailure>(err).is_some()
|
||||
}
|
||||
|
||||
/// The storage error of a version copy that kept timing out on a namespace
|
||||
/// lock through its in-place retries, kept typed so the entry failure policy
|
||||
/// defers it.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
struct DecommissionCopyLockTimeout(#[source] Error);
|
||||
|
||||
fn is_decommission_copy_lock_timeout_error(err: &Error) -> bool {
|
||||
let err = data_movement::data_movement_stage_source(err).unwrap_or(err);
|
||||
matches!(err, Error::Lock(rustfs_lock::LockError::Timeout { .. }))
|
||||
}
|
||||
|
||||
/// A version copy that timed out acquiring a namespace lock leaves the source
|
||||
/// in place; the next attempt copies the version again or finds an equivalent
|
||||
/// committed target. The lock is held by another mutation of the key or, when
|
||||
/// this node restarted mid-copy, by its previous incarnation until that lock's
|
||||
/// lease expires on the peers. Either clears by waiting, so the entry is
|
||||
/// deferred instead of being counted as a permanent per-object failure.
|
||||
fn decommission_copy_lock_timeout_deferral(stage: &str, bucket: &str, object: &str, err: Error) -> Error {
|
||||
data_movement::data_movement_context_error(
|
||||
format!("decommission {stage} timed out on a namespace lock for {bucket}/{object}: {err}"),
|
||||
DecommissionCopyLockTimeout(err),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_decommission_copy_lock_timeout_deferral(err: &Error) -> bool {
|
||||
data_movement::data_movement_stage_source_as::<DecommissionCopyLockTimeout>(err).is_some()
|
||||
}
|
||||
|
||||
/// The deferral of a version copy whose in-place retries ended on `error`, if
|
||||
/// that failure belongs to the pools at that moment rather than to the object.
|
||||
fn take_decommission_copy_deferral(stage: &str, bucket: &str, object: &str, error: &mut Option<Error>) -> Option<Error> {
|
||||
if let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err)) {
|
||||
return Some(decommission_copy_quorum_deferral(stage, bucket, object, err));
|
||||
}
|
||||
error
|
||||
.take_if(|err| is_decommission_copy_lock_timeout_error(err))
|
||||
.map(|err| decommission_copy_lock_timeout_deferral(stage, bucket, object, err))
|
||||
}
|
||||
|
||||
fn resolve_decommission_entry_reload_result(result: Result<()>, bucket: &str, object_name: &str) -> Result<()> {
|
||||
result.map_err(|err| Error::other(format!("decommission reload_pool_meta failed for {bucket}/{object_name}: {err}")))
|
||||
}
|
||||
@@ -5782,11 +5822,14 @@ enum DecommissionEntryOutcome {
|
||||
/// entry is done and must not be counted as a failure or escalated. A quorum
|
||||
/// failure of the source cleanup is deferred too: the copied source is still
|
||||
/// in place and the next attempt cleans it up again. So is a version copy that
|
||||
/// lost drive quorum through its in-place retries: the source still holds the
|
||||
/// version and the next attempt copies it again. Every other failure stays
|
||||
/// terminal.
|
||||
/// lost drive quorum or timed out on a namespace lock through its in-place
|
||||
/// retries: the source still holds the version and the next attempt copies it
|
||||
/// again. Every other failure stays terminal.
|
||||
fn resolve_decommission_entry_failure(err: Error) -> Result<DecommissionEntryOutcome> {
|
||||
if is_decommission_source_cleanup_retryable_error(&err) || is_decommission_copy_quorum_deferral(&err) {
|
||||
if is_decommission_source_cleanup_retryable_error(&err)
|
||||
|| is_decommission_copy_quorum_deferral(&err)
|
||||
|| is_decommission_copy_lock_timeout_deferral(&err)
|
||||
{
|
||||
return Ok(DecommissionEntryOutcome::Deferred(err));
|
||||
}
|
||||
match classify_decommission_capacity_failure(&err) {
|
||||
@@ -11702,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,
|
||||
@@ -11710,6 +11808,7 @@ pub(crate) struct DecommissionPoolCapacityInfo {
|
||||
physical_free: usize,
|
||||
physical_total: usize,
|
||||
physical_used: usize,
|
||||
diagnostic: PoolDiagnosticStatus,
|
||||
}
|
||||
|
||||
impl DecommissionPoolCapacityInfo {
|
||||
@@ -11732,6 +11831,10 @@ impl DecommissionPoolCapacityInfo {
|
||||
physical_free,
|
||||
physical_total,
|
||||
physical_used,
|
||||
diagnostic: PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Unknown,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11753,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();
|
||||
@@ -15125,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<()> {
|
||||
@@ -15201,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,
|
||||
@@ -15209,6 +15492,7 @@ impl ECStore {
|
||||
physical_free,
|
||||
physical_total,
|
||||
physical_used,
|
||||
diagnostic,
|
||||
})
|
||||
} else {
|
||||
Err(invalid_decommission_pool_index_error(self.pools.len(), idx))
|
||||
@@ -18150,13 +18434,11 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
if failure && let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err)) {
|
||||
return Err(decommission_copy_quorum_deferral(
|
||||
"delete_marker_copy",
|
||||
bucket.as_str(),
|
||||
version.name.as_str(),
|
||||
err,
|
||||
));
|
||||
if failure
|
||||
&& let Some(err) =
|
||||
take_decommission_copy_deferral("delete_marker_copy", bucket.as_str(), version.name.as_str(), &mut error)
|
||||
{
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if ignore {
|
||||
@@ -18542,14 +18824,14 @@ impl ECStore {
|
||||
|
||||
if failure
|
||||
&& !source_read_failed
|
||||
&& let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err))
|
||||
{
|
||||
return Err(decommission_copy_quorum_deferral(
|
||||
&& let Some(err) = take_decommission_copy_deferral(
|
||||
DECOMMISSION_STAGE_MIGRATE_OBJECT,
|
||||
bucket.as_str(),
|
||||
version.name.as_str(),
|
||||
err,
|
||||
));
|
||||
&mut error,
|
||||
)
|
||||
{
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if ignore {
|
||||
@@ -21803,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 [
|
||||
@@ -27939,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;
|
||||
@@ -28296,6 +28818,61 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// A restarted node's previous incarnation keeps its namespace locks on the
|
||||
/// peers until their lease expires, so a resumed copy of a key that was in
|
||||
/// flight at the crash times out acquiring the lock. That copy never
|
||||
/// committed: it is deferred, never counted as a failed object.
|
||||
#[test]
|
||||
fn decommission_copy_lock_timeout_is_deferred_not_failed() {
|
||||
let timeout = || {
|
||||
Error::Lock(rustfs_lock::LockError::timeout(
|
||||
"bucket-a/object-a@latest",
|
||||
std::time::Duration::from_secs(5),
|
||||
))
|
||||
};
|
||||
let prepare = data_movement::data_movement_stage_error_for_test(
|
||||
"decommission_object",
|
||||
"prepare_put_object",
|
||||
"bucket-a",
|
||||
"object-a",
|
||||
timeout(),
|
||||
);
|
||||
for err in [timeout(), prepare] {
|
||||
assert!(is_decommission_copy_lock_timeout_error(&err), "{err}");
|
||||
assert!(!is_decommission_copy_quorum_error(&err), "{err}");
|
||||
let mut error = Some(err);
|
||||
let deferred = take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut error)
|
||||
.expect("a lock timeout copy must be deferred");
|
||||
assert!(error.is_none());
|
||||
assert!(is_decommission_copy_lock_timeout_deferral(&deferred));
|
||||
assert!(!is_decommission_copy_quorum_deferral(&deferred));
|
||||
let failure = data_movement::data_movement_stage_source_as::<DecommissionCopyLockTimeout>(&deferred)
|
||||
.expect("the deferred error must keep its lock-timeout origin");
|
||||
assert!(is_decommission_copy_lock_timeout_error(&failure.0));
|
||||
assert!(matches!(
|
||||
resolve_decommission_entry_failure(deferred),
|
||||
Ok(DecommissionEntryOutcome::Deferred(_))
|
||||
));
|
||||
}
|
||||
|
||||
let mut quorum = Some(Error::ErasureWriteQuorum);
|
||||
let deferred = take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut quorum)
|
||||
.expect("a lost drive quorum copy stays deferred");
|
||||
assert!(is_decommission_copy_quorum_deferral(&deferred));
|
||||
|
||||
for other in [
|
||||
Error::Lock(rustfs_lock::LockError::internal("lock backend failed")),
|
||||
Error::other("Lock acquisition timeout for resource 'bucket-a/object-a@latest' after 5s"),
|
||||
] {
|
||||
let mut error = Some(other);
|
||||
assert!(
|
||||
take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut error).is_none(),
|
||||
"{error:?}"
|
||||
);
|
||||
assert!(error.is_some(), "a per-object failure keeps its error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
|
||||
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
|
||||
|
||||
@@ -2480,6 +2480,93 @@ mod decommission_lock_order_tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// A node killed mid-copy leaves its namespace lock on the peers until the
|
||||
/// lock lease expires, so the resumed decommission times out acquiring the
|
||||
/// lock of the key it was copying through every in-place retry. The entry
|
||||
/// is deferred and copied once the lease is gone; it is never counted as a
|
||||
/// failed object, which would fail the whole decommission.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn decommission_defers_a_copy_that_timed_out_on_a_stale_namespace_lock() {
|
||||
run_large_stack_async_test("decommission-copy-lock-timeout", || async {
|
||||
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
||||
let bucket = test_bucket("copy-lock-timeout");
|
||||
let object = "external/005.bin";
|
||||
let payload = vec![0x3c; 2048];
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create the bucket");
|
||||
put_source_object(&store, &bucket, object, payload.clone()).await;
|
||||
|
||||
// The stale lease outlives every in-place attempt of the first
|
||||
// pass and has expired by the deferred replay.
|
||||
let stale_lease_calls = crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS;
|
||||
let copy_calls = Arc::new(AtomicUsize::new(0));
|
||||
let _fault = DecommissionTestFaultGuard::install_with_error(
|
||||
Arc::new({
|
||||
let copy_calls = Arc::clone(©_calls);
|
||||
let bucket = bucket.clone();
|
||||
move |stage, entry_bucket, entry_object, _, _| {
|
||||
stage == "before_target_commit"
|
||||
&& entry_bucket == bucket
|
||||
&& entry_object == object
|
||||
&& copy_calls.fetch_add(1, Ordering::AcqRel) < stale_lease_calls
|
||||
}
|
||||
}),
|
||||
Some(Arc::new(|_, bucket, object| {
|
||||
// The shape the target write reports when the lock domain
|
||||
// still holds the previous incarnation's lock.
|
||||
data_movement::data_movement_stage_error_for_test(
|
||||
"decommission_object",
|
||||
"prepare_put_object",
|
||||
bucket,
|
||||
object,
|
||||
crate::error::Error::Lock(rustfs_lock::LockError::timeout(
|
||||
format!("{bucket}/{object}@latest"),
|
||||
Duration::from_secs(5),
|
||||
)),
|
||||
)
|
||||
})),
|
||||
);
|
||||
|
||||
start_transitioned_entry_decommission(&store, &bucket).await;
|
||||
spawn_decommission_routine(&store, 1)
|
||||
.await
|
||||
.expect("the decommission routine task should join")
|
||||
.expect("the decommission routine should return");
|
||||
|
||||
assert_eq!(
|
||||
copy_calls.load(Ordering::Acquire),
|
||||
stale_lease_calls + 1,
|
||||
"the first pass exhausts its in-place retries and the replay copies the object once"
|
||||
);
|
||||
let local = store.pool_meta.read().await;
|
||||
let info = local.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.expect("the decommission state should remain present");
|
||||
assert_eq!(
|
||||
info.items_decommission_failed, 0,
|
||||
"a lock held until its lease expires is not a permanent failure: {info:?}"
|
||||
);
|
||||
assert!(
|
||||
info.complete && !info.failed && !info.canceled && info.capacity_blocked_reason.is_none(),
|
||||
"the replayed entry must complete the decommission: {info:?}"
|
||||
);
|
||||
drop(local);
|
||||
|
||||
assert!(exact_versions(&store, 0, &bucket, object).await.is_empty(), "the source is cleaned up");
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the migrated object stays readable");
|
||||
let mut body = Vec::new();
|
||||
reader.stream.read_to_end(&mut body).await.expect("read the migrated object");
|
||||
assert_eq!(body, payload);
|
||||
});
|
||||
}
|
||||
|
||||
/// Seed `object` in pool 0 with three 2 KiB data versions and a newest
|
||||
/// delete marker; returns the data versions oldest first.
|
||||
async fn seed_versioned_entry_with_marker(
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::{
|
||||
error::{Error, classify_system_path_failure_reason},
|
||||
object_api::{ObjectInfo, ObjectOptions, PutObjReader},
|
||||
runtime::sources as runtime_sources,
|
||||
store::{ECStore, list_objects::list_marker_key},
|
||||
store::ECStore,
|
||||
};
|
||||
pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path};
|
||||
use rustfs_data_usage::{
|
||||
@@ -1966,8 +1966,8 @@ fn advance_version_listing_cursor(
|
||||
let next_version_marker = next_version_marker
|
||||
.filter(|next_version_marker| !next_version_marker.is_empty())
|
||||
.ok_or_else(|| Error::other(format!("bucket {bucket} version listing was truncated without a version marker")))?;
|
||||
let current_key_marker = marker.as_deref().map(list_marker_key);
|
||||
let next_key_marker = list_marker_key(&next_marker);
|
||||
let current_key_marker = marker.as_deref();
|
||||
let next_key_marker = next_marker.as_str();
|
||||
if current_key_marker == Some(next_key_marker) && version_marker.as_deref() == Some(next_version_marker.as_str()) {
|
||||
return Err(Error::other(format!(
|
||||
"bucket {bucket} version listing returned a repeated continuation marker"
|
||||
@@ -5167,20 +5167,20 @@ mod tests {
|
||||
);
|
||||
assert!(ensure_truncated_version_page_has_entries("bucket-a", 0).is_err());
|
||||
ensure_truncated_version_page_has_entries("bucket-a", 1).expect("a truncated page with an entry can advance");
|
||||
}
|
||||
|
||||
let mut tagged_marker = Some("object-a[rustfs_cache:v2,id:old]".to_string());
|
||||
let mut tagged_version_marker = Some("version-a".to_string());
|
||||
assert!(
|
||||
advance_version_listing_cursor(
|
||||
"bucket-a",
|
||||
&mut tagged_marker,
|
||||
&mut tagged_version_marker,
|
||||
Some("object-a[rustfs_cache:v2,id:new]".to_string()),
|
||||
Some("version-a".to_string()),
|
||||
)
|
||||
.is_err(),
|
||||
"different cache tags for the same logical marker must not count as progress"
|
||||
);
|
||||
#[test]
|
||||
fn version_listing_cursor_compares_literal_cache_shaped_keys() {
|
||||
let mut marker = Some("object-a".to_string());
|
||||
let mut version_marker = Some("version-a".to_string());
|
||||
advance_version_listing_cursor(
|
||||
"bucket-a",
|
||||
&mut marker,
|
||||
&mut version_marker,
|
||||
Some("object-a[rustfs_cache:v2,id:old]".to_string()),
|
||||
Some("version-a".to_string()),
|
||||
)
|
||||
.expect("a distinct literal key advances the cursor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+417
-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 => (),
|
||||
_ => {
|
||||
@@ -11306,34 +11309,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 +11369,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 +12056,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 +12193,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 +12243,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 +12278,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 +12471,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 +12949,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 +26571,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 +30994,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();
|
||||
|
||||
+140
-67
@@ -3047,17 +3047,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 +3064,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 +3193,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 +3694,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 +6274,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 +6490,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 +6657,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
|
||||
}
|
||||
@@ -6841,6 +6854,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
|
||||
|
||||
@@ -8839,7 +8839,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();
|
||||
|
||||
@@ -9294,36 +9294,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 +9481,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]))?;
|
||||
@@ -11147,45 +11177,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]));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -507,18 +507,6 @@ impl ListContinuationV2 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the key part of a marker that may still carry the legacy cursor tag.
|
||||
/// The data-usage scanner compares successive version-listing markers through
|
||||
/// it so two differently tagged markers for the same key never count as progress.
|
||||
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): markers tagged by 1.0.x servers. Remove after every supported
|
||||
// direct-upgrade release issues plain key markers; callers can then compare markers directly.
|
||||
pub(crate) fn list_marker_key(marker: &str) -> &str {
|
||||
marker
|
||||
.rfind(LIST_CACHE_MARKER_PREFIX)
|
||||
.filter(|start_idx| marker[*start_idx..].ends_with(']'))
|
||||
.map_or(marker, |start_idx| &marker[..start_idx])
|
||||
}
|
||||
|
||||
fn normalize_list_quorum(value: &str) -> &'static str {
|
||||
let value = value.trim();
|
||||
if value.eq_ignore_ascii_case("disk") {
|
||||
@@ -586,8 +574,7 @@ fn versioned_prefix_page_boundary_key<'a>(
|
||||
/// old-readable escape until legacy tag parsing can be removed.
|
||||
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): Remove after the minimum upgrade source stops issuing tagged cursors.
|
||||
fn escape_list_marker(marker: String) -> String {
|
||||
// Both the legacy request parser and scanner cursor comparison recognize
|
||||
// cache-shaped suffixes. One old-readable outer tag protects the literal key.
|
||||
// One old-readable outer tag protects a literal cache-shaped key.
|
||||
if marker
|
||||
.rfind(LIST_CACHE_MARKER_PREFIX)
|
||||
.is_some_and(|start| marker[start..].contains(']'))
|
||||
@@ -624,9 +611,9 @@ fn build_list_versions_next_marker(objects: &[ObjectInfo], prefixes: &[String])
|
||||
.unwrap_or_else(|| "null".to_string()),
|
||||
)
|
||||
};
|
||||
(Some(escape_list_marker(last.name.clone())), version_marker)
|
||||
(Some(last.name.clone()), version_marker)
|
||||
} else if let Some(last_prefix) = prefixes.last() {
|
||||
(Some(escape_list_marker(last_prefix.clone())), None)
|
||||
(Some(last_prefix.clone()), None)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
@@ -689,7 +676,7 @@ fn list_objects_paginate(
|
||||
&& let Some(last_scanned) = last_scanned_key
|
||||
{
|
||||
is_truncated = true;
|
||||
next_marker = Some(escape_list_marker(last_scanned.to_owned()));
|
||||
next_marker = Some(last_scanned.to_owned());
|
||||
} else if should_truncate {
|
||||
is_truncated = true;
|
||||
if include_version_id {
|
||||
@@ -757,7 +744,7 @@ where
|
||||
pagination.last_scanned_key,
|
||||
);
|
||||
if is_truncated && let Some(raw_key) = prefix_at_page_boundary {
|
||||
next_marker = Some(escape_list_marker(raw_key));
|
||||
next_marker = Some(raw_key);
|
||||
next_version_idmarker = None;
|
||||
}
|
||||
|
||||
@@ -849,10 +836,8 @@ impl ListPathOptions {
|
||||
/// Releases up to 1.0.x appended `[rustfs_cache:v2,id:...,src:walker,gen:live]`
|
||||
/// to every internal continuation marker and exposed it through the V2
|
||||
/// continuation token and the ListObjectVersions `NextKeyMarker`. Current
|
||||
/// servers emit plain keys, but a client that paginates across the upgrade
|
||||
/// still sends the tagged form back as `continuation-token`, `key-marker`
|
||||
/// or `marker`, and the opt-in index provider still carries its `src`/`gen`
|
||||
/// cursor fields in the same tag.
|
||||
/// servers still accept the tagged form in V2 continuation tokens and
|
||||
/// legacy object-list markers. Version-listing key markers are literal keys.
|
||||
///
|
||||
/// Newly issued cursors protect cache-shaped literal keys with one legacy
|
||||
/// outer tag. Parse only at the listing entry point; parsing the resulting
|
||||
@@ -908,7 +893,9 @@ impl ECStore {
|
||||
) -> Result<ListObjectsV2Info> {
|
||||
let marker = {
|
||||
if continuation_token.is_none() {
|
||||
start_after
|
||||
// StartAfter is a literal object key, even if it resembles a
|
||||
// legacy cursor. Protect it from the compatibility parser.
|
||||
start_after.map(escape_list_marker)
|
||||
} else {
|
||||
continuation_token.clone()
|
||||
}
|
||||
@@ -1129,7 +1116,7 @@ impl ECStore {
|
||||
};
|
||||
|
||||
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||
let mut opts = ListPathOptions {
|
||||
let opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
caller: if include_version_purge {
|
||||
ListObjectsGatherCaller::Lifecycle
|
||||
@@ -1146,10 +1133,6 @@ impl ECStore {
|
||||
include_marker: has_version_marker,
|
||||
..Default::default()
|
||||
};
|
||||
// Strip a legacy cursor tag before any name comparison (notably
|
||||
// `forward_past`), see backlog#1047.
|
||||
opts.parse_marker();
|
||||
|
||||
let mut page = self
|
||||
.list_path(&opts)
|
||||
.await
|
||||
@@ -2347,7 +2330,7 @@ impl SetDisks {
|
||||
let has_version_marker = version_marker.is_some();
|
||||
let version_marker = version_marker.map(parse_version_marker).transpose()?;
|
||||
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||
let mut opts = ListPathOptions {
|
||||
let opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
prefix: prefix.to_owned(),
|
||||
limit: effective_max_keys,
|
||||
@@ -2358,8 +2341,6 @@ impl SetDisks {
|
||||
include_marker: has_version_marker,
|
||||
..Default::default()
|
||||
};
|
||||
opts.parse_marker();
|
||||
|
||||
let mut page = self
|
||||
.list_path_result(&opts)
|
||||
.await
|
||||
@@ -2832,8 +2813,8 @@ mod lifecycle_exact_tests;
|
||||
mod test {
|
||||
use super::{
|
||||
ENV_API_LIST_OBJECTS_QUORUM, GatherResultsState, ListPathOptions, ListSourceMode, MAX_OBJECT_LIST, RUSTFS_META_BUCKET,
|
||||
VersionMarker, gather_results, list_marker_key, list_merged_entry_channel, list_metadata_resolution_params,
|
||||
list_objects_paginate, list_objects_quorum_from_env, max_keys_plus_one, merge_entry_channels, normalize_list_quorum,
|
||||
VersionMarker, gather_results, list_merged_entry_channel, list_metadata_resolution_params, list_objects_paginate,
|
||||
list_objects_quorum_from_env, max_keys_plus_one, merge_entry_channels, normalize_list_quorum,
|
||||
observe_scanner_namespace_mutations, parse_version_marker, recursive_walk_list_path_options,
|
||||
reset_scanner_namespace_mutation_generation_for_test, scanner_namespace_mutation_generation, send_or_cancel,
|
||||
version_marker_for_entries, walk_result_from_set_errors,
|
||||
@@ -4341,7 +4322,10 @@ mod test {
|
||||
list_objects_paginate(vec![plain_object(key), plain_object("zz")], &None, 1, false, include_version_id, None);
|
||||
assert!(truncated);
|
||||
let marker = marker.expect("truncated page needs a cursor");
|
||||
assert_eq!(list_marker_key(&marker), key, "scanner must compare the complete literal key");
|
||||
if include_version_id {
|
||||
assert_eq!(marker, key, "version markers must be literal keys");
|
||||
continue;
|
||||
}
|
||||
let mut opts = ListPathOptions {
|
||||
marker: Some(marker),
|
||||
..Default::default()
|
||||
@@ -4399,9 +4383,7 @@ mod test {
|
||||
assert_eq!(page.is_truncated, index + 1 < keys.len());
|
||||
marker = page.next_continuation_token;
|
||||
|
||||
// ListObjects (V1) returns the same cursor as NextMarker and the
|
||||
// client sends it back as `marker`, which reaches this layer as
|
||||
// StartAfter.
|
||||
// V1 sends the literal NextMarker back as StartAfter.
|
||||
let v1_page = store
|
||||
.clone()
|
||||
.inner_list_objects_v2(bucket, "", None, None, 1, false, v1_marker, false)
|
||||
@@ -4409,7 +4391,7 @@ mod test {
|
||||
.expect("list next object through the V1 marker path");
|
||||
assert_eq!(v1_page.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
|
||||
assert_eq!(v1_page.is_truncated, index + 1 < keys.len());
|
||||
v1_marker = v1_page.next_continuation_token;
|
||||
v1_marker = v1_page.is_truncated.then(|| key.to_owned());
|
||||
|
||||
let versions = store
|
||||
.clone()
|
||||
@@ -4418,12 +4400,44 @@ mod test {
|
||||
.expect("list next version");
|
||||
assert_eq!(versions.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
|
||||
assert_eq!(versions.is_truncated, index + 1 < keys.len());
|
||||
if versions.is_truncated {
|
||||
assert_eq!(versions.next_marker.as_deref(), Some(key), "NextKeyMarker must be the literal key");
|
||||
}
|
||||
key_marker = versions.next_marker;
|
||||
version_marker = versions.next_version_idmarker;
|
||||
}
|
||||
assert!(marker.is_none());
|
||||
assert!(v1_marker.is_none());
|
||||
assert!(key_marker.is_none());
|
||||
|
||||
let literal = "b[rustfs_cache:v1,return:]";
|
||||
let after_literal = store
|
||||
.clone()
|
||||
.inner_list_objects_v2(bucket, "", None, None, 1, false, Some(literal.to_owned()), false)
|
||||
.await
|
||||
.expect("start after a caller-supplied literal key");
|
||||
assert_eq!(after_literal.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
|
||||
|
||||
let resumed = store
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, "", Some(literal.to_owned()), None, None, 1)
|
||||
.await
|
||||
.expect("resume from a caller-supplied literal key marker");
|
||||
assert_eq!(resumed.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
|
||||
|
||||
let resumed_version = store
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, "", Some(literal.to_owned()), Some("null".to_owned()), None, 1)
|
||||
.await
|
||||
.expect("resume after the literal key's null version");
|
||||
assert_eq!(resumed_version.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
|
||||
|
||||
let recursive_delete_page = store.pools[0].disk_set[0]
|
||||
.clone()
|
||||
.inner_list_object_versions_for_recursive_delete(bucket, "", Some(literal.to_owned()), None, 1)
|
||||
.await
|
||||
.expect("resume recursive-delete validation from the literal key");
|
||||
assert_eq!(recursive_delete_page.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4589,6 +4603,32 @@ mod test {
|
||||
assert!(next_version_idmarker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_delimiter_boundaries_preserve_cache_shaped_raw_keys() {
|
||||
let delimiter = Some("-".to_owned());
|
||||
let raw_key = "data-0002[rustfs_cache:v2,return:]";
|
||||
let (_, _, truncated, marker, version_marker) =
|
||||
list_objects_paginate(vec![folded_prefix_object("data-")], &delimiter, 2, true, true, Some(raw_key));
|
||||
assert!(truncated);
|
||||
assert_eq!(marker.as_deref(), Some(raw_key));
|
||||
assert!(version_marker.is_none());
|
||||
|
||||
let (_, _, truncated, marker, version_marker) = super::list_objects_paginate_versions(
|
||||
vec![folded_prefix_object("data-")],
|
||||
super::VersionedListPagination {
|
||||
delimiter: &delimiter,
|
||||
max_keys: 1,
|
||||
disk_has_more: true,
|
||||
marker: None,
|
||||
last_scanned_key: Some(raw_key),
|
||||
raw_keys: std::iter::once(raw_key),
|
||||
},
|
||||
);
|
||||
assert!(truncated);
|
||||
assert_eq!(marker.as_deref(), Some(raw_key));
|
||||
assert!(version_marker.is_none());
|
||||
}
|
||||
|
||||
// End-to-end simulation of versioned pagination with `max_keys=1`, where the
|
||||
// next page resumes via a bare key marker (`forward_past` keeps `name > marker`
|
||||
// only, matching `inner_list_object_versions_with_projection` without a version
|
||||
@@ -6971,22 +7011,6 @@ mod test {
|
||||
assert_eq!(parsed.set_idx, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marker_key_strips_only_trailing_cache_tag() {
|
||||
assert_eq!(
|
||||
list_marker_key("photos/2026/image.jpg[rustfs_cache:v2,id:list-cache-id,src:walker,gen:live]"),
|
||||
"photos/2026/image.jpg"
|
||||
);
|
||||
assert_eq!(
|
||||
list_marker_key("photos/[rustfs_cache:v2]/image.jpg"),
|
||||
"photos/[rustfs_cache:v2]/image.jpg"
|
||||
);
|
||||
assert_eq!(
|
||||
list_marker_key("photos/image.jpg[rustfs_cache:v2,id:list-cache-id"),
|
||||
"photos/image.jpg[rustfs_cache:v2,id:list-cache-id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_path_marker_parser_return_tag_forces_cache_refresh() {
|
||||
let mut parsed = ListPathOptions {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
@@ -5420,7 +5452,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)?
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -67,11 +67,12 @@ const METRIC_CACHE_SAVE_RETRY_TOTAL: &str = "rustfs_scanner_cache_save_retry_tot
|
||||
const METRIC_CACHE_SAVE_PAYLOAD_BYTES_TOTAL: &str = "rustfs_scanner_cache_save_payload_bytes_total";
|
||||
const METRIC_CACHE_SAVE_DURATION_SECONDS: &str = "rustfs_scanner_cache_save_duration_seconds";
|
||||
const METRIC_CACHE_BACKUP_REVISION_FAILURE_TOTAL: &str = "rustfs_scanner_cache_backup_revision_failure_total";
|
||||
const METRIC_CACHE_READ_ORIGIN_TOTAL: &str = "rustfs_scanner_cache_read_origin_total";
|
||||
const LOG_COMPONENT_SCANNER: &str = "scanner";
|
||||
const LOG_SUBSYSTEM_CACHE: &str = "cache";
|
||||
const EVENT_SCANNER_CACHE_LOAD_STATE: &str = "scanner_cache_load_state";
|
||||
const EVENT_SCANNER_CACHE_SAVE_STATE: &str = "scanner_cache_save_state";
|
||||
static CACHE_SAVE_METRICS_ONCE: Once = Once::new();
|
||||
static CACHE_METRICS_ONCE: Once = Once::new();
|
||||
|
||||
pub const DATA_USAGE_SCAN_CHECKPOINT_VERSION: u16 = 1;
|
||||
pub const DATA_USAGE_RAW_ENUMERATION_CURSOR_VERSION: u16 = 1;
|
||||
@@ -204,6 +205,54 @@ pub(crate) struct DataUsageCacheRevisions {
|
||||
backup: Option<DataUsageCacheRevision>,
|
||||
}
|
||||
|
||||
/// The observed result of trying the logical primary cache slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DataUsageCachePrimaryRead {
|
||||
Missing,
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
/// Process-local read provenance, not evidence of freshness or authority.
|
||||
/// Legacy objects may come from the old data-usage bucket. Empty may mean
|
||||
/// missing or corrupt objects; neither state certifies a complete snapshot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum DataUsageCacheReadOrigin {
|
||||
Primary {
|
||||
legacy: bool,
|
||||
},
|
||||
Backup {
|
||||
primary: DataUsageCachePrimaryRead,
|
||||
legacy: bool,
|
||||
},
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl DataUsageCacheReadOrigin {
|
||||
fn metric_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Primary { legacy: false } => "primary",
|
||||
Self::Primary { legacy: true } => "legacy_primary",
|
||||
Self::Backup {
|
||||
primary: DataUsageCachePrimaryRead::Missing,
|
||||
legacy: false,
|
||||
} => "backup_primary_missing",
|
||||
Self::Backup {
|
||||
primary: DataUsageCachePrimaryRead::Missing,
|
||||
legacy: true,
|
||||
} => "legacy_backup_primary_missing",
|
||||
Self::Backup {
|
||||
primary: DataUsageCachePrimaryRead::Corrupt,
|
||||
legacy: false,
|
||||
} => "backup_primary_corrupt",
|
||||
Self::Backup {
|
||||
primary: DataUsageCachePrimaryRead::Corrupt,
|
||||
legacy: true,
|
||||
} => "legacy_backup_primary_corrupt",
|
||||
Self::Empty => "empty",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub static DATA_USAGE_BUCKET: LazyLock<String> =
|
||||
LazyLock::new(|| format!("{RUSTFS_META_BUCKET}{SLASH_SEPARATOR}{BUCKET_META_PREFIX}"));
|
||||
|
||||
@@ -1271,8 +1320,12 @@ impl DataUsageCache {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_cache_save_metrics_registered() {
|
||||
CACHE_SAVE_METRICS_ONCE.call_once(|| {
|
||||
fn ensure_cache_metrics_registered() {
|
||||
CACHE_METRICS_ONCE.call_once(|| {
|
||||
describe_counter!(
|
||||
METRIC_CACHE_READ_ORIGIN_TOTAL,
|
||||
"Successful scanner cache loads by observed logical origin; does not prove cache freshness."
|
||||
);
|
||||
describe_counter!(
|
||||
METRIC_CACHE_SAVE_ATTEMPT_TOTAL,
|
||||
"Total scanner data usage cache save attempts by result and cache type."
|
||||
|
||||
@@ -70,6 +70,7 @@ struct DataUsageCacheLoadResult {
|
||||
cache: DataUsageCache,
|
||||
main_revision: DataUsageCacheRevision,
|
||||
backup_revision: Option<DataUsageCacheRevision>,
|
||||
origin: DataUsageCacheReadOrigin,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -97,6 +98,7 @@ impl DataUsageCache {
|
||||
/// If the object is not found, a nil error with empty data usage cache is returned.
|
||||
pub async fn load<S: ScannerObjectIO>(&mut self, store: Arc<S>, name: &str) -> StorageResult<()> {
|
||||
let loaded = Self::load_cache(store, name).await?;
|
||||
Self::record_cache_read_origin(loaded.origin);
|
||||
*self = loaded.cache;
|
||||
Ok(())
|
||||
}
|
||||
@@ -106,6 +108,17 @@ impl DataUsageCache {
|
||||
store: Arc<S>,
|
||||
name: &str,
|
||||
) -> StorageResult<DataUsageCacheRevisions> {
|
||||
self.load_with_revisions_and_origin(store, name)
|
||||
.await
|
||||
.map(|(revisions, _)| revisions)
|
||||
}
|
||||
|
||||
/// Origin is an observation of this load, not proof of current coverage.
|
||||
pub(crate) async fn load_with_revisions_and_origin<S: ScannerObjectIO>(
|
||||
&mut self,
|
||||
store: Arc<S>,
|
||||
name: &str,
|
||||
) -> StorageResult<(DataUsageCacheRevisions, DataUsageCacheReadOrigin)> {
|
||||
let backup_name = format!("{name}.bkp");
|
||||
let backup_path = path_join_buf(&[BUCKET_META_PREFIX, &backup_name]);
|
||||
let loaded = Self::load_cache(store.clone(), name).await?;
|
||||
@@ -131,9 +144,16 @@ impl DataUsageCache {
|
||||
},
|
||||
};
|
||||
let main = loaded.main_revision;
|
||||
let origin = loaded.origin;
|
||||
Self::record_cache_read_origin(origin);
|
||||
*self = loaded.cache;
|
||||
|
||||
Ok(DataUsageCacheRevisions { main, backup })
|
||||
Ok((DataUsageCacheRevisions { main, backup }, origin))
|
||||
}
|
||||
|
||||
fn record_cache_read_origin(origin: DataUsageCacheReadOrigin) {
|
||||
Self::ensure_cache_metrics_registered();
|
||||
counter!(METRIC_CACHE_READ_ORIGIN_TOTAL, "origin" => origin.metric_label()).increment(1);
|
||||
}
|
||||
|
||||
pub(crate) async fn read_revisions<S: ScannerObjectIO>(store: Arc<S>, name: &str) -> StorageResult<DataUsageCacheRevisions> {
|
||||
@@ -156,10 +176,12 @@ impl DataUsageCache {
|
||||
cache,
|
||||
revision: Some(main_revision),
|
||||
} => {
|
||||
let legacy = matches!(&main_revision, DataUsageCacheRevision::Missing);
|
||||
return Ok(DataUsageCacheLoadResult {
|
||||
cache: *cache,
|
||||
main_revision,
|
||||
backup_revision: None,
|
||||
origin: DataUsageCacheReadOrigin::Primary { legacy },
|
||||
});
|
||||
}
|
||||
DataUsageCacheLoadAttempt::Loaded { revision: None, .. } => {
|
||||
@@ -193,10 +215,17 @@ impl DataUsageCache {
|
||||
let main_revision = main_revision.ok_or_else(|| {
|
||||
Error::other(format!("scanner cache main revision is unavailable while loading backup: {name}"))
|
||||
})?;
|
||||
let primary = if main_state == DataUsageCacheLoadState::Corrupt {
|
||||
DataUsageCachePrimaryRead::Corrupt
|
||||
} else {
|
||||
DataUsageCachePrimaryRead::Missing
|
||||
};
|
||||
let legacy = matches!(&backup_revision, DataUsageCacheRevision::Missing);
|
||||
return Ok(DataUsageCacheLoadResult {
|
||||
cache: *cache,
|
||||
main_revision,
|
||||
backup_revision: Some(backup_revision),
|
||||
origin: DataUsageCacheReadOrigin::Backup { primary, legacy },
|
||||
});
|
||||
}
|
||||
DataUsageCacheLoadState::Loaded
|
||||
@@ -220,6 +249,7 @@ impl DataUsageCache {
|
||||
main_revision: main_revision
|
||||
.ok_or_else(|| Error::other(format!("scanner cache missing state has no revision: {name}")))?,
|
||||
backup_revision,
|
||||
origin: DataUsageCacheReadOrigin::Empty,
|
||||
});
|
||||
}
|
||||
(DataUsageCacheLoadState::Corrupt, DataUsageCacheLoadState::Missing)
|
||||
@@ -239,6 +269,7 @@ impl DataUsageCache {
|
||||
main_revision: main_revision
|
||||
.ok_or_else(|| Error::other(format!("scanner cache corrupt state has no revision: {name}")))?,
|
||||
backup_revision,
|
||||
origin: DataUsageCacheReadOrigin::Empty,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
@@ -518,7 +549,7 @@ impl DataUsageCache {
|
||||
revision: Option<DataUsageCacheRevision>,
|
||||
expected_epoch: Option<u64>,
|
||||
) -> StorageResult<()> {
|
||||
Self::ensure_cache_save_metrics_registered();
|
||||
Self::ensure_cache_metrics_registered();
|
||||
let path_type = Self::cache_path_type(path);
|
||||
let path = path.to_string();
|
||||
let sha256hex = (revision.is_some() && !buf.is_empty())
|
||||
|
||||
@@ -168,8 +168,10 @@ impl AsyncRead for PrefixThenErrorReader {
|
||||
#[derive(Debug)]
|
||||
struct CacheReadStore {
|
||||
main: CacheReadBody,
|
||||
main_missing: bool,
|
||||
backup: Option<Vec<u8>>,
|
||||
legacy: Option<Vec<u8>>,
|
||||
legacy_backup: Option<Vec<u8>>,
|
||||
puts: AtomicUsize,
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
entry_sources: Mutex<Vec<Option<crate::storage_api::ecstore_diagnostic::ReadVersionEntrySource>>>,
|
||||
@@ -185,8 +187,10 @@ impl CacheReadStore {
|
||||
fn new(main: CacheReadBody, backup: Option<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
main,
|
||||
main_missing: false,
|
||||
backup,
|
||||
legacy: None,
|
||||
legacy_backup: None,
|
||||
puts: AtomicUsize::new(0),
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
entry_sources: Mutex::new(Vec::new()),
|
||||
@@ -239,8 +243,12 @@ impl ObjectIO for CacheReadStore {
|
||||
.await
|
||||
.push(crate::storage_api::ecstore_diagnostic::current_read_version_entry_source());
|
||||
if bucket == DATA_USAGE_BUCKET.as_str() {
|
||||
return self
|
||||
.legacy
|
||||
let legacy = if object.ends_with(".bkp") {
|
||||
&self.legacy_backup
|
||||
} else {
|
||||
&self.legacy
|
||||
};
|
||||
return legacy
|
||||
.clone()
|
||||
.map(|data| Self::reader(CacheReadBody::Bytes(data), "legacy-etag"))
|
||||
.ok_or(Error::FileNotFound);
|
||||
@@ -252,6 +260,9 @@ impl ObjectIO for CacheReadStore {
|
||||
let main_path = path_join_buf(&[BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME]);
|
||||
let backup_path = format!("{main_path}.bkp");
|
||||
if object == main_path {
|
||||
if self.main_missing {
|
||||
return Err(Error::FileNotFound);
|
||||
}
|
||||
return Ok(Self::reader(self.main.clone(), "main-etag"));
|
||||
}
|
||||
if object == backup_path {
|
||||
@@ -712,6 +723,143 @@ async fn corrupt_primary_cache_loads_valid_backup() {
|
||||
assert_eq!(store.puts.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_read_origin_reports_logical_slot_and_legacy_location() {
|
||||
let mut primary = DataUsageCache::default();
|
||||
primary.info.name = "primary".to_owned();
|
||||
let mut backup = DataUsageCache::default();
|
||||
backup.info.name = "backup".to_owned();
|
||||
backup.info.snapshot_complete = true;
|
||||
let primary_bytes = primary.marshal_msg().expect("serialize primary");
|
||||
let backup_bytes = backup.marshal_msg().expect("serialize backup");
|
||||
let empty_bytes = DataUsageCache::default().marshal_msg().expect("serialize empty cache");
|
||||
let corrupt = b"not-msgpack".to_vec();
|
||||
|
||||
let cases = [
|
||||
(
|
||||
"primary",
|
||||
Some(primary_bytes.clone()),
|
||||
Some(backup_bytes.clone()),
|
||||
None,
|
||||
None,
|
||||
DataUsageCacheReadOrigin::Primary { legacy: false },
|
||||
primary_bytes.clone(),
|
||||
),
|
||||
(
|
||||
"legacy primary",
|
||||
None,
|
||||
None,
|
||||
Some(primary_bytes.clone()),
|
||||
None,
|
||||
DataUsageCacheReadOrigin::Primary { legacy: true },
|
||||
primary_bytes,
|
||||
),
|
||||
(
|
||||
"backup after missing",
|
||||
None,
|
||||
Some(backup_bytes.clone()),
|
||||
None,
|
||||
None,
|
||||
DataUsageCacheReadOrigin::Backup {
|
||||
primary: DataUsageCachePrimaryRead::Missing,
|
||||
legacy: false,
|
||||
},
|
||||
backup_bytes.clone(),
|
||||
),
|
||||
(
|
||||
"legacy backup after missing",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(backup_bytes.clone()),
|
||||
DataUsageCacheReadOrigin::Backup {
|
||||
primary: DataUsageCachePrimaryRead::Missing,
|
||||
legacy: true,
|
||||
},
|
||||
backup_bytes.clone(),
|
||||
),
|
||||
(
|
||||
"backup after corrupt",
|
||||
Some(corrupt.clone()),
|
||||
Some(backup_bytes.clone()),
|
||||
None,
|
||||
None,
|
||||
DataUsageCacheReadOrigin::Backup {
|
||||
primary: DataUsageCachePrimaryRead::Corrupt,
|
||||
legacy: false,
|
||||
},
|
||||
backup_bytes.clone(),
|
||||
),
|
||||
(
|
||||
"legacy backup after corrupt",
|
||||
Some(corrupt.clone()),
|
||||
None,
|
||||
None,
|
||||
Some(backup_bytes.clone()),
|
||||
DataUsageCacheReadOrigin::Backup {
|
||||
primary: DataUsageCachePrimaryRead::Corrupt,
|
||||
legacy: true,
|
||||
},
|
||||
backup_bytes,
|
||||
),
|
||||
(
|
||||
"missing objects",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
DataUsageCacheReadOrigin::Empty,
|
||||
empty_bytes.clone(),
|
||||
),
|
||||
(
|
||||
"corrupt primary without backup",
|
||||
Some(corrupt),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
DataUsageCacheReadOrigin::Empty,
|
||||
empty_bytes,
|
||||
),
|
||||
];
|
||||
|
||||
for (name, main, current_backup, legacy_primary, legacy_backup, expected_origin, expected_bytes) in cases {
|
||||
let has_main = main.is_some();
|
||||
let has_backup = current_backup.is_some();
|
||||
let mut fixture = CacheReadStore::new(CacheReadBody::Bytes(main.unwrap_or_default()), current_backup);
|
||||
fixture.main_missing = !has_main;
|
||||
fixture.legacy = legacy_primary;
|
||||
fixture.legacy_backup = legacy_backup;
|
||||
let store = Arc::new(fixture);
|
||||
let mut loaded = DataUsageCache::default();
|
||||
let (revisions, origin) = loaded
|
||||
.load_with_revisions_and_origin(store.clone(), DATA_USAGE_CACHE_NAME)
|
||||
.await
|
||||
.expect("fixture cache load");
|
||||
|
||||
assert_eq!(origin, expected_origin, "origin for {name}");
|
||||
assert_eq!(loaded.marshal_msg().expect("re-encode cache"), expected_bytes, "cache body for {name}");
|
||||
assert_eq!(
|
||||
revisions.main,
|
||||
if has_main {
|
||||
DataUsageCacheRevision::Etag("main-etag".to_owned())
|
||||
} else {
|
||||
DataUsageCacheRevision::Missing
|
||||
},
|
||||
"primary CAS revision for {name}"
|
||||
);
|
||||
assert_eq!(
|
||||
revisions.backup,
|
||||
Some(if has_backup {
|
||||
DataUsageCacheRevision::Etag("backup-etag".to_owned())
|
||||
} else {
|
||||
DataUsageCacheRevision::Missing
|
||||
}),
|
||||
"backup CAS revision for {name}"
|
||||
);
|
||||
assert_eq!(store.puts.load(Ordering::SeqCst), 0, "reading must not write for {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_cache_without_valid_backup_rebuilds_with_cas_revisions() {
|
||||
for backup in [None, Some(b"also-not-msgpack".to_vec())] {
|
||||
|
||||
@@ -7,17 +7,18 @@ use std::sync::atomic::AtomicU64;
|
||||
|
||||
const PAYLOAD_BYTES: &str = "rustfs_scanner_cache_save_payload_bytes_total";
|
||||
const ENCODED_BYTES: &str = "rustfs_scanner_cache_serialized_bytes_total";
|
||||
const READ_ORIGIN: &str = "rustfs_scanner_cache_read_origin_total";
|
||||
|
||||
#[derive(Default)]
|
||||
struct SaveRecorder(StdMutex<HashMap<metrics::Key, Arc<AtomicU64>>>);
|
||||
struct CacheRecorder(StdMutex<HashMap<metrics::Key, Arc<AtomicU64>>>);
|
||||
|
||||
impl metrics::Recorder for SaveRecorder {
|
||||
impl metrics::Recorder for CacheRecorder {
|
||||
fn describe_counter(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn describe_gauge(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn describe_histogram(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
|
||||
fn register_counter(&self, key: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Counter {
|
||||
if ![PAYLOAD_BYTES, ENCODED_BYTES].contains(&key.name()) {
|
||||
if ![PAYLOAD_BYTES, ENCODED_BYTES, READ_ORIGIN].contains(&key.name()) {
|
||||
return metrics::Counter::noop();
|
||||
}
|
||||
metrics::Counter::from_arc(self.0.lock().expect("save recorder").entry(key.clone()).or_default().clone())
|
||||
@@ -36,7 +37,7 @@ async fn check_save(store: BackupFallbackStore, revisions: Option<DataUsageCache
|
||||
let store = Arc::new(store);
|
||||
let cache = DataUsageCache::default();
|
||||
let encoded = rmp_serde::to_vec(&cache).expect("independent encoded body");
|
||||
let recorder = SaveRecorder::default();
|
||||
let recorder = CacheRecorder::default();
|
||||
let save = async {
|
||||
match revisions {
|
||||
Some(revisions) => {
|
||||
@@ -85,6 +86,49 @@ async fn check_save(store: BackupFallbackStore, revisions: Option<DataUsageCache
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn read_origin_counts_successful_loads_with_fixed_labels() {
|
||||
let bytes = DataUsageCache::default().marshal_msg().expect("serialize backup");
|
||||
let corrupt_primary = CacheReadStore::new(CacheReadBody::Bytes(b"not-msgpack".to_vec()), Some(bytes.clone()));
|
||||
let mut legacy_backup = CacheReadStore::new(CacheReadBody::Bytes(Vec::new()), None);
|
||||
legacy_backup.main_missing = true;
|
||||
legacy_backup.legacy_backup = Some(bytes);
|
||||
let mut empty = CacheReadStore::new(CacheReadBody::Bytes(Vec::new()), None);
|
||||
empty.main_missing = true;
|
||||
let recorder = CacheRecorder::default();
|
||||
|
||||
for (store, with_revisions) in [(corrupt_primary, false), (legacy_backup, true), (empty, true)] {
|
||||
let store = Arc::new(store);
|
||||
let mut loaded = DataUsageCache::default();
|
||||
let read = async {
|
||||
if with_revisions {
|
||||
loaded.load_with_revisions(store, DATA_USAGE_CACHE_NAME).await.map(|_| ())
|
||||
} else {
|
||||
loaded.load(store, DATA_USAGE_CACHE_NAME).await
|
||||
}
|
||||
};
|
||||
let mut read = std::pin::pin!(read);
|
||||
std::future::poll_fn(|cx| metrics::with_local_recorder(&recorder, || read.as_mut().poll(cx)))
|
||||
.await
|
||||
.expect("fixture load");
|
||||
}
|
||||
|
||||
let unavailable = Arc::new(BackupFallbackStore::new(Vec::new(), false));
|
||||
let mut loaded = DataUsageCache::default();
|
||||
let mut failed_read = std::pin::pin!(loaded.load_with_revisions(unavailable, DATA_USAGE_CACHE_NAME));
|
||||
let error = std::future::poll_fn(|cx| metrics::with_local_recorder(&recorder, || failed_read.as_mut().poll(cx)))
|
||||
.await
|
||||
.expect_err("an unreadable primary must not count as a fallback");
|
||||
assert!(matches!(error, Error::ErasureReadQuorum));
|
||||
|
||||
let rows = recorder.0.lock().expect("cache recorder");
|
||||
assert_eq!(rows.len(), 3, "one fixed origin label per successful load");
|
||||
for origin in ["backup_primary_corrupt", "legacy_backup_primary_missing", "empty"] {
|
||||
let key = metrics::Key::from_parts(READ_ORIGIN, vec![metrics::Label::new("origin", origin)]);
|
||||
assert_eq!(rows[&key].load(Ordering::Relaxed), 1, "one observation for {origin}");
|
||||
}
|
||||
}
|
||||
|
||||
fn revisions(backup: bool) -> DataUsageCacheRevisions {
|
||||
DataUsageCacheRevisions {
|
||||
main: DataUsageCacheRevision::Missing,
|
||||
|
||||
@@ -1098,6 +1098,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 +1124,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 +1146,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 +1170,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 +1195,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 +1215,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 +1255,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 +1272,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 +1295,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 +1317,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 +1341,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 +1362,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"
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1807,6 +1833,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(),
|
||||
|
||||
@@ -2211,6 +2211,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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ collection:
|
||||
## Operations
|
||||
|
||||
- [Retained MRF responsibilities](operations/mrf-responsibilities.md) — unknown bucket identities, bounded probes, and unreadable checkpoint recovery.
|
||||
- [Read-only BTF prototype inspection](operations/btf-prototype-inspection.md) — bounded declarations, explicit void handling, and independent native reference tests.
|
||||
- [Read-only BTF declaration inspection](operations/btf-prototype-inspection.md) — bounded prototypes and scalar member layouts, explicit void handling, and independent native references.
|
||||
|
||||
- [Internode HTTP streaming transport](operations/internode-http-transport.md) — protocol selection, pooled connection isolation, and distributed read validation.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
- `backlog-2097-tier-mutation-v4-error-text` tier-mutation Prepare rejection classification: a v3 server rejects a v4 request before store/runtime dispatch with the FailedPrecondition status and an authenticated, byte-exact unsupported-version message. A v4 coordinator recognizes only that exact code/message/requested-version tuple as definitely not persisted and fails the mutation without sending that peer an incompatible Abort; Unimplemented, near-text, missing/unknown failure classes, timeouts, and every other transport outcome remain ambiguous and stay in identity-bound Abort fanout. There is no automatic v3 retry. New servers retain v3 request/proof decoding for older coordinators, while operators must pause tier edit/remove/clear during a mixed v3/v4 rollout. Remove the text classifier after the minimum supported RustFS peer version returns the signed v4 PreDispatchRejected failure class.
|
||||
- `backlog-2097-tier-delete-journal-v6` tier-delete sole-owner recovery: v6 distinguishes transactions that may replace the xl.meta free-version owner and therefore require the all-pool live-source proof. v5-and-older readers reject and retain v6 records during rolling upgrades instead of performing an unsafe remote delete. Keep v1-v5 readers for upgrade recovery and keep the downgrade prohibition while any v6 record exists; retire the fence only after every supported rollback release understands and enforces v6 proof semantics.
|
||||
- `multipart-compression-default-off-window` staged multipart disk-compression rollout: releases before the resumable legacy decompressor fail transient reads of compressed objects under mid-payload suspension, so multipart uploads advertise the compression marker only when RUSTFS_COMPRESSION_MULTIPART_ENABLED is set in addition to RUSTFS_COMPRESSION_ENABLED, keeping rolling upgrades from creating new compressed multipart objects while pre-fix nodes may still serve reads. Flip the default to enabled (and retire the extra switch) after the minimum supported direct-upgrade release ships the resumable decompressor.
|
||||
- `listing-cursor-legacy-tag` ListObjects cursor compatibility: releases up to 1.0.x appended a metacache tag ([rustfs_cache:v2,id:...,src:walker,gen:live]) to every internal continuation marker and exposed it through the base64 V2 continuation token and the ListObjectVersions NextKeyMarker; no server component consumed the tag, and it caused keys sorting between the marker and the tag to be skipped (backlog#1047). Current servers emit plain markers for ordinary keys; cache-shaped literal keys retain one legacy outer tag so both old and new readers resume after the complete key. V2 tokens remain base64 of the cursor. ListPathOptions::parse_marker decodes the cursor once at the listing entry point, and list_marker_key removes the outer tag for scanner comparisons. V1 responses remove only the conditional outer escape; the pre-existing ambiguity of incoming raw V1 markers remains during the compatibility window. Remove the tag stripping after every supported direct-upgrade release has issued plain key markers for a full release, so no client can still hold a tagged cursor.
|
||||
- `listing-cursor-legacy-tag` ListObjects cursor compatibility: releases up to 1.0.x appended a metacache tag ([rustfs_cache:v2,id:...,src:walker,gen:live]) to continuation markers. V2 tokens continue to accept that legacy form, and cache-shaped literal keys retain an outer tag in newly issued V2 cursors. V1 markers, V2 StartAfter, and ListObjectVersions markers are literal keys. Clients must restart old version-listing chains and V1 chains holding escaped markers after upgrading. Remove the V2 tag stripping after every supported direct-upgrade release has issued plain key markers for a full release, so no client can still hold a tagged cursor.
|
||||
- `s3gate-trailer-adapter` aws-chunked trailer handle bridge: s3s decodes aws-chunked request bodies and publishes their x-amz-checksum-* trailers through its own handle, while rio consumes trailers only through its framework-neutral TrailerSource trait. The RustFS application crate adapts the s3s handle at the object write path so rio carries no s3s dependency. Remove the adapter after the gateway stack replaces s3s as the request body decoder and publishes trailers through its own TrailerSource implementation.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
Status: normative. This document is the source of truth for how RustFS erasure-codes, stores, reads, reconstructs, and heals user data, and for the on-disk / on-wire compatibility contract that every future change must preserve. It governs the highest-risk code in the system: a regression here can silently corrupt or lose all user data, or make existing (and MinIO-migrated) objects permanently unreadable.
|
||||
|
||||
Erasure coding, quorum/heal, and metadata/on-disk formats are **High-risk** per [AGENTS.md](../../AGENTS.md) ("Broad or High-Risk Changes"). Any behavior-affecting change to code this document governs requires adversarial review with the `adversarial-validation` skill and, for anything touching decode or the on-disk format, a regression test against real on-disk and MinIO-migrated samples before merge.
|
||||
Erasure coding, quorum/heal, and metadata/on-disk formats are **High-risk** per [AGENTS.md](../../AGENTS.md). Any behavior-affecting change to code this document governs requires adversarial review and, for anything touching decode or the on-disk format, a regression test against real on-disk and MinIO-migrated samples before merge.
|
||||
|
||||
This document describes the algorithm as implemented on `main`. The *invariant* stated is always the rule the code must satisfy; where the code enforces it, the enforcing symbol is cited.
|
||||
|
||||
@@ -313,7 +313,7 @@ Small objects store their payload inline after the container CRC ([filemeta_inli
|
||||
## 8. Read path and read quorum
|
||||
|
||||
- **INVARIANT — read quorum = `data_blocks`.** `object_quorum_from_meta` returns `(read_quorum = data_blocks, write_quorum)` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)); `parity_blocks = common_parity(...)` is the parity value held by the most disks that still reaches its own read quorum. When `default_parity_count == 0`, read = write = all shards.
|
||||
- Authoritative FileInfo selection — `find_file_info_in_quorum` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)) groups valid metas by a content-identity SHA-256 (`file_info_quorum_hash`) that hashes size/flags/mod_time/transition/version_id/data_dir/parts and, for real objects, data/parity/distribution — **excluding replication-status keys** so replication noise never splits quorum. A meta counts only if its mod_time equals the common mod_time (or etag matches when mod_time is absent). The winning hash must reach quorum, else `ErasureReadQuorum`. Reads select at read quorum through `select_valid_fileinfo`, the same rule heal uses and MinIO's `getObjectFileInfo`: the most common mod_time wins and a tie goes to the newer one, so a write that failed after reaching exactly `data_blocks` copies is served as listing shows it, instead of `ErasureReadQuorum` until heal runs. Two identities tied at the selected mod_time (a data = parity set split in half, such as a metadata update that failed after reaching half the drives) resolve to the one on the lowest-index disk, in reads and heal alike, so a retried metadata write can repair the split. A selected version with local shards also needs as many copies as its own `data_blocks`, since delete markers and empty versions can lower the common parity's read quorum. A metadata update whose target disks cannot reach write quorum is refused before any write. A version replaced by a committed write keeps fewer than `data_blocks` copies and can never be selected again; the newer side of a failed write can be served and then lose to the older side if one of its disks fails before heal converges the set.
|
||||
- Authoritative FileInfo selection — `find_file_info_in_quorum` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)) groups valid metas by a content-identity SHA-256 (`file_info_quorum_hash`) that hashes size/flags/mod_time/transition/version_id/data_dir/parts and, for real objects, data/parity/distribution — **excluding replication-status keys** so replication noise never splits quorum. A meta counts only if its mod_time equals the common mod_time (or etag matches when mod_time is absent). The winning hash must reach quorum, else `ErasureReadQuorum`. S3 reads select at read quorum through `select_valid_fileinfo`, the same rule heal uses and MinIO's `getObjectFileInfo`: the most common mod_time wins and a tie goes to the newer one, so a write that failed after reaching exactly `data_blocks` copies is served as listing shows it, instead of `ErasureReadQuorum` until heal runs. Two identities tied at the selected mod_time (a data = parity set split in half, such as a metadata update that failed after reaching half the drives) resolve to the one on the lowest-index disk, in reads and heal alike, so a retried metadata write can repair the split. A selected version with local shards also needs as many copies as its own `data_blocks`, since delete markers and empty versions can lower the common parity's read quorum. A metadata update whose target disks cannot reach write quorum is refused before any write. A version replaced by a committed write keeps fewer than `data_blocks` copies and can never be selected again; the newer side of a failed write can be served and then lose to the older side if one of its disks fails before heal converges the set. Reads in the system bucket (`is_meta_bucketname`) keep the commit-proof rule in `select_committed_fileinfo`: when enough copies answer to prove write quorum, the latest version must hold it, and a degraded read never serves a newer version below write quorum. Pool metadata's PREPARE/COMMIT recovery and identity epochs rely on an ordinary read implying a committed record, so a split stays unreadable until native recovery resolves it. When every drive answered and an S3 latest read still cannot select a version, `read_latest_past_dangling_versions` walks the version stack newest first through exact-version reads, as MinIO's `pickLatestQuorumFilesInfo` resolves stacks: it serves the readable version with the newest selected mod_time, skips versions whose exact read proves them dangling (the NoSuchVersion an explicit GET reports) and queues heal for them, and keeps the read-quorum error on any other outcome. A failed versioned PUT that left the new version on fewer drives than its data blocks therefore no longer hides the committed version below it; ListObjectVersions can still show the dangling version as latest until heal removes it. Metadata updates of the latest version (object tagging, `put_object_metadata`) and the CopyObject source read resolve a failed selection through the same walk; when the walk finds no readable version, tagging keeps the read-quorum error while `put_object_metadata` and CopyObject keep their existing `delete_if_dangling` cleanup. Replication status write-back never walks. The update targets only the served version's disks and is refused before writing when they cannot reach its write quorum, including a version-only copy.
|
||||
- **INVARIANT — decode needs ≥ `data_blocks` shards.** The stripe reader requires `available_shards ≥ data_shards`; below that the read fails closed with a read-quorum error (never silent truncation) ([set_disk/read.rs](../../crates/ecstore/src/set_disk/read.rs), [set_disk/shard_source.rs](../../crates/ecstore/src/set_disk/shard_source.rs)). Codec geometry is validated at construction: `Erasure::try_new` / `try_new_with_options` ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)) return `ErasureConstructionError` for `data_shards == 0`, `block_size == 0`, shard-count overflow, or an unsupported shard configuration, so a read never reaches a `block_size` / `data_shards` division with invalid geometry (§13).
|
||||
- If `available ≥ data_blocks` but some shards are missing, the read is served **and** a background read-repair heal is enqueued.
|
||||
- **INVARIANT — cross-stripe read verification.** When a data shard is missing and `available > data_blocks`, reconstruction regenerates parity and compares it to the surviving parity; a mismatch is `InvalidData "inconsistent read source shards"` (backlog#832), catching corruption that passed per-shard bitrot but disagrees across the stripe ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)).
|
||||
@@ -400,7 +400,7 @@ Decode tolerance
|
||||
|
||||
## 13. Change procedure and guardrails
|
||||
|
||||
- **Risk tier.** All of the above is High-risk ([AGENTS.md](../../AGENTS.md) "Broad or High-Risk Changes"). Any behavior-affecting change requires adversarial review with the `adversarial-validation` skill.
|
||||
- **Risk tier.** All of the above is High-risk ([AGENTS.md](../../AGENTS.md)). Any behavior-affecting change requires adversarial review.
|
||||
- **Keep this document in sync.** A change to any governed behavior, formula, format field, or invariant must update this spec in the same PR; renaming a cited symbol must update its reference here. The spec is normative and is the checklist the next change is reviewed against, so drift is a correctness defect. References are symbol-based (not line numbers) specifically so ordinary refactors do not invalidate them — but semantic changes still must.
|
||||
- **Adding an on-disk field** must be additive: new msgpack key or a `minor`/`meta_ver` bump with a read path for the old value; keep decoders skipping unknown keys; write both internal-key prefixes; never repurpose or reorder existing keys or header array positions.
|
||||
- **Never make a decode boundary stricter** than what §11 allows without (a) proving no legitimate older-RustFS or MinIO-migrated shape is rejected, and (b) a regression test against real on-disk and MinIO fixtures. New validation belongs at the trust boundary and must fail *open to a tolerant default*, not closed to `FileCorrupt`, for anything recoverable. (Concretely: rejecting a negative `actual_size`, or hard-failing a non-16-byte `transitioned-versionID`, breaks existing data — see §11.)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Read-only BTF prototype inspection
|
||||
# Read-only BTF declaration inspection
|
||||
|
||||
Use `scripts/inspect_btf_prototypes.py` to inspect declared function types in a
|
||||
Use `scripts/inspect_btf_prototypes.py` to inspect declared function types or selected
|
||||
integer member layouts in a
|
||||
raw, little-endian BTF v1 regular file. Symlinks, devices and pipes are rejected.
|
||||
The command reads the named file and writes one
|
||||
JSON document to stdout. It does not load BPF, attach probes, read kernel memory,
|
||||
@@ -18,14 +19,37 @@ parameter type IDs, qualifier chains, and resolved declarations. A missing or
|
||||
ambiguous function is an error, rather than a substitute selected by proximity.
|
||||
The schema version is `1`.
|
||||
|
||||
Selected type records expose their common BTF headers. Kind-specific payloads
|
||||
and recursive layouts are not decoded by this command; use an independent BTF
|
||||
dump when those records are required. A reported declaration is not a complete
|
||||
C type, layout proof or runtime calling convention.
|
||||
Prototype records expose common BTF headers. A reported prototype is not a
|
||||
complete C type, recursive layout proof or runtime calling convention.
|
||||
|
||||
For integer counters, select one exact struct and one or more named member paths:
|
||||
|
||||
```bash
|
||||
python3 scripts/inspect_btf_prototypes.py \
|
||||
--btf /sys/kernel/btf/vmlinux \
|
||||
--struct bpf_ringbuf \
|
||||
--field mask --field consumer_pos --field producer_pos --field pending_pos
|
||||
```
|
||||
|
||||
This mode validates the selected struct member records, qualifier chains and
|
||||
integer payloads. `scalar_fields` preserves request order and reports each
|
||||
declared byte offset, width, integer encoding flags and member path. Nested
|
||||
structs are supported; every selected span must fit its declared container.
|
||||
Only complete 1, 2, 4 or 8-byte integers are supported. Bitfields (including
|
||||
legacy integer-offset encodings), pointer traversal, pointer/array/union leaves,
|
||||
ambiguous members, missing types and by-value cycles are errors. No pointer
|
||||
width is inferred. Byte alignment does not guarantee natural machine alignment.
|
||||
|
||||
`--struct` requires `--field` and cannot be combined with `--function`. Up to
|
||||
16 unique paths, each containing at most 32 named members, are accepted. A
|
||||
failure in any selected path rejects the whole report before emitting JSON.
|
||||
The mode sets `runtime_ABI_verified` to `false`: it does not bind BTF to executing
|
||||
machine code, pointer values, loaded images or an observer's admission contract.
|
||||
Only requested paths are validated, not the complete BTF graph or all C layouts.
|
||||
|
||||
The reader supports BTF kinds 1 through 19, indexes at most 500,000 records,
|
||||
reads at most 32 MiB plus one overflow-detection byte, limits names to 4,096
|
||||
bytes, follows at most 32 qualifier records, and accepts at most 16 parameters
|
||||
bytes, follows at most 32 type records per qualifier chain, and accepts at most 16 parameters
|
||||
per selected prototype. Reports above 1 MiB are rejected before any JSON prefix
|
||||
is emitted. Invalid/truncated/unsupported source, missing resolved type IDs,
|
||||
malformed pointer/qualifier headers, and ambiguous selectors exit nonzero with
|
||||
@@ -47,8 +71,11 @@ python3 -B scripts/test_inspect_btf_prototypes.py
|
||||
|
||||
The `BTF prototype reference` workflow additionally compiles a small C fixture,
|
||||
extracts its raw `.BTF` section, and independently compares both selected
|
||||
declarations and resolved types against `bpftool` output. The object is never
|
||||
loaded. The workflow selects a concrete `bpftool` executable by availability and
|
||||
prototypes, scalar layouts and resolved types against `bpftool` output. A separate
|
||||
ordinary C executable uses compiler `offsetof`/`sizeof` to check layout offsets
|
||||
and widths, including nested and packed members; it does not parse BTF or load
|
||||
probes. The BPF object is never loaded. The workflow selects a concrete
|
||||
`bpftool` executable by availability and
|
||||
version before comparing data; it does not depend on the hosted kernel's tools
|
||||
wrapper or switch tools after a comparison failure. The native
|
||||
[file-dump mode](https://github.com/libbpf/bpftool/blob/main/docs/bpftool-btf.rst)
|
||||
|
||||
@@ -64,12 +64,14 @@ A single local path (`rustfs D:\rustfs\data`) is a single-node single-drive depl
|
||||
|
||||
An empty `RUSTFS_STORAGE_CLASS_STANDARD` or `RUSTFS_STORAGE_CLASS_RRS` restores the automatic policy and overrides a persisted value (`standard_policy` / `rrs_policy`). `MINIO_`-prefixed spellings of these keys, of `ERASURE_SET_DRIVE_COUNT`, and of `VOLUMES` are mapped onto the `RUSTFS_` names when the `RUSTFS_` name is unset (`apply_external_env_compat` in `crates/utils/src/envs.rs`); when both are set the `RUSTFS_` value wins and the conflict is reported at startup.
|
||||
|
||||
Heal knobs (`RUSTFS_HEAL_*`) and their defaults are documented once, in [scanner-runtime-controls.md](scanner-runtime-controls.md#heal-runtime-controls); the deep bitrot cycle is the admin config key `heal.bitrot_cycle` (environment `RUSTFS_SCANNER_BITROT_CYCLE_SECS`), not a `RUSTFS_HEAL_*` variable. Data-movement knobs: `RUSTFS_REBALANCE_MAX_ATTEMPTS` (default `DEFAULT_REBALANCE_MAX_ATTEMPTS` = 3, `crates/ecstore/src/services/rebalance/mod.rs`), `RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY` (default cap 4) and `RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY` (default cap 8, hard cap 64) in `crates/ecstore/src/core/pools.rs`, and `RUSTFS_LIST_OBJECTS_QUORUM` (default `strict`, which reads every online drive of the set; `disk`, `reduced`, `optimal` and `auto` read a sample, and anything else means `strict`, per `normalize_list_quorum` in `crates/ecstore/src/store/list_objects.rs`). `optimal` reads about half of the drives, so a deleted object can briefly reappear in a listing while the sampled drives still hold a stale copy; a GET or HEAD of that key still returns 404. A sampled listing can also omit a committed object whose copies mostly sit outside the sample: an object written with more parity than the set default, for example `EC:8` on a 16-drive set whose default is `EC:4`, can hold copies on only nine drives, and a random eight of the sixteen then holds fewer than the four copies a listing needs in roughly one listing in six. `disk` and `reduced` sample fewer drives and have both limits more often. Keep `strict` wherever every committed object must appear, such as backup, sync, replication and lifecycle tooling. None of them relaxes object quorum, object locks, or version checks.
|
||||
Heal knobs (`RUSTFS_HEAL_*`) and their defaults are documented once, in [scanner-runtime-controls.md](scanner-runtime-controls.md#heal-runtime-controls); the deep bitrot cycle is the admin config key `heal.bitrot_cycle` (environment `RUSTFS_SCANNER_BITROT_CYCLE_SECS`), not a `RUSTFS_HEAL_*` variable. Data-movement knobs: `RUSTFS_REBALANCE_MAX_ATTEMPTS` (default `DEFAULT_REBALANCE_MAX_ATTEMPTS` = 3, `crates/ecstore/src/services/rebalance/mod.rs`), `RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY` (default cap 4) and `RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY` (default cap 8, hard cap 64) in `crates/ecstore/src/core/pools.rs`, and `RUSTFS_LIST_OBJECTS_QUORUM` (default `strict`, which reads every online drive of the set; `disk`, `reduced`, `optimal` and `auto` read a sample, and anything else means `strict`, per `normalize_list_quorum` in `crates/ecstore/src/store/list_objects.rs`). `strict` reads all online drives, but resolves entries using a quorum of those drives. After a successful delete, a change in which drives are offline can leave enough stale copies online for the deleted key to reappear in LIST; GET or HEAD can return 404 or 503. Restore the drives and allow healing to complete before relying on degraded listings for backup, sync, replication, or lifecycle work. `optimal` samples about half the drives and can also omit a committed object whose copies mostly sit outside the sample: an `EC:8` object committed on nine of sixteen drives can miss a four-copy sample quorum. `disk` and `reduced` sample fewer drives and encounter these limits more often. Keep the default `strict` for work that needs a complete listing, while accounting for the degraded-drive limitation above. None of these settings changes object read or write quorum.
|
||||
|
||||
### 3.1 Retired listing index: upgrade and rollback
|
||||
|
||||
ListObjects uses the disk walker; the experimental `RUSTFS_LIST_OBJECTS_INDEX_*` provider is retired. New writers no longer update its namespace mutation journal. An older node can therefore mistake a persistent index snapshot for a current one and omit newly written keys. Deployments that never used persistent index snapshots need no additional rollout step.
|
||||
|
||||
After all nodes have upgraded to literal listing markers, restart any unfinished version-listing pagination from the beginning, including manual transition jobs resumed from older checkpoints. Restart V1 pagination chains holding escaped markers as well. During a mixed-version rollout, old and new nodes interpret cache-shaped key markers differently, so do not continue those pagination chains until the upgrade is complete.
|
||||
|
||||
For deployments that used persistent snapshots:
|
||||
|
||||
1. Before replacing any binary, first restart every old node with `RUSTFS_LIST_OBJECTS_INDEX_MODE` unset. Verify the running configuration on every node and finish this configuration rollout while all nodes still use the old binary.
|
||||
|
||||
@@ -143,6 +143,39 @@ Rules that override the table: a candidate result is actionable only when the `A
|
||||
|
||||
### CPU and memory evidence
|
||||
|
||||
#### Local shard batch attribution
|
||||
|
||||
The existing GET-stage metric gate also controls local batch attribution. This
|
||||
path performs buffered positioned reads, even if O_DIRECT or io_uring is enabled
|
||||
for another backend. Keep the gate, exporter, and build features identical
|
||||
between compared binaries; calibrate instrumentation overhead separately.
|
||||
|
||||
| Metric suffix after `rustfs_io_get_object_local_batch_` | Meaning |
|
||||
| --- | --- |
|
||||
| `decisions_total` | Reader-setup decisions, labelled by a fixed decision enum; this is not an S3 request counter. |
|
||||
| `prepare_seconds` | Eligibility and path preparation, including pre-worker path checks; absent when preparation was skipped. |
|
||||
| `stage_seconds` | Queue wait, whole-worker execution, and per-shard metadata/open/read durations, labelled by stage and operation outcome. |
|
||||
| `shards` / `shards_total` | Batch size and completed shard outcomes. |
|
||||
| `requested_bytes_total` / `read_bytes_total` | Requested bytes and bytes actually returned by positioned reads, split by shard outcome. |
|
||||
|
||||
Worker handles are captured before dispatch and remain owned by the worker, so
|
||||
completion samples can arrive after the caller stops waiting. A stage that did
|
||||
not execute contributes no sample. Queue wait ends when the worker starts;
|
||||
execution includes allocation and instrumentation, while read timing excludes
|
||||
buffer allocation. These nested and per-shard durations cannot be added to
|
||||
request latency, and stage p99 values cannot be summed. Match request-boundary
|
||||
samples and successful-request counters to the same workload window when
|
||||
normalizing costs. Keep failed requests and partial-shard failures visible.
|
||||
Inline GET and HEAD can bypass this reader-setup entry entirely; absence of a
|
||||
decision sample does not imply a failed request. The two local duration metrics
|
||||
use dedicated 0.1-microsecond-to-10-second histogram boundaries in the OTLP view;
|
||||
request-duration histogram boundaries are separate.
|
||||
|
||||
For a bounded, four-shard instrumentation microbenchmark, run
|
||||
`cargo bench -p rustfs-io-metrics --bench metrics_pipeline -- local_batch_attribution_4_shards_debug_recorder`.
|
||||
It performs no disk I/O and uses a debugging recorder, so its result calibrates
|
||||
the sampling code rather than production exporter overhead or S3 latency.
|
||||
|
||||
Warp output says whether throughput or latency changed; host telemetry says why. Collect it for the whole run and stop the collectors after the script exits.
|
||||
|
||||
| Tool | Command | Answers |
|
||||
|
||||
@@ -30,7 +30,7 @@ The same thing was missing at every layer: an inventory of what remote targets m
|
||||
|
||||
- **Change design.** A default that applies to every target was changed to satisfy one target class. The PR text considered checksums only for objects that already carried one ("object-level `x-amz-checksum-*` forwarding is unaffected") and never asked the inverse question: which target-side rules depend on a checksum being present.
|
||||
- **Tests.** The new unit test asserted the fix ("no `x-amz-trailer`"), not the contract ("the target accepted the PUT"). The fake target modeled neither failure mode: it did not store `aws-chunked` verbatim and it did not demand a checksum on locked PUTs. `replication-check` has an ObjectLock phase, but its probe PUT carries no retention headers, so it would have reported OK against the customer's target.
|
||||
- **Review.** The compatibility lens (`.agents/skills/adversarial-validation/references/compatibility.md`) listed only inbound and on-disk concerns: `xl.meta`, proto, MinIO fixtures. It contained no line about outbound target behavior, so the lens passed while blind to the class.
|
||||
- **Review.** The compatibility review guidance listed only inbound and on-disk concerns: `xl.meta`, proto, MinIO fixtures. It contained no line about outbound target behavior, so the review passed while blind to the class.
|
||||
- **Release.** Two new environment escape hatches shipped undocumented. The customer's second question in rustfs#7082 was whether a configuration option exists; it did, and it was unfindable.
|
||||
|
||||
## Corrective actions
|
||||
@@ -39,7 +39,7 @@ The same thing was missing at every layer: an inventory of what remote targets m
|
||||
| --- | --- |
|
||||
| Outbound target matrix e2e: every target failure mode times every object shape, with an explicit expectation table. Known-red cells are pinned to an open issue and fail loudly when they start passing. | `crates/e2e_test/src/replication_target_matrix_test.rs` |
|
||||
| Fake target gains the three failure modes a real fleet has shown: rejects `aws-chunked` uploads, requires a checksum on Object Lock PUTs (and verifies `Content-MD5`), mints its own version ids. | `crates/e2e_test/src/fake_s3_target/mod.rs` |
|
||||
| Compatibility lens gains an outbound target section. | `.agents/skills/adversarial-validation/references/compatibility.md` |
|
||||
| Review policy covers outbound target behavior. | `.agents/references/adversarial-validation.md` |
|
||||
| `AGENTS.md` names outbound client defaults as high risk and requires the matrix plus documented escape hatches. | `AGENTS.md`, Adversarial Validation |
|
||||
| The two escape hatches from rustfs#6895 are documented. | `docs/operations/replication-outbound-transport.md` |
|
||||
| Product fix for rustfs#7082: a locked PUT carries `Content-MD5` derived from the source ETag when that ETag is the MD5 of the wire bytes, and an SDK CRC32 checksum otherwise; the two matrix cells flipped to `Completed` in the same change. | `crates/ecstore/src/bucket/bucket_target_sys.rs` (`object_lock_put_integrity_for`), `crates/replication/src/object.rs` (`object_lock_put_integrity`) |
|
||||
|
||||
Generated
+746
-914
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,18 @@ rustfs-security-governance = { path = "../crates/security-governance" }
|
||||
rustfs-utils = { path = "../crates/utils", features = ["compress", "path"] }
|
||||
serde_json = "1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Standalone workspaces must repeat the root workspace's fixed SigV4a backend.
|
||||
aws-sigv4 = { git = "https://github.com/smithy-lang/smithy-rs", rev = "5c1c4bca2ea7af97496e077c9f1a718eaac64bf8" }
|
||||
|
||||
[patch."https://github.com/smithy-lang/smithy-rs"]
|
||||
# Keep the signer's path dependencies on the SDK's existing registry types.
|
||||
aws-credential-types = { version = "=1.3.1" }
|
||||
aws-smithy-eventstream = { version = "=0.61.6" }
|
||||
aws-smithy-http = { version = "=0.64.3" }
|
||||
aws-smithy-runtime-api = { version = "=1.19.2" }
|
||||
aws-smithy-types = { version = "=1.9.1" }
|
||||
|
||||
[[bin]]
|
||||
name = "bucket_validation"
|
||||
path = "fuzz_targets/bucket_validation.rs"
|
||||
|
||||
@@ -1503,6 +1503,11 @@ mod pools_handler_tests {
|
||||
decommission_status: "none".to_string(),
|
||||
rebalance_status: "none".to_string(),
|
||||
decommission: None,
|
||||
availability: None,
|
||||
pool_metadata_status: None,
|
||||
rebalance_metadata_status: None,
|
||||
capacity_status: None,
|
||||
observed_at: None,
|
||||
},
|
||||
admin_discovery: super::PoolAdminDiscovery {
|
||||
runtime_capabilities: "/rustfs/admin/v4/runtime/capabilities".to_string(),
|
||||
|
||||
@@ -33,12 +33,15 @@ impl Operation for EnableTableBucketHandler {
|
||||
let warehouse = warehouse_from_params(¶ms)?;
|
||||
let resource = TableCatalogResource::warehouse(&warehouse);
|
||||
authorize_table_catalog_resource_request(&req, &resource, AdminAction::SetTableBucketAction).await?;
|
||||
let app_context = runtime_sources::app_context_from_extensions(&req.extensions);
|
||||
let backend = table_catalog_backend_from_extensions(&req.extensions)?;
|
||||
let object_store = runtime_sources::object_store_from_req(&req)
|
||||
.ok_or_else(|| table_catalog_internal_error("request object store is not initialized"))?;
|
||||
let store = table_catalog_store_from_backend(backend.clone())?;
|
||||
let publication = TableCommitObjectBackend::preauthorized(backend);
|
||||
let response = enable_table_bucket_response(&store, &publication, object_store.as_ref(), &warehouse).await?;
|
||||
crate::app::bucket_usecase::notify_table_bucket_metadata_reload(warehouse.clone(), "enable table bucket", app_context)
|
||||
.await;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
@@ -51,12 +54,15 @@ impl Operation for DisableTableBucketHandler {
|
||||
let warehouse = warehouse_from_params(¶ms)?;
|
||||
let resource = TableCatalogResource::warehouse(&warehouse);
|
||||
authorize_table_catalog_resource_request(&req, &resource, AdminAction::SetTableBucketAction).await?;
|
||||
let app_context = runtime_sources::app_context_from_extensions(&req.extensions);
|
||||
let backend = table_catalog_backend_from_extensions(&req.extensions)?;
|
||||
let object_store = runtime_sources::object_store_from_req(&req)
|
||||
.ok_or_else(|| table_catalog_internal_error("request object store is not initialized"))?;
|
||||
let store = table_catalog_store_from_backend(backend.clone())?;
|
||||
let publication = TableCommitObjectBackend::preauthorized(backend);
|
||||
let response = disable_table_bucket_response(&store, &publication, object_store.as_ref(), &warehouse).await?;
|
||||
crate::app::bucket_usecase::notify_table_bucket_metadata_reload(warehouse.clone(), "disable table bucket", app_context)
|
||||
.await;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1679,6 +1679,26 @@ fn enable_table_bucket_response_fences_before_marker_and_catalog_entry() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_bucket_toggle_handlers_reload_peer_metadata_after_publication() {
|
||||
let src = table_catalog_handler_source();
|
||||
let enable = operation_block(&src, "EnableTableBucketHandler");
|
||||
let disable = operation_block(&src, "DisableTableBucketHandler");
|
||||
|
||||
for (block, response, operation) in [
|
||||
(enable, "enable_table_bucket_response", "enable table bucket"),
|
||||
(disable, "disable_table_bucket_response", "disable table bucket"),
|
||||
] {
|
||||
let response_offset = block
|
||||
.find(&format!("let response = {response}"))
|
||||
.expect("table bucket toggle should publish local state");
|
||||
let reload = block
|
||||
.find(&format!("notify_table_bucket_metadata_reload(warehouse.clone(), \"{operation}\""))
|
||||
.expect("table bucket toggle should reload peer metadata");
|
||||
assert!(response_offset < reload, "peer reload must follow local publication");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_commit_publication_acquires_migration_permits_before_publication_lock() {
|
||||
let src = table_catalog_handler_source();
|
||||
|
||||
+159
-12
@@ -16,7 +16,7 @@
|
||||
|
||||
use super::storage_api::admin_usecase::admin::get_server_info;
|
||||
use super::storage_api::admin_usecase::capacity::{
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, RebalStatus, RebalanceMeta,
|
||||
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolDecommissionInfo, PoolMeta, PoolStatus, RebalStatus, RebalanceMeta,
|
||||
get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
};
|
||||
use super::storage_api::admin_usecase::contract::StorageAdminApi;
|
||||
@@ -143,6 +143,20 @@ pub struct AdminPoolStatus {
|
||||
pub rebalance_status: String,
|
||||
#[serde(rename = "decommissionInfo")]
|
||||
pub decommission: Option<AdminPoolDecommissionInfo>,
|
||||
#[serde(rename = "availability", skip_serializing_if = "Option::is_none")]
|
||||
pub availability: Option<String>,
|
||||
#[serde(rename = "poolMetadataStatus", skip_serializing_if = "Option::is_none")]
|
||||
pub pool_metadata_status: Option<String>,
|
||||
#[serde(rename = "rebalanceMetadataStatus", skip_serializing_if = "Option::is_none")]
|
||||
pub rebalance_metadata_status: Option<String>,
|
||||
#[serde(rename = "capacityStatus", skip_serializing_if = "Option::is_none")]
|
||||
pub capacity_status: Option<String>,
|
||||
#[serde(
|
||||
rename = "observedAt",
|
||||
with = "time::serde::rfc3339::option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub observed_at: Option<time::OffsetDateTime>,
|
||||
}
|
||||
|
||||
pub type AdminPoolListItem = AdminPoolStatus;
|
||||
@@ -416,12 +430,19 @@ impl DefaultAdminUsecase {
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
||||
};
|
||||
let pool_statuses = self.execute_list_pool_statuses().await?;
|
||||
let rebalance_meta = Self::rebalance_status_snapshot(store.as_ref()).await?;
|
||||
let mut items = Vec::with_capacity(pool_statuses.len());
|
||||
for status in pool_statuses {
|
||||
let rebalance_status = ECStore::pool_rebalance_status(rebalance_meta.as_ref(), status.id);
|
||||
items.push(Self::pool_list_item_from_status(status, rebalance_status));
|
||||
let (pool_diagnostics, pool_metadata_status) = store.admin_pool_diagnostic_snapshot(None).await;
|
||||
let (rebalance_meta, rebalance_metadata_status) = store.rebalance_admin_diagnostic_snapshot().await;
|
||||
let observed_at = time::OffsetDateTime::now_utc();
|
||||
let mut items = Vec::with_capacity(pool_diagnostics.len());
|
||||
for diagnostic in pool_diagnostics {
|
||||
let rebalance_status = ECStore::pool_rebalance_status(rebalance_meta.as_ref(), diagnostic.id);
|
||||
items.push(Self::pool_list_item_from_diagnostic(
|
||||
diagnostic,
|
||||
rebalance_status,
|
||||
pool_metadata_status,
|
||||
rebalance_metadata_status,
|
||||
observed_at,
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
@@ -456,11 +477,19 @@ impl DefaultAdminUsecase {
|
||||
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
||||
};
|
||||
|
||||
let pool_meta = Self::pool_status_snapshot(store.as_ref()).await?;
|
||||
let status = store.status(&pool_meta, idx).await.map_err(ApiError::from)?;
|
||||
let rebalance_meta = Self::rebalance_status_snapshot(store.as_ref()).await?;
|
||||
let (mut diagnostics, pool_metadata_status) = store.admin_pool_diagnostic_snapshot(Some(idx)).await;
|
||||
let diagnostic = diagnostics
|
||||
.pop()
|
||||
.ok_or_else(|| Self::app_error_default(S3ErrorCode::InvalidArgument))?;
|
||||
let (rebalance_meta, rebalance_metadata_status) = store.rebalance_admin_diagnostic_snapshot().await;
|
||||
let rebalance_status = ECStore::pool_rebalance_status(rebalance_meta.as_ref(), idx);
|
||||
Ok(Self::pool_list_item_from_status(status, rebalance_status))
|
||||
Ok(Self::pool_list_item_from_diagnostic(
|
||||
diagnostic,
|
||||
rebalance_status,
|
||||
pool_metadata_status,
|
||||
rebalance_metadata_status,
|
||||
time::OffsetDateTime::now_utc(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn execute_list_decommission_status(&self) -> AdminUsecaseResult<AdminDecommissionStatus> {
|
||||
@@ -535,9 +564,63 @@ impl DefaultAdminUsecase {
|
||||
decommission_status: decommission_status.to_string(),
|
||||
rebalance_status: rebalance_status.to_string(),
|
||||
decommission: decommission.map(Self::admin_decommission_info_from_pool),
|
||||
availability: None,
|
||||
pool_metadata_status: None,
|
||||
rebalance_metadata_status: None,
|
||||
capacity_status: None,
|
||||
observed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_list_item_from_diagnostic(
|
||||
diagnostic: PoolAdminDiagnostic,
|
||||
rebalance_status: (RebalStatus, bool),
|
||||
pool_metadata_status: &'static str,
|
||||
rebalance_metadata_status: &'static str,
|
||||
observed_at: time::OffsetDateTime,
|
||||
) -> AdminPoolListItem {
|
||||
let availability = diagnostic.diagnostic.availability.as_str().to_string();
|
||||
let capacity_status = diagnostic.diagnostic.capacity_status.as_str().to_string();
|
||||
let mut item = match diagnostic.pool {
|
||||
Some(status) => Self::pool_list_item_from_status(status, rebalance_status),
|
||||
None => AdminPoolStatus {
|
||||
id: diagnostic.id,
|
||||
cmd_line: diagnostic.cmd_line,
|
||||
last_update: time::OffsetDateTime::UNIX_EPOCH,
|
||||
total_size: 0,
|
||||
current_size: 0,
|
||||
used_size: 0,
|
||||
used: 0.0,
|
||||
status: Self::POOL_STATUS_UNKNOWN.to_string(),
|
||||
decommission_status: Self::POOL_STATUS_UNKNOWN.to_string(),
|
||||
rebalance_status: Self::POOL_STATUS_UNKNOWN.to_string(),
|
||||
decommission: None,
|
||||
availability: None,
|
||||
pool_metadata_status: None,
|
||||
rebalance_metadata_status: None,
|
||||
capacity_status: None,
|
||||
observed_at: None,
|
||||
},
|
||||
};
|
||||
item.availability = Some(availability);
|
||||
item.pool_metadata_status = Some(pool_metadata_status.to_string());
|
||||
item.rebalance_metadata_status = Some(rebalance_metadata_status.to_string());
|
||||
item.capacity_status = Some(capacity_status);
|
||||
item.observed_at = Some(observed_at);
|
||||
if pool_metadata_status == "unknown" {
|
||||
item.status = Self::POOL_STATUS_UNKNOWN.to_string();
|
||||
item.decommission_status = Self::POOL_STATUS_UNKNOWN.to_string();
|
||||
item.decommission = None;
|
||||
}
|
||||
if rebalance_metadata_status != "fresh" {
|
||||
item.rebalance_status = Self::POOL_STATUS_UNKNOWN.to_string();
|
||||
if item.decommission.is_none() {
|
||||
item.status = Self::POOL_STATUS_UNKNOWN.to_string();
|
||||
}
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
fn pool_lifecycle_state(decommission: Option<&PoolDecommissionInfo>, rebalance_status: &'static str) -> &'static str {
|
||||
match decommission {
|
||||
Some(info) if info.complete => Self::POOL_STATE_DECOMMISSIONED,
|
||||
@@ -701,9 +784,73 @@ impl DefaultAdminUsecase {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::storage_api::admin_usecase::capacity::{DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus};
|
||||
use super::super::storage_api::admin_usecase::capacity::{
|
||||
DecommissionUnresolvedEntry, PoolAvailability, PoolCapacityStatus, PoolDecommissionInfo, PoolDiagnosticStatus, PoolStatus,
|
||||
};
|
||||
use super::*;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
fn pool_diagnostic(
|
||||
pool: Option<PoolStatus>,
|
||||
availability: PoolAvailability,
|
||||
capacity_status: PoolCapacityStatus,
|
||||
) -> PoolAdminDiagnostic {
|
||||
PoolAdminDiagnostic {
|
||||
id: pool.as_ref().map_or(1, |pool| pool.id),
|
||||
cmd_line: pool
|
||||
.as_ref()
|
||||
.map_or_else(|| "pool-1".to_string(), |pool| pool.cmd_line.clone()),
|
||||
pool,
|
||||
diagnostic: PoolDiagnosticStatus {
|
||||
availability,
|
||||
capacity_status,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_pool_diagnostic_serializes_availability_and_freshness() {
|
||||
let item = DefaultAdminUsecase::pool_list_item_from_diagnostic(
|
||||
pool_diagnostic(
|
||||
Some(PoolStatus {
|
||||
id: 1,
|
||||
cmd_line: "pool-1".to_string(),
|
||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||
decommission: None,
|
||||
}),
|
||||
PoolAvailability::Offline,
|
||||
PoolCapacityStatus::Cached,
|
||||
),
|
||||
(RebalStatus::Started, false),
|
||||
"stale",
|
||||
"unknown",
|
||||
OffsetDateTime::UNIX_EPOCH,
|
||||
);
|
||||
|
||||
assert_eq!(item.availability.as_deref(), Some("offline"));
|
||||
assert_eq!(item.pool_metadata_status.as_deref(), Some("stale"));
|
||||
assert_eq!(item.rebalance_metadata_status.as_deref(), Some("unknown"));
|
||||
assert_eq!(item.capacity_status.as_deref(), Some("cached"));
|
||||
assert_eq!(item.status, "unknown", "unverified rebalance state must not define lifecycle");
|
||||
assert_eq!(item.rebalance_status, "unknown");
|
||||
assert_eq!(item.observed_at, Some(OffsetDateTime::UNIX_EPOCH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_pool_diagnostic_without_metadata_never_fabricates_active_state() {
|
||||
let item = DefaultAdminUsecase::pool_list_item_from_diagnostic(
|
||||
pool_diagnostic(None, PoolAvailability::Unknown, PoolCapacityStatus::Unknown),
|
||||
(RebalStatus::None, false),
|
||||
"unknown",
|
||||
"unknown",
|
||||
OffsetDateTime::UNIX_EPOCH,
|
||||
);
|
||||
|
||||
assert_eq!(item.status, "unknown");
|
||||
assert_eq!(item.decommission_status, "unknown");
|
||||
assert_eq!(item.rebalance_status, "unknown");
|
||||
assert_eq!(item.total_size, 0);
|
||||
}
|
||||
use tracing_subscriber::{Layer, Registry, layer::Context, prelude::*};
|
||||
|
||||
struct RejectEvents;
|
||||
|
||||
@@ -631,6 +631,14 @@ async fn notify_bucket_metadata_reload(
|
||||
notify_bucket_metadata_reload_with_context(bucket, operation, request_context, scanner_maintenance_change, None).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_table_bucket_metadata_reload(
|
||||
bucket: String,
|
||||
operation: &'static str,
|
||||
app_context: Option<Arc<AppContext>>,
|
||||
) {
|
||||
notify_bucket_metadata_reload_with_context(bucket, operation, None, false, app_context).await;
|
||||
}
|
||||
|
||||
async fn notify_bucket_metadata_reload_with_context(
|
||||
bucket: String,
|
||||
operation: &'static str,
|
||||
|
||||
@@ -70,6 +70,13 @@ pub(crate) mod admin {
|
||||
|
||||
pub(crate) mod capacity {
|
||||
pub(crate) type DecommissionUnresolvedEntry = crate::storage::storage_api::ecstore_capacity::DecommissionUnresolvedEntry;
|
||||
pub(crate) type PoolAdminDiagnostic = crate::storage::storage_api::ecstore_capacity::PoolAdminDiagnostic;
|
||||
#[cfg(test)]
|
||||
pub(crate) type PoolAvailability = crate::storage::storage_api::ecstore_capacity::PoolAvailability;
|
||||
#[cfg(test)]
|
||||
pub(crate) type PoolCapacityStatus = crate::storage::storage_api::ecstore_capacity::PoolCapacityStatus;
|
||||
#[cfg(test)]
|
||||
pub(crate) type PoolDiagnosticStatus = crate::storage::storage_api::ecstore_capacity::PoolDiagnosticStatus;
|
||||
pub(crate) type PoolDecommissionInfo = crate::storage::storage_api::ecstore_capacity::PoolDecommissionInfo;
|
||||
pub(crate) type PoolMeta = crate::storage::storage_api::ecstore_capacity::PoolMeta;
|
||||
pub(crate) type PoolStatus = crate::storage::storage_api::ecstore_capacity::PoolStatus;
|
||||
|
||||
@@ -185,16 +185,17 @@ pub(crate) async fn list_objects_v2(
|
||||
}
|
||||
|
||||
for page_index in 0..TABLE_LIST_MAX_RAW_PAGES {
|
||||
// Table cursors store literal object names, not ecstore continuation tokens.
|
||||
let raw = store
|
||||
.clone()
|
||||
.list_objects_v2(
|
||||
bucket,
|
||||
prefix,
|
||||
marker.clone(),
|
||||
None,
|
||||
None,
|
||||
TABLE_LIST_RAW_PAGE_SIZE,
|
||||
false,
|
||||
raw_start_after.take(),
|
||||
marker.clone().or_else(|| raw_start_after.take()),
|
||||
incl_deleted,
|
||||
)
|
||||
.await
|
||||
@@ -634,6 +635,59 @@ mod tests {
|
||||
use crate::app::storage_api::bucket_usecase::StorageObjectInfo as ObjectInfo;
|
||||
use crate::app::storage_api::multipart_usecase::contract::multipart::MultipartInfo;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn protected_table_list_resumes_after_a_literal_cache_shaped_key() {
|
||||
use super::{ListObjectsV2Request, list_objects_v2};
|
||||
use crate::app::storage_api::bucket_usecase::access::TableDataPlaneListAccess;
|
||||
use crate::app::storage_api::test::StoragePutObjReader;
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use crate::app::storage_api::test::contract::object::ObjectIO as _;
|
||||
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let bucket = format!("table-marker-{}", uuid::Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create test bucket");
|
||||
let keys = [
|
||||
"tables/table-id/a0",
|
||||
"tables/table-id/a1",
|
||||
"tables/table-id/a[rustfs_cache:v2,return:]",
|
||||
"tables/table-id/z",
|
||||
];
|
||||
for key in keys.into_iter().chain(["tables/zz-denied/hidden"]) {
|
||||
store
|
||||
.put_object(&bucket, key, &mut StoragePutObjReader::from_vec(vec![1]), &Default::default())
|
||||
.await
|
||||
.expect("seed object");
|
||||
}
|
||||
let access = TableDataPlaneListAccess::test_listing_fixture(&bucket);
|
||||
let mut token = None;
|
||||
for (index, key) in keys.into_iter().enumerate() {
|
||||
let page = list_objects_v2(
|
||||
store.clone(),
|
||||
&access,
|
||||
ListObjectsV2Request {
|
||||
bucket: &bucket,
|
||||
prefix: "",
|
||||
continuation_token: token.as_deref(),
|
||||
delimiter: None,
|
||||
max_keys: 1,
|
||||
start_after: None,
|
||||
incl_deleted: false,
|
||||
opaque_cursor_supported: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("list next protected page");
|
||||
assert_eq!(page.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
|
||||
assert_eq!(page.is_truncated, index + 1 < keys.len());
|
||||
token = page.next_continuation_token;
|
||||
}
|
||||
assert!(token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_list_entry_keeps_an_object_without_a_delimiter() {
|
||||
let object = ObjectInfo {
|
||||
|
||||
@@ -42,6 +42,12 @@ use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
use crate::connect::DeviceIdentity;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "perf_drive/test_diagnostics.rs"]
|
||||
mod test_diagnostics;
|
||||
#[cfg(test)]
|
||||
use self::test_diagnostics::IoDiagnostic;
|
||||
|
||||
pub const DRIVE_SCHEMA_VERSION: u16 = 1;
|
||||
pub const DRIVE_TOOL_ID: &str = "performance.drive";
|
||||
pub const DRIVE_CAPABILITY: &str = "performance.drive@1";
|
||||
@@ -389,13 +395,36 @@ pub async fn measure_drive(
|
||||
let _lease = CollectorLease::acquire()?;
|
||||
let started = Instant::now();
|
||||
let deadline = tokio::time::Instant::now() + request.duration;
|
||||
#[cfg(test)]
|
||||
let scratch_diagnostic = IoDiagnostic::new("scratch_create", 0, 0, 0);
|
||||
#[cfg(test)]
|
||||
scratch_diagnostic.emit("start");
|
||||
let mut scratch = match ScratchGuard::create(request) {
|
||||
Ok(scratch) => scratch,
|
||||
Err(error) => return Ok(failed_measurement(request, started.elapsed(), classify_io(&error), 0, 0, 0)),
|
||||
Err(error) => {
|
||||
#[cfg(test)]
|
||||
scratch_diagnostic.emit("io_error");
|
||||
return Ok(failed_measurement(request, started.elapsed(), classify_io(&error), 0, 0, 0));
|
||||
}
|
||||
};
|
||||
#[cfg(test)]
|
||||
scratch_diagnostic.emit("complete");
|
||||
|
||||
let measured = run_benchmark(request, &scratch, cancel, deadline).await;
|
||||
#[cfg(test)]
|
||||
let cleanup_diagnostic = IoDiagnostic::new("scratch_cleanup", 0, 0, 0);
|
||||
#[cfg(test)]
|
||||
cleanup_diagnostic.emit("start");
|
||||
let cleanup = scratch.cleanup();
|
||||
#[cfg(test)]
|
||||
{
|
||||
cleanup_diagnostic.emit(if cleanup.is_ok() { "complete" } else { "io_error" });
|
||||
eprintln!(
|
||||
"connect_drive_total elapsed_us={} cleanup_succeeded={}",
|
||||
started.elapsed().as_micros(),
|
||||
cleanup.is_ok()
|
||||
);
|
||||
}
|
||||
let elapsed = started.elapsed().min(request.duration);
|
||||
if cleanup.is_err() {
|
||||
let (read_bytes, write_bytes, io_count) = match &measured {
|
||||
@@ -661,7 +690,13 @@ async fn run_benchmark(
|
||||
cancel: &CancellationToken,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> Result<BenchmarkSample, BenchmarkFailure> {
|
||||
#[cfg(test)]
|
||||
let open_diagnostic = IoDiagnostic::new("file_open", 0, 0, 0);
|
||||
#[cfg(test)]
|
||||
open_diagnostic.emit("start");
|
||||
let std_file = scratch.open_file().map_err(BenchmarkFailure::Io)?;
|
||||
#[cfg(test)]
|
||||
open_diagnostic.emit("complete");
|
||||
let mut file = tokio::fs::File::from_std(std_file);
|
||||
let block_size = usize::try_from(request.block_bytes).map_err(|_| BenchmarkFailure::Verification)?;
|
||||
let write_buffer = vec![0xa5; block_size];
|
||||
@@ -671,15 +706,36 @@ async fn run_benchmark(
|
||||
let write_started = Instant::now();
|
||||
while remaining > 0 {
|
||||
let length = usize::try_from(remaining.min(request.block_bytes)).map_err(|_| BenchmarkFailure::Verification)?;
|
||||
cancellable(file.write_all(&write_buffer[..length]), cancel, deadline).await?;
|
||||
cancellable(
|
||||
file.write_all(&write_buffer[..length]),
|
||||
cancel,
|
||||
deadline,
|
||||
#[cfg(test)]
|
||||
IoDiagnostic::new("write", 0, write_bytes, io_count),
|
||||
)
|
||||
.await?;
|
||||
write_bytes = write_bytes.checked_add(length as u64).ok_or(BenchmarkFailure::Verification)?;
|
||||
io_count = io_count.checked_add(1).ok_or(BenchmarkFailure::Verification)?;
|
||||
remaining -= length as u64;
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
cancellable(file.sync_all(), cancel, deadline).await?;
|
||||
cancellable(
|
||||
file.sync_all(),
|
||||
cancel,
|
||||
deadline,
|
||||
#[cfg(test)]
|
||||
IoDiagnostic::new("sync_all", 0, write_bytes, io_count),
|
||||
)
|
||||
.await?;
|
||||
let write_latency = write_started.elapsed();
|
||||
cancellable(file.seek(std::io::SeekFrom::Start(0)), cancel, deadline).await?;
|
||||
cancellable(
|
||||
file.seek(std::io::SeekFrom::Start(0)),
|
||||
cancel,
|
||||
deadline,
|
||||
#[cfg(test)]
|
||||
IoDiagnostic::new("seek", 0, write_bytes, io_count),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut read_buffer = vec![0_u8; block_size];
|
||||
remaining = request.scratch_bytes;
|
||||
@@ -687,7 +743,14 @@ async fn run_benchmark(
|
||||
let read_started = Instant::now();
|
||||
while remaining > 0 {
|
||||
let length = usize::try_from(remaining.min(request.block_bytes)).map_err(|_| BenchmarkFailure::Verification)?;
|
||||
cancellable(file.read_exact(&mut read_buffer[..length]), cancel, deadline).await?;
|
||||
cancellable(
|
||||
file.read_exact(&mut read_buffer[..length]),
|
||||
cancel,
|
||||
deadline,
|
||||
#[cfg(test)]
|
||||
IoDiagnostic::new("read", read_bytes, write_bytes, io_count),
|
||||
)
|
||||
.await?;
|
||||
if read_buffer[..length] != write_buffer[..length] {
|
||||
return Err(BenchmarkFailure::Verification);
|
||||
}
|
||||
@@ -714,15 +777,26 @@ async fn cancellable<T, F>(
|
||||
operation: F,
|
||||
cancel: &CancellationToken,
|
||||
deadline: tokio::time::Instant,
|
||||
#[cfg(test)] diagnostic: IoDiagnostic,
|
||||
) -> Result<T, BenchmarkFailure>
|
||||
where
|
||||
F: std::future::Future<Output = std::io::Result<T>>,
|
||||
{
|
||||
tokio::select! {
|
||||
let result = tokio::select! {
|
||||
() = cancel.cancelled() => Err(BenchmarkFailure::Cancelled),
|
||||
() = tokio::time::sleep_until(deadline) => Err(BenchmarkFailure::TimedOut),
|
||||
result = operation => result.map_err(BenchmarkFailure::Io),
|
||||
};
|
||||
#[cfg(test)]
|
||||
if let Err(error) = &result {
|
||||
diagnostic.emit(match error {
|
||||
BenchmarkFailure::Cancelled => "cancelled",
|
||||
BenchmarkFailure::TimedOut => "timed_out",
|
||||
BenchmarkFailure::Verification => "verification_failed",
|
||||
BenchmarkFailure::Io(_) => "io_error",
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
struct ScratchGuard {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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 std::time::Instant;
|
||||
|
||||
pub(super) struct IoDiagnostic {
|
||||
phase: &'static str,
|
||||
started: Instant,
|
||||
read_bytes: u64,
|
||||
write_bytes: u64,
|
||||
io_count: u64,
|
||||
}
|
||||
|
||||
impl IoDiagnostic {
|
||||
pub(super) fn new(phase: &'static str, read_bytes: u64, write_bytes: u64, io_count: u64) -> Self {
|
||||
Self {
|
||||
phase,
|
||||
started: Instant::now(),
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
io_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&self, outcome: &'static str) -> String {
|
||||
format!(
|
||||
"connect_drive_phase phase={} outcome={} elapsed_us={} completed_read_bytes={} completed_write_bytes={} completed_io_count={}",
|
||||
self.phase,
|
||||
outcome,
|
||||
self.started.elapsed().as_micros(),
|
||||
self.read_bytes,
|
||||
self.write_bytes,
|
||||
self.io_count,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn emit(&self, outcome: &'static str) {
|
||||
eprintln!("{}", self.line(outcome));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::IoDiagnostic;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn timeout_retains_completed_io_progress() {
|
||||
let diagnostic = IoDiagnostic::new("read", 4_096, 32_768, 9);
|
||||
let line = diagnostic.line("timed_out");
|
||||
let fields: std::collections::BTreeMap<_, _> =
|
||||
line.split_whitespace().skip(1).map(|f| f.split_once('=').unwrap()).collect();
|
||||
assert_eq!(fields["phase"], "read");
|
||||
assert_eq!(fields["outcome"], "timed_out");
|
||||
assert_eq!(fields["completed_read_bytes"], "4096");
|
||||
assert_eq!(fields["completed_write_bytes"], "32768");
|
||||
assert_eq!(fields["completed_io_count"], "9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_elapsed_is_not_clamped_to_request_budget() {
|
||||
let mut diagnostic = IoDiagnostic::new("sync_all", 0, 32_768, 8);
|
||||
diagnostic.started -= Duration::from_secs(2);
|
||||
let line = diagnostic.line("timed_out");
|
||||
let micros = line
|
||||
.split_whitespace()
|
||||
.find_map(|f| f.strip_prefix("elapsed_us="))
|
||||
.unwrap()
|
||||
.parse::<u128>()
|
||||
.unwrap();
|
||||
assert!(micros >= 2_000_000);
|
||||
}
|
||||
}
|
||||
@@ -100,14 +100,19 @@ fn trace_protocol_request(operation: &str, bucket: Option<&str>, object: Option<
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "webdav")]
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
fn session_list_buckets_request(
|
||||
input: ListBucketsInput,
|
||||
session_context: &SessionContext,
|
||||
request_headers: &HeaderMap,
|
||||
secure_transport: bool,
|
||||
credentials: rustfs_credentials::Credentials,
|
||||
is_owner: bool,
|
||||
) -> S3Request<ListBucketsInput> {
|
||||
let credentials = &session_context.principal.user_identity.credentials;
|
||||
let request_credentials = s3s::auth::Credentials {
|
||||
access_key: credentials.access_key.clone(),
|
||||
secret_key: credentials.secret_key.clone().into(),
|
||||
};
|
||||
let mut extensions = http::Extensions::default();
|
||||
let remote_addr = std::net::SocketAddr::new(session_context.source_ip, 0);
|
||||
extensions.insert(Some(RemoteAddr(remote_addr)));
|
||||
@@ -115,9 +120,8 @@ fn session_list_buckets_request(
|
||||
client_info.forwarded_proto = Some(if secure_transport { "https" } else { "http" }.to_string());
|
||||
extensions.insert(client_info);
|
||||
|
||||
let is_owner = current_action_credentials().is_some_and(|global_cred| credentials.access_key == global_cred.access_key);
|
||||
extensions.insert(ReqInfo {
|
||||
cred: Some(credentials.clone()),
|
||||
cred: Some(credentials),
|
||||
is_owner,
|
||||
bucket: None,
|
||||
object: None,
|
||||
@@ -134,10 +138,7 @@ fn session_list_buckets_request(
|
||||
uri: http::Uri::from_static("/"),
|
||||
headers: request_headers.clone(),
|
||||
extensions,
|
||||
credentials: Some(s3s::auth::Credentials {
|
||||
access_key: credentials.access_key.clone(),
|
||||
secret_key: credentials.secret_key.clone().into(),
|
||||
}),
|
||||
credentials: Some(request_credentials),
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
@@ -633,7 +634,7 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "webdav")]
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
async fn list_buckets_for_session(
|
||||
&self,
|
||||
session_context: &SessionContext,
|
||||
@@ -652,7 +653,23 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli
|
||||
let input = ListBucketsInput::builder().build().map_err(|e| {
|
||||
s3s::S3Error::with_message(s3s::S3ErrorCode::InvalidRequest, format!("Failed to build ListBucketsInput: {}", e))
|
||||
})?;
|
||||
let request = session_list_buckets_request(input, session_context, request_headers, secure_transport);
|
||||
let context = self.fs.server_ctx().installed_app_context().ok_or_else(|| {
|
||||
s3s::S3Error::with_message(s3s::S3ErrorCode::InternalError, "protocol server application context is not initialized")
|
||||
})?;
|
||||
// The session identity is captured at login. Re-check the key as
|
||||
// authorize_protocol_request does for every other operation, so a key
|
||||
// disabled or expired since then stops listing buckets. An invalid key
|
||||
// is an access denial, as in authorize_operation.
|
||||
let (credentials, is_owner) =
|
||||
crate::auth::check_key_valid_with_context("", session_context.access_key(), Some(context.as_ref()))
|
||||
.await
|
||||
.map_err(|e| match e.code() {
|
||||
s3s::S3ErrorCode::InvalidAccessKeyId => s3s::S3Error::new(s3s::S3ErrorCode::AccessDenied),
|
||||
_ => e,
|
||||
})?;
|
||||
let mut request =
|
||||
session_list_buckets_request(input, session_context, request_headers, secure_transport, credentials, is_owner);
|
||||
request.extensions.insert(self.fs.server_ctx().clone());
|
||||
self.fs.list_buckets(request).await.map(|response| response.output)
|
||||
}
|
||||
|
||||
@@ -667,11 +684,9 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli
|
||||
) -> Result<Option<SessionCapacityView>, Self::Error> {
|
||||
// List buckets under the session's own authorization so the view
|
||||
// never covers buckets the caller cannot see.
|
||||
let input = ListBucketsInput::builder().build().map_err(|e| {
|
||||
s3s::S3Error::with_message(s3s::S3ErrorCode::InvalidRequest, format!("Failed to build ListBucketsInput: {}", e))
|
||||
})?;
|
||||
let request = session_list_buckets_request(input, session_context, request_headers, secure_transport);
|
||||
let listing = self.fs.list_buckets(request).await?.output;
|
||||
let listing = self
|
||||
.list_buckets_for_session(session_context, request_headers, secure_transport)
|
||||
.await?;
|
||||
|
||||
let store = current_object_store_handle();
|
||||
let listed = listing.buckets.unwrap_or_default();
|
||||
@@ -1130,7 +1145,7 @@ mod tests {
|
||||
assert!(copied.is_service_account());
|
||||
}
|
||||
|
||||
#[cfg(feature = "webdav")]
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[test]
|
||||
fn request_extensions_preserve_authenticated_identity_and_source_ip() {
|
||||
use std::collections::HashMap;
|
||||
@@ -1149,14 +1164,26 @@ mod tests {
|
||||
let source_ip = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10));
|
||||
|
||||
let identity = rustfs_policy::auth::UserIdentity {
|
||||
credentials: credentials.clone(),
|
||||
credentials: rustfs_credentials::Credentials {
|
||||
secret_key: "stale-secret".to_string(),
|
||||
groups: None,
|
||||
claims: None,
|
||||
..credentials.clone()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let principal = rustfs_protocols::common::ProtocolPrincipal::new(std::sync::Arc::new(identity));
|
||||
let session_context = SessionContext::new(principal, rustfs_protocols::Protocol::WebDav, source_ip);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("user-agent", http::HeaderValue::from_static("webdav-client"));
|
||||
let request = session_list_buckets_request(ListBucketsInput::default(), &session_context, &headers, true);
|
||||
let request = session_list_buckets_request(
|
||||
ListBucketsInput::default(),
|
||||
&session_context,
|
||||
&headers,
|
||||
true,
|
||||
credentials.clone(),
|
||||
true,
|
||||
);
|
||||
let request_info = request.extensions.get::<ReqInfo>().expect("request info should be present");
|
||||
let copied = request_info.cred.as_ref().expect("credentials should be present");
|
||||
let remote_addr = request
|
||||
@@ -1172,12 +1199,18 @@ mod tests {
|
||||
assert_eq!(copied.parent_user, credentials.parent_user);
|
||||
assert_eq!(copied.groups, credentials.groups);
|
||||
assert_eq!(copied.claims, Some(claims));
|
||||
assert!(request_info.is_owner, "ownership comes from the server's credential validation");
|
||||
let request_credentials = request.credentials.as_ref().expect("request credentials");
|
||||
assert_eq!(request_credentials.access_key, credentials.access_key);
|
||||
assert_eq!(request_credentials.secret_key.expose(), credentials.secret_key);
|
||||
assert_eq!(remote_addr.0.ip(), source_ip);
|
||||
assert_eq!(client_info.real_ip, source_ip);
|
||||
assert_eq!(client_info.forwarded_proto.as_deref(), Some("https"));
|
||||
assert_eq!(request.headers.get("user-agent").expect("user agent"), "webdav-client");
|
||||
|
||||
let insecure_request = session_list_buckets_request(ListBucketsInput::default(), &session_context, &headers, false);
|
||||
let insecure_request =
|
||||
session_list_buckets_request(ListBucketsInput::default(), &session_context, &headers, false, credentials, false);
|
||||
assert!(!insecure_request.extensions.get::<ReqInfo>().expect("request info").is_owner);
|
||||
let insecure_client_info = insecure_request
|
||||
.extensions
|
||||
.get::<ClientInfo>()
|
||||
|
||||
@@ -679,3 +679,221 @@ fn protocol_object_creation_operation_preserves_multipart_staging() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn protocol_session_bucket_listing_revalidates_the_session_key() {
|
||||
use crate::storage_api::protocols::client::S3ErrorCode;
|
||||
|
||||
crate::app::gating_test_env::run_large_stack_test("protocol-session-bucket-listing", || async {
|
||||
let root = Credentials {
|
||||
access_key: "listing-policy-root".into(),
|
||||
secret_key: "listing-policy-root-secret".into(),
|
||||
status: "on".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp, context, iam) = protocol_test_context(root).await;
|
||||
let store = context.object_store();
|
||||
let slot = ServerContextSlot::new();
|
||||
assert!(slot.install(context));
|
||||
let backend = crate::init::protocol_storage_client_for_server(slot);
|
||||
for bucket in ["listing-visible", "listing-hidden"] {
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
}
|
||||
iam.create_user(
|
||||
"listing-parent",
|
||||
&rustfs_madmin::AddOrUpdateUserReq {
|
||||
secret_key: "listing-parent-secret".into(),
|
||||
policy: None,
|
||||
status: rustfs_madmin::AccountStatus::Enabled,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create parent user");
|
||||
let mut allow = json!({
|
||||
"Effect":"Allow", "Action":["s3:ListBucket","s3:GetBucketLocation"],
|
||||
"Resource":"arn:aws:s3:::listing-visible",
|
||||
"Condition":{
|
||||
"IpAddress":{"aws:SourceIp":"127.0.0.0/8"},
|
||||
"Bool":{"aws:SecureTransport":"true"},
|
||||
"StringEquals":{"aws:UserAgent":"sftp-listing-test"}
|
||||
}
|
||||
});
|
||||
iam.set_policy(
|
||||
"listing-policy",
|
||||
decode_policy(json!({"Version":"2012-10-17","Statement":[allow.clone()]})).expect("parse listing policy"),
|
||||
)
|
||||
.await
|
||||
.expect("save listing policy");
|
||||
iam.policy_db_set("listing-parent", UserType::Reg, false, "listing-policy")
|
||||
.await
|
||||
.expect("attach listing policy");
|
||||
iam.new_service_account(
|
||||
"listing-parent",
|
||||
None,
|
||||
rustfs_iam::sys::NewServiceAccountOpts {
|
||||
access_key: "listing-service-account".into(),
|
||||
secret_key: "listing-service-account-secret".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create service account");
|
||||
let identity = iam
|
||||
.check_key("listing-service-account")
|
||||
.await
|
||||
.expect("load service account")
|
||||
.0
|
||||
.expect("service account exists");
|
||||
// The identity is captured once, at login, like a protocol session does.
|
||||
let mut session = SessionContext::new(
|
||||
ProtocolPrincipal::new(Arc::new(identity)),
|
||||
Protocol::Sftp,
|
||||
"127.0.0.1".parse().expect("loopback"),
|
||||
);
|
||||
session
|
||||
.request_headers
|
||||
.insert("user-agent", "sftp-listing-test".parse().expect("user agent"));
|
||||
let list = || backend.list_buckets_for_session(&session, &session.request_headers, session.secure_transport);
|
||||
|
||||
let visible: Vec<_> = list()
|
||||
.await
|
||||
.expect("bucket-scoped listing")
|
||||
.buckets
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|b| b.name)
|
||||
.collect();
|
||||
assert_eq!(visible, ["listing-visible"], "listing is filtered to buckets the principal may list");
|
||||
|
||||
let mut insecure = session.clone();
|
||||
insecure.secure_transport = false;
|
||||
let mut foreign_ip = session.clone();
|
||||
foreign_ip.source_ip = "192.0.2.10".parse().expect("documentation address");
|
||||
let mut missing_header = session.clone();
|
||||
missing_header.request_headers.remove("user-agent");
|
||||
for denied_session in [insecure, foreign_ip, missing_header] {
|
||||
let result = backend
|
||||
.list_buckets_for_session(&denied_session, &denied_session.request_headers, denied_session.secure_transport)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(&result, Err(e) if *e.code() == S3ErrorCode::AccessDenied),
|
||||
"bucket discovery must enforce the session's request conditions: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
allow["Resource"] = json!(["arn:aws:s3:::listing-visible", "arn:aws:s3:::listing-hidden"]);
|
||||
iam.set_policy(
|
||||
"listing-policy",
|
||||
decode_policy(json!({"Version":"2012-10-17","Statement":[allow]})).expect("parse parent policy"),
|
||||
)
|
||||
.await
|
||||
.expect("allow the parent to list both buckets");
|
||||
for bucket in ["listing-visible", "listing-hidden"] {
|
||||
let session_policy = decode_policy(json!({
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Allow", "Action":["s3:ListBucket","s3:GetBucketLocation"],
|
||||
"Resource":format!("arn:aws:s3:::{bucket}")
|
||||
}]
|
||||
}))
|
||||
.expect("parse service-account policy");
|
||||
iam.update_service_account(
|
||||
"listing-service-account",
|
||||
rustfs_iam::sys::UpdateServiceAccountOpts {
|
||||
session_policy: Some(session_policy),
|
||||
secret_key: None,
|
||||
name: None,
|
||||
description: None,
|
||||
expiration: None,
|
||||
status: None,
|
||||
parent_user: None,
|
||||
allow_site_replicator_account: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("update the service-account policy after login");
|
||||
let visible: Vec<_> = list()
|
||||
.await
|
||||
.expect("listing with the refreshed session policy")
|
||||
.buckets
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|b| b.name)
|
||||
.collect();
|
||||
assert_eq!(visible, [bucket], "listing must use the current service-account claims");
|
||||
}
|
||||
|
||||
iam.update_service_account(
|
||||
"listing-service-account",
|
||||
rustfs_iam::sys::UpdateServiceAccountOpts {
|
||||
session_policy: None,
|
||||
secret_key: None,
|
||||
name: None,
|
||||
description: None,
|
||||
expiration: None,
|
||||
status: Some("off".into()),
|
||||
parent_user: None,
|
||||
allow_site_replicator_account: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("disable service account");
|
||||
|
||||
let after_disable = list().await;
|
||||
assert!(
|
||||
matches!(&after_disable, Err(e) if *e.code() == S3ErrorCode::AccessDenied),
|
||||
"a key disabled after login must stop listing buckets: {after_disable:?}"
|
||||
);
|
||||
|
||||
#[cfg(feature = "webdav")]
|
||||
{
|
||||
let mut webdav_session = session.clone();
|
||||
webdav_session.protocol = Protocol::WebDav;
|
||||
let capacity = backend
|
||||
.session_capacity_view(&webdav_session, &webdav_session.request_headers, webdav_session.secure_transport)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(&capacity, Err(e) if *e.code() == S3ErrorCode::AccessDenied),
|
||||
"quota discovery must revalidate the session key before reading capacity: {capacity:?}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "webdav")]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn protocol_capacity_view_uses_server_context_for_empty_listing() {
|
||||
crate::app::gating_test_env::run_large_stack_test("protocol-capacity-listing-context", || async {
|
||||
let root = Credentials {
|
||||
access_key: "capacity-listing-root".into(),
|
||||
secret_key: "capacity-listing-root-secret".into(),
|
||||
status: "on".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp, context, _iam) = protocol_test_context(root.clone()).await;
|
||||
let slot = ServerContextSlot::new();
|
||||
assert!(slot.install(context));
|
||||
let backend = crate::init::protocol_storage_client_for_server(slot);
|
||||
let session = SessionContext::new(
|
||||
ProtocolPrincipal::new(Arc::new(rustfs_policy::auth::UserIdentity {
|
||||
credentials: root,
|
||||
..Default::default()
|
||||
})),
|
||||
Protocol::WebDav,
|
||||
"127.0.0.1".parse().expect("loopback"),
|
||||
);
|
||||
|
||||
let capacity = backend
|
||||
.session_capacity_view(&session, &session.request_headers, session.secure_transport)
|
||||
.await
|
||||
.expect("capacity discovery uses the server's IAM context")
|
||||
.expect("capacity view is supported");
|
||||
assert!(capacity.buckets.is_empty(), "a server with no user buckets has an empty capacity view");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,6 +241,30 @@ impl TableDataPlaneListCursor {
|
||||
}
|
||||
|
||||
impl TableDataPlaneListAccess {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_listing_fixture(bucket: &str) -> Self {
|
||||
let resource = |table: &str, prefix: &str, allowed| TableDataPlaneListResource {
|
||||
resource: crate::table_catalog::TableDataPlaneResource {
|
||||
table_bucket: bucket.to_owned(),
|
||||
namespace: "test".to_owned(),
|
||||
table: table.to_owned(),
|
||||
table_id: table.to_owned(),
|
||||
warehouse_object_prefix: prefix.to_owned(),
|
||||
},
|
||||
allowed,
|
||||
};
|
||||
Self {
|
||||
bucket: bucket.to_owned(),
|
||||
principal_fingerprint: "principal".to_owned(),
|
||||
snapshot_fingerprint: "snapshot".to_owned(),
|
||||
cursor_key: [0x42; 32],
|
||||
resources: vec![
|
||||
resource("table-id", "tables/table-id/", true),
|
||||
resource("denied-id", "tables/zz-denied/", false),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn allows_object(&self, object: &str) -> bool {
|
||||
if crate::table_catalog::is_reserved_table_object_key(object) {
|
||||
return false;
|
||||
|
||||
@@ -391,16 +391,21 @@ fn calculate_next_marker(v2: &ListObjectsV2Output) -> Option<String> {
|
||||
// The token was built by this server around the internal cursor, which is
|
||||
// the last raw key the walker scanned: on a delimiter page that folded into
|
||||
// fewer prefixes than max-keys it lies past the last visible entry.
|
||||
if let Some(marker) = v2
|
||||
if let Some(mut marker) = v2
|
||||
.next_continuation_token
|
||||
.as_deref()
|
||||
.and_then(|token| decode_list_objects_v2_continuation_token(token).ok())
|
||||
{
|
||||
// Keep the escape that wraps cache-shaped literal keys: a V1 client
|
||||
// sends NextMarker back as `marker`, and the listing layer strips one
|
||||
// legacy cursor tag from it while that compatibility window is open.
|
||||
// Unwrapping it here would make the next page restart at the key's
|
||||
// stem and return the same key forever.
|
||||
// V2 cursors protect literal cache-shaped keys with an outer tag. V1
|
||||
// exposes the key itself, which is sent back as a literal StartAfter.
|
||||
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): Remove after supported upgrade sources stop issuing tagged V2 cursors.
|
||||
let unescaped_len = marker
|
||||
.strip_suffix("[rustfs_cache:v2,return:]")
|
||||
.filter(|key| key.rfind("[rustfs_cache:").is_some_and(|start| key[start..].contains(']')))
|
||||
.map(str::len);
|
||||
if let Some(len) = unescaped_len {
|
||||
marker.truncate(len);
|
||||
}
|
||||
return Some(encode_list_output_value(marker, v2.encoding_type.as_ref()));
|
||||
}
|
||||
|
||||
@@ -587,18 +592,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_objects_next_marker_keeps_the_literal_key_escape() {
|
||||
fn test_list_objects_next_marker_returns_the_literal_key() {
|
||||
for key in [
|
||||
"key[rustfs_cache:v2,return:]",
|
||||
"key[rustfs_cache:v1,id:x]suffix",
|
||||
"key[rustfs_cache:v1,id:x][rustfs_cache:v2,return:]",
|
||||
] {
|
||||
let cursor = format!("{key}[rustfs_cache:v2,return:]");
|
||||
for encoding in [None, Some(EncodingType::from_static(EncodingType::URL))] {
|
||||
let v2 = ListObjectsV2Output {
|
||||
is_truncated: Some(true),
|
||||
encoding_type: encoding.clone(),
|
||||
next_continuation_token: Some(encode_list_objects_v2_continuation_token(&cursor)),
|
||||
..Default::default()
|
||||
};
|
||||
let expected = super::encode_list_output_value(key.to_owned(), encoding.as_ref());
|
||||
assert_eq!(build_list_objects_output(v2, None).next_marker.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_objects_next_marker_preserves_an_unescaped_literal_return_tag() {
|
||||
let key = "key[rustfs_cache:v2,return:]";
|
||||
let cursor = format!("{key}[rustfs_cache:v2,return:]");
|
||||
let v2 = ListObjectsV2Output {
|
||||
is_truncated: Some(true),
|
||||
next_continuation_token: Some(encode_list_objects_v2_continuation_token(&cursor)),
|
||||
next_continuation_token: Some(encode_list_objects_v2_continuation_token(key)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// The client sends this back as `marker`; the listing layer strips the
|
||||
// outer escape and resumes after the literal key.
|
||||
assert_eq!(build_list_objects_output(v2, None).next_marker.as_deref(), Some(cursor.as_str()));
|
||||
assert_eq!(build_list_objects_output(v2, None).next_marker.as_deref(), Some(key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -441,9 +441,11 @@ pub(crate) mod ecstore_bucket {
|
||||
|
||||
pub(crate) mod ecstore_capacity {
|
||||
pub(crate) use rustfs_ecstore::api::capacity::{
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
||||
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
||||
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, is_reserved_or_invalid_bucket,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::capacity::{PoolAvailability, PoolCapacityStatus, PoolDiagnosticStatus};
|
||||
}
|
||||
|
||||
pub(crate) mod ecstore_client {
|
||||
|
||||
@@ -143,6 +143,8 @@ pub(crate) mod protocols {
|
||||
pub(crate) use crate::storage::storage_api::{StorageObjectOptions, StoragePutObjReader};
|
||||
pub(crate) type FS = crate::storage::storage_api::FS;
|
||||
pub(crate) use crate::storage::storage_api::request_context_consumer::RequestContext;
|
||||
#[cfg(all(test, any(feature = "webdav", feature = "sftp")))]
|
||||
pub(crate) use s3s::S3ErrorCode;
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::ListObjectsV2Input;
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ set -euo pipefail
|
||||
|
||||
# Guard: no private key material and no long-lived provider credential may
|
||||
# exist as a literal anywhere in the repository — see AGENTS.md "Security
|
||||
# Baseline" ("Never commit secrets, credentials, or key material") and
|
||||
# .agents/skills/security-advisory-lessons ("Do not ship hard-coded shared
|
||||
# tokens, HMAC secrets, private keys, or production test keys").
|
||||
# Baseline" ("Never commit secrets, credentials, or key material"). This includes
|
||||
# shared tokens, HMAC secrets, private keys, and production test keys.
|
||||
#
|
||||
# This replaces the unit test `test_source_does_not_embed_private_key` that
|
||||
# used to live in crates/crypto/src/license_token.rs (rustfs/backlog#1884).
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
3|crates/ecstore/src/runtime/sources.rs
|
||||
4|crates/ecstore/src/services/batch_processor.rs
|
||||
12|crates/ecstore/src/services/notification_sys.rs
|
||||
16|crates/ecstore/src/services/rebalance/control.rs
|
||||
15|crates/ecstore/src/services/rebalance/control.rs
|
||||
1|crates/ecstore/src/services/rebalance/entry.rs
|
||||
8|crates/ecstore/src/services/rebalance/meta.rs
|
||||
6|crates/ecstore/src/services/rebalance/meta.rs
|
||||
8|crates/ecstore/src/services/rebalance/runtime.rs
|
||||
17|crates/ecstore/src/services/rebalance/worker.rs
|
||||
33|crates/ecstore/src/services/tier/tier.rs
|
||||
|
||||
@@ -14,3 +14,44 @@ rustfs_void rustfs_btf_void(const void *data)
|
||||
{
|
||||
__asm__ volatile("" : : "r"(data) : "memory");
|
||||
}
|
||||
|
||||
typedef unsigned long long rustfs_u64;
|
||||
struct rustfs_btf_inner {
|
||||
unsigned int prefix;
|
||||
const rustfs_u64 count;
|
||||
};
|
||||
struct rustfs_btf_layout {
|
||||
unsigned int kind;
|
||||
struct rustfs_btf_inner nested;
|
||||
volatile unsigned long flags;
|
||||
unsigned short slot;
|
||||
unsigned int bits : 3;
|
||||
};
|
||||
struct __attribute__((packed)) rustfs_btf_packed {
|
||||
char tag;
|
||||
unsigned int value;
|
||||
};
|
||||
volatile struct rustfs_btf_layout rustfs_btf_layout_instance;
|
||||
volatile struct rustfs_btf_packed rustfs_btf_packed_instance;
|
||||
|
||||
#ifdef RUSTFS_BTF_LAYOUT_REFERENCE
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// Independent compiler layout oracle; no BTF parser or kernel operations.
|
||||
int main(void)
|
||||
{
|
||||
printf("{\"rustfs_btf_layout\":{\"size\":%zu,\"fields\":{"
|
||||
"\"kind\":[%zu,%zu],\"nested.count\":[%zu,%zu],"
|
||||
"\"flags\":[%zu,%zu],\"slot\":[%zu,%zu]}},"
|
||||
"\"rustfs_btf_packed\":{\"size\":%zu,\"fields\":{\"value\":[%zu,%zu]}}}\n",
|
||||
sizeof(struct rustfs_btf_layout),
|
||||
offsetof(struct rustfs_btf_layout, kind), sizeof(rustfs_btf_layout_instance.kind),
|
||||
offsetof(struct rustfs_btf_layout, nested.count), sizeof(rustfs_btf_layout_instance.nested.count),
|
||||
offsetof(struct rustfs_btf_layout, flags), sizeof(rustfs_btf_layout_instance.flags),
|
||||
offsetof(struct rustfs_btf_layout, slot), sizeof(rustfs_btf_layout_instance.slot),
|
||||
sizeof(struct rustfs_btf_packed),
|
||||
offsetof(struct rustfs_btf_packed, value), sizeof(rustfs_btf_packed_instance.value));
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -7,6 +7,7 @@ import array
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
@@ -14,6 +15,7 @@ from pathlib import Path
|
||||
|
||||
MAX_BTF_BYTES = 32 * 1024 * 1024
|
||||
MAX_FUNCTIONS = 16
|
||||
MAX_FIELDS = 16
|
||||
MAX_JSON_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@@ -118,6 +120,48 @@ class _BTF:
|
||||
result, chain = self.unwrap(p['size_or_type'], allow_void=True)
|
||||
return {'function': fn, 'prototype': p, 'args': args, 'return': result, 'return_chain': chain}
|
||||
|
||||
def scalar_field(self, root, path):
|
||||
"""Resolve a declared, byte-aligned integer span without dereferencing pointers."""
|
||||
container, absolute, steps, seen = root, 0, [], set()
|
||||
parts = path.split('.')
|
||||
for index, name in enumerate(parts):
|
||||
need(container['kind'] == 4 and container['ID'] not in seen,
|
||||
'acyclic_struct_member_path')
|
||||
seen.add(container['ID'])
|
||||
members = []
|
||||
for member in range(container['count']):
|
||||
nm, ty, bits = struct.unpack_from('<III', self.raw, container['offset'] + 12 + member * 12)
|
||||
if self.string(nm) == name:
|
||||
members.append((ty, bits))
|
||||
need(len(members) == 1, 'unique_actual_named_struct_member')
|
||||
ident, encoded_offset = members[0]
|
||||
bitfield = encoded_offset >> 24 if container['kflag'] else 0
|
||||
offset = encoded_offset & 0xffffff if container['kflag'] else encoded_offset
|
||||
need(not bitfield and offset % 8 == 0, 'non_bitfield_byte_aligned_member')
|
||||
leaf, chain = self.unwrap(ident)
|
||||
if index + 1 < len(parts):
|
||||
need(leaf['kind'] == 4, 'member_path_cannot_cross_pointer_or_non_struct')
|
||||
width = leaf['size_or_type']
|
||||
else:
|
||||
need(leaf['kind'] == 1 and leaf['count'] == 0 and not leaf['kflag'],
|
||||
'actual_scalar_integer_header')
|
||||
encoding = struct.unpack_from('<I', self.raw, leaf['offset'] + 12)[0]
|
||||
flags, integer_offset, integer_bits = (encoding >> 24) & 15, (encoding >> 16) & 255, encoding & 255
|
||||
width = leaf['size_or_type']
|
||||
need(encoding & 0xf000ff00 == 0 and flags in (0, 1, 2, 4),
|
||||
'supported_integer_encoding')
|
||||
need(width in (1, 2, 4, 8) and integer_offset == 0 and integer_bits == width * 8,
|
||||
'complete_byte_scalar_integer')
|
||||
need(width > 0 and offset + width * 8 <= container['size_or_type'] * 8,
|
||||
'member_span_within_declared_container')
|
||||
absolute += offset
|
||||
steps.append({'container_ID': container['ID'], 'member': name, 'member_type_ID': ident,
|
||||
'member_bit_offset': offset, 'qualifiers': chain})
|
||||
container = leaf
|
||||
return {'path': path, 'byte_offset': absolute // 8, 'bytes': width, 'leaf': leaf,
|
||||
'integer': {'encoding_flags': flags, 'bit_offset': integer_offset, 'bits': integer_bits},
|
||||
'steps': steps}
|
||||
|
||||
|
||||
def inspect_prototypes(path, names):
|
||||
"""Read bounded source bytes and report declared prototypes, never runtime ABI."""
|
||||
@@ -126,6 +170,30 @@ def inspect_prototypes(path, names):
|
||||
or len(name.encode("utf-8")) > 4096 for name in names)
|
||||
or len(set(names)) != len(names)):
|
||||
raise ValueError("one_to_sixteen_unique_function_names_required")
|
||||
btf, source = _read_btf(path)
|
||||
return {"schema": 1, "source": source, "prototypes": [btf.prototype(name) for name in names]}
|
||||
|
||||
|
||||
def inspect_scalar_fields(path, struct_name, fields):
|
||||
"""Report selected declared integer layouts, never runtime memory or ABI."""
|
||||
if (not isinstance(struct_name, str) or not struct_name or "\0" in struct_name
|
||||
or len(struct_name.encode("utf-8")) > 4096):
|
||||
raise ValueError("one_exact_struct_name_required")
|
||||
if (not isinstance(fields, (list, tuple)) or not fields or len(fields) > MAX_FIELDS
|
||||
or any(not isinstance(field, str) or len(field.encode("utf-8")) > 4096
|
||||
or not 1 <= len(field.split('.')) <= 32
|
||||
or any(re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', part) is None for part in field.split('.'))
|
||||
for field in fields)
|
||||
or len(set(fields)) != len(fields)):
|
||||
raise ValueError("one_to_sixteen_unique_scalar_member_paths_required")
|
||||
btf, source = _read_btf(path)
|
||||
root = btf.find(struct_name, 4)
|
||||
return {"schema": 1, "source": source, "struct": root, "runtime_ABI_verified": False,
|
||||
"scalar_fields": [btf.scalar_field(root, field) for field in fields]}
|
||||
|
||||
|
||||
def _read_btf(path):
|
||||
"""Share the same bounded regular-file contract across inspection modes."""
|
||||
path = Path(path)
|
||||
if not stat.S_ISREG(path.lstat().st_mode):
|
||||
raise ValueError("BTF_input_must_be_a_regular_non_symlink_file")
|
||||
@@ -139,11 +207,8 @@ def inspect_prototypes(path, names):
|
||||
raw = source.read(MAX_BTF_BYTES + 1)
|
||||
if len(raw) > MAX_BTF_BYTES:
|
||||
raise ValueError("BTF_source_exceeds_32_MiB")
|
||||
btf = _BTF(raw)
|
||||
return {"schema": 1,
|
||||
"source": {"format": "BTF-v1-little-endian", "bytes": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest()},
|
||||
"prototypes": [btf.prototype(name) for name in names]}
|
||||
return _BTF(raw), {"format": "BTF-v1-little-endian", "bytes": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest()}
|
||||
|
||||
|
||||
def encode_document(document):
|
||||
@@ -160,13 +225,21 @@ def encode_document(document):
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read-only inspection of declared BTF function prototypes; no probe loading or runtime ABI claims.")
|
||||
description="Read-only BTF prototypes or scalar member layouts; no probe loading or runtime ABI claims.")
|
||||
parser.add_argument("--btf", required=True, type=Path, help="raw BTF v1 little-endian file (at most 32 MiB)")
|
||||
parser.add_argument("--function", required=True, action="append", dest="functions",
|
||||
selection = parser.add_mutually_exclusive_group(required=True)
|
||||
selection.add_argument("--function", action="append", dest="functions",
|
||||
help="exact, unique function name; repeat at most 16 times")
|
||||
selection.add_argument("--struct", dest="struct_name", help="exact, unique struct name for scalar fields")
|
||||
parser.add_argument("--field", action="append", dest="fields",
|
||||
help="named integer member path within --struct; repeat at most 16 times")
|
||||
args = parser.parse_args(argv)
|
||||
if (args.struct_name is not None) != (args.fields is not None):
|
||||
parser.error("--struct and --field must be supplied together")
|
||||
try:
|
||||
output = encode_document(inspect_prototypes(args.btf, args.functions))
|
||||
document = (inspect_scalar_fields(args.btf, args.struct_name, args.fields) if args.struct_name is not None
|
||||
else inspect_prototypes(args.btf, args.functions))
|
||||
output = encode_document(document)
|
||||
except OSError:
|
||||
parser.exit(2, "BTF input could not be read\n")
|
||||
except (ValueError, UnicodeError) as error:
|
||||
|
||||
@@ -50,6 +50,240 @@ def structure(types, field, size=8):
|
||||
return blob(types + [typ(4, size, "Root", count=1) + member])
|
||||
|
||||
|
||||
class LayoutFixture:
|
||||
def __init__(self):
|
||||
self.strings = bytearray(b'\0')
|
||||
self.types = []
|
||||
|
||||
def name(self, value):
|
||||
if not value:
|
||||
return 0
|
||||
offset = len(self.strings)
|
||||
self.strings.extend(value.encode() + b'\0')
|
||||
return offset
|
||||
|
||||
def add(self, kind, third, name='', payload=b'', count=0, kflag=False):
|
||||
self.types.append(struct.pack('<III', self.name(name), (kind << 24) | count | (int(kflag) << 31), third) + payload)
|
||||
return len(self.types)
|
||||
|
||||
def integer(self, size=8, encoding=None, **header):
|
||||
if encoding is None:
|
||||
encoding = size * 8
|
||||
return self.add(1, size, 'integer', struct.pack('<I', encoding), **header)
|
||||
|
||||
def structure(self, name, size, members, kflag=False, kind=4):
|
||||
raw = b''.join(struct.pack('<III', self.name(n), ty, offset) for n, ty, offset in members)
|
||||
return self.add(kind, size, name, raw, len(members), kflag)
|
||||
|
||||
def blob(self):
|
||||
raw = b''.join(self.types)
|
||||
return struct.pack('<HBBIIIII', 0xEB9F, 1, 0, 24, 0, len(raw), len(raw), len(self.strings)) + raw + self.strings
|
||||
|
||||
|
||||
class ScalarFieldTests(unittest.TestCase):
|
||||
def inspect(self, fixture, fields=('value',)):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'input.btf'
|
||||
raw = fixture.blob()
|
||||
path.write_bytes(raw)
|
||||
result = reader.inspect_scalar_fields(path, 'Root', fields)
|
||||
self.assertEqual(path.read_bytes(), raw)
|
||||
self.assertEqual(result['source']['sha256'], hashlib.sha256(raw).hexdigest())
|
||||
self.assertFalse(result['runtime_ABI_verified'])
|
||||
return result
|
||||
|
||||
def run_cli(self, fixture, *options):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'input.btf'
|
||||
path.write_bytes(fixture.blob())
|
||||
return subprocess.run([sys.executable, '-B', str(HERE / 'inspect_btf_prototypes.py'),
|
||||
'--btf', str(path), *options], capture_output=True, timeout=10)
|
||||
|
||||
def test_nested_offsets_and_qualifier_ids(self):
|
||||
f = LayoutFixture()
|
||||
small, wide = f.integer(4), f.integer()
|
||||
alias = f.add(8, wide, 'Counter')
|
||||
const = f.add(10, alias)
|
||||
inner = f.structure('Inner', 16, [('pad', small, 0), ('count', const, 64)])
|
||||
f.structure('Root', 24, [('kind', small, 0), ('nested', inner, 64)])
|
||||
report = self.inspect(f, ['nested.count', 'kind'])
|
||||
count, kind = report['scalar_fields']
|
||||
self.assertEqual((count['path'], count['byte_offset'], count['bytes']), ('nested.count', 16, 8))
|
||||
self.assertEqual(count['integer'], {'encoding_flags': 0, 'bit_offset': 0, 'bits': 64})
|
||||
self.assertEqual([t['ID'] for t in count['steps'][1]['qualifiers']], [const, alias, wide])
|
||||
self.assertEqual((kind['byte_offset'], kind['bytes']), (0, 4))
|
||||
|
||||
def test_full_integer_widths_and_encoding_flags(self):
|
||||
for size, flags in ((1, 0), (2, 1), (4, 0), (8, 1), (1, 2), (1, 4)):
|
||||
with self.subTest(size=size, flags=flags):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(size, (flags << 24) | size * 8)
|
||||
f.structure('Root', size, [('value', value, 0)])
|
||||
report = self.inspect(f)['scalar_fields'][0]
|
||||
self.assertEqual((report['bytes'], report['integer']['encoding_flags']), (size, flags))
|
||||
|
||||
def test_kind_flag_normal_member_is_not_a_bitfield(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 16, [('bits', value, 3 << 24), ('value', value, 64)], kflag=True)
|
||||
self.assertEqual(self.inspect(f)['scalar_fields'][0]['byte_offset'], 8)
|
||||
with self.assertRaisesRegex(ValueError, 'non_bitfield'):
|
||||
self.inspect(f, ['bits'])
|
||||
|
||||
def test_legacy_integer_bitfields_are_rejected(self):
|
||||
for encoding in (7, (1 << 16) | 8):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(4, encoding)
|
||||
f.structure('Root', 4, [('value', value, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'complete_byte_scalar'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_unsupported_and_reserved_integer_encodings(self):
|
||||
for encoding in ((3 << 24) | 32, (8 << 24) | 32, (1 << 31) | 32, (1 << 8) | 32):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(4, encoding)
|
||||
f.structure('Root', 4, [('value', value, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'supported_integer_encoding'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_integer_headers_and_unsupported_width(self):
|
||||
for kwargs in ({'count': 1}, {'kflag': True}, {'size': 16}):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(**kwargs)
|
||||
f.structure('Root', 16, [('value', value, 0)])
|
||||
with self.assertRaises(ValueError):
|
||||
self.inspect(f)
|
||||
|
||||
def test_unaligned_and_out_of_container_members(self):
|
||||
for offset in (1, 8, 64):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [('value', value, offset)])
|
||||
with self.assertRaises(ValueError):
|
||||
self.inspect(f)
|
||||
|
||||
def test_nested_container_extent_is_checked(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
inner = f.structure('Inner', 16, [('value', value, 0)])
|
||||
f.structure('Root', 8, [('nested', inner, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'span_within'):
|
||||
self.inspect(f, ['nested.value'])
|
||||
|
||||
def test_pointer_and_non_integer_leaves_are_not_sized(self):
|
||||
for kind in (2, 3, 5, 7):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
payload = struct.pack('<III', value, value, 1) if kind == 3 else b''
|
||||
leaf = f.add(kind, value if kind == 2 else 0, payload=payload)
|
||||
f.structure('Root', 16, [('value', leaf, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'actual_scalar_integer'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_paths_never_cross_pointers(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
inner = f.structure('Inner', 8, [('value', value, 0)])
|
||||
pointer = f.add(2, inner)
|
||||
f.structure('Root', 8, [('pointer', pointer, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'cannot_cross_pointer'):
|
||||
self.inspect(f, ['pointer.value'])
|
||||
|
||||
def test_by_value_cycles_are_rejected(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [('value', value, 0), ('self', 2, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'acyclic_struct'):
|
||||
self.inspect(f, ['self.value'])
|
||||
|
||||
def test_missing_type_and_qualifier_cycles_are_not_defaulted(self):
|
||||
for target in (0, 999, 1):
|
||||
f = LayoutFixture()
|
||||
alias = f.add(8, target, 'Alias')
|
||||
f.structure('Root', 8, [('value', alias, 0)])
|
||||
with self.assertRaises(ValueError):
|
||||
self.inspect(f)
|
||||
|
||||
def test_missing_or_duplicate_member(self):
|
||||
for names in (['other'], ['value', 'value']):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [(n, value, 0) for n in names])
|
||||
with self.assertRaisesRegex(ValueError, 'unique_actual_named_struct_member'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_duplicate_root_and_invalid_member_name_offset(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [('value', value, 0)])
|
||||
f.structure('Root', 8, [('value', value, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'unique_actual_named_type'):
|
||||
self.inspect(f)
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.add(4, 8, 'Root', struct.pack('<III', 9999, value, 0), count=1)
|
||||
with self.assertRaisesRegex(ValueError, 'string_offset'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_selector_bounds_are_checked_before_source_io(self):
|
||||
for fields in ([], ['value', 'value'], [''], ['a..b'], ['a[0]'], ['a'] * 17,
|
||||
['.'.join(['a'] * 33)], [None], 'value'):
|
||||
with self.subTest(fields=fields):
|
||||
with self.assertRaisesRegex(ValueError, 'member_paths_required'):
|
||||
reader.inspect_scalar_fields('missing', 'Root', fields)
|
||||
for name in ('', None, 'Root\0'):
|
||||
with self.assertRaisesRegex(ValueError, 'struct_name_required'):
|
||||
reader.inspect_scalar_fields('missing', name, ['value'])
|
||||
|
||||
def test_full_path_count_and_depth_boundaries(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(1)
|
||||
fields = ['field_' + str(i) for i in range(16)]
|
||||
f.structure('Root', 16, [(name, value, i * 8) for i, name in enumerate(fields)])
|
||||
report = self.inspect(f, list(reversed(fields)))
|
||||
self.assertEqual([r['path'] for r in report['scalar_fields']], list(reversed(fields)))
|
||||
f = LayoutFixture()
|
||||
value = f.integer(1)
|
||||
for i in range(32):
|
||||
value = f.structure('Root' if i == 31 else 'Level_' + str(i), 1, [('next', value, 0)])
|
||||
field = self.inspect(f, ['.'.join(['next'] * 32)])['scalar_fields'][0]
|
||||
self.assertEqual((len(field['steps']), field['byte_offset'], field['bytes']), (32, 0, 1))
|
||||
|
||||
def test_qualifier_record_boundary(self):
|
||||
for aliases in (31, 32):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
for i in range(aliases):
|
||||
value = f.add(8, value, 'Alias_' + str(i))
|
||||
f.structure('Root', 8, [('value', value, 0)])
|
||||
if aliases == 31:
|
||||
field = self.inspect(f)['scalar_fields'][0]
|
||||
self.assertEqual(len(field['steps'][0]['qualifiers']), 32)
|
||||
else:
|
||||
with self.assertRaisesRegex(ValueError, 'qualifier_chain'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_cli_success_and_later_failure_has_no_partial_json(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [('value', value, 0)])
|
||||
result = self.run_cli(f, '--struct', 'Root', '--field', 'value')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
report = json.loads(result.stdout)
|
||||
self.assertFalse(report['runtime_ABI_verified'])
|
||||
self.assertEqual(report['scalar_fields'][0]['byte_offset'], 0)
|
||||
failed = self.run_cli(f, '--struct', 'Root', '--field', 'value', '--field', 'missing')
|
||||
self.assertEqual((failed.returncode, failed.stdout), (2, b''))
|
||||
|
||||
def test_cli_modes_and_incomplete_selection_are_rejected(self):
|
||||
f = LayoutFixture()
|
||||
for args in (['--struct', 'Root'], ['--field', 'value'], ['--struct', ''],
|
||||
['--function', 'demo', '--field', 'value'],
|
||||
['--struct', 'Root', '--field', 'value', '--function', 'demo']):
|
||||
result = self.run_cli(f, *args)
|
||||
self.assertEqual((result.returncode, result.stdout), (2, b''))
|
||||
|
||||
|
||||
class VoidBoundaryTests(unittest.TestCase):
|
||||
def test_scalar_and_existing_pointer_declarations_are_retained(self):
|
||||
result = reader._BTF(function([integer(), typ(2, 1)], 1, [2])).prototype("demo")
|
||||
@@ -345,7 +579,88 @@ class NativeCaptureTests(unittest.TestCase):
|
||||
bounded_reference_command([sys.executable, "-c", "import threading; threading.Event().wait()"], timeout=0.1)
|
||||
|
||||
|
||||
def native_reference(path):
|
||||
def native_scalar_reference(path, records, layout_reference, original):
|
||||
"""Use bpftool's member records and a compiler's offsets, not the BTF reader."""
|
||||
def number(record, field):
|
||||
value = re.search(r'\b' + field + r'=(\d+)', record['extra'])
|
||||
if not value:
|
||||
raise ValueError('missing_native_' + field)
|
||||
return int(value.group(1))
|
||||
|
||||
def unwrap(ident):
|
||||
chain = []
|
||||
while True:
|
||||
if ident in chain or len(chain) >= 32:
|
||||
raise ValueError('native_field_qualifier_cycle_or_bound')
|
||||
chain.append(ident)
|
||||
value = records[ident]
|
||||
if value['kind'] not in ('TYPEDEF', 'CONST', 'VOLATILE', 'RESTRICT', 'TYPE_TAG'):
|
||||
return ident, value, chain
|
||||
ident = number(value, 'type_id')
|
||||
|
||||
with Path(layout_reference).open('rb') as source:
|
||||
raw = source.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError('compiler_layout_reference_bound')
|
||||
compiler = json.loads(raw)
|
||||
expected_members = {'rustfs_btf_layout': ['kind', 'nested.count', 'flags', 'slot'],
|
||||
'rustfs_btf_packed': ['value']}
|
||||
if set(compiler) != set(expected_members):
|
||||
raise ValueError('compiler_layout_membership')
|
||||
for name, fields in expected_members.items():
|
||||
roots = [(ident, record) for ident, record in records.items()
|
||||
if record['kind'] == 'STRUCT' and record['name'] == name]
|
||||
if len(roots) != 1 or list(compiler[name]['fields']) != fields:
|
||||
raise ValueError('unique_native_struct_and_compiler_fields')
|
||||
root_id, root = roots[0]
|
||||
command = [sys.executable, '-B', str(HERE / 'inspect_btf_prototypes.py'),
|
||||
'--btf', str(path), '--struct', name]
|
||||
for field in fields:
|
||||
command.extend(['--field', field])
|
||||
result = bounded_reference_command(command)
|
||||
report = json.loads(result)
|
||||
if (report['source']['sha256'] != hashlib.sha256(original).hexdigest()
|
||||
or report['source']['bytes'] != len(original)
|
||||
or report['runtime_ABI_verified'] is not False or report['struct']['ID'] != root_id
|
||||
or report['struct']['size_or_type'] != compiler[name]['size']
|
||||
or compiler[name]['size'] != number(root, 'size')
|
||||
or [row['path'] for row in report['scalar_fields']] != fields):
|
||||
raise ValueError('native_scalar_root_or_membership')
|
||||
for actual in report['scalar_fields']:
|
||||
current, current_id, absolute = root, root_id, 0
|
||||
if len(actual['steps']) != len(actual['path'].split('.')):
|
||||
raise ValueError('native_scalar_step_count')
|
||||
for step, member_name in zip(actual['steps'], actual['path'].split('.')):
|
||||
matches = [row for row in current['members'] if row[0] == member_name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError('unique_native_scalar_member')
|
||||
_, ty, offset, bits = matches[0]
|
||||
leaf_id, leaf, chain = unwrap(ty)
|
||||
if (bits or offset % 8 or step['container_ID'] != current_id
|
||||
or step['member'] != member_name or step['member_type_ID'] != ty
|
||||
or step['member_bit_offset'] != offset
|
||||
or [r['ID'] for r in step['qualifiers']] != chain):
|
||||
raise ValueError('native_scalar_member_or_chain')
|
||||
absolute += offset
|
||||
current, current_id = leaf, leaf_id
|
||||
if leaf['kind'] != 'INT' or actual['leaf']['ID'] != leaf_id:
|
||||
raise ValueError('native_scalar_leaf')
|
||||
encoding = re.search(r'\bencoding=(\S+)', leaf['extra']).group(1)
|
||||
flags = {'(none)': 0, 'SIGNED': 1, 'CHAR': 2, 'BOOL': 4}[encoding]
|
||||
expected = [absolute // 8, number(leaf, 'size')]
|
||||
if ([actual['byte_offset'], actual['bytes']] != expected
|
||||
or compiler[name]['fields'][actual['path']] != expected
|
||||
or actual['integer'] != {'encoding_flags': flags,
|
||||
'bit_offset': number(leaf, 'bits_offset'),
|
||||
'bits': number(leaf, 'nr_bits')}):
|
||||
raise ValueError('native_scalar_layout_or_payload')
|
||||
with Path(path).open('rb') as source:
|
||||
if source.read(65537) != original:
|
||||
raise ValueError('native_scalar_source_changed')
|
||||
print('Validated scalar layouts against bpftool and independent compiler offsetof/sizeof')
|
||||
|
||||
|
||||
def native_reference(path, layout_reference=None):
|
||||
"""Compare a small compiler-produced fixture with bpftool, not this decoder."""
|
||||
path = Path(path)
|
||||
with path.open("rb") as source:
|
||||
@@ -359,13 +674,19 @@ def native_reference(path):
|
||||
match = re.fullmatch(r"\[(\d+)\] (\w+) '(.*)'(?: (.*))?", line)
|
||||
if match:
|
||||
ident, kind, name, extra = match.groups()
|
||||
current = {"kind": kind, "name": name, "extra": extra or "", "args": []}
|
||||
current = {"kind": kind, "name": name, "extra": extra or "", "args": [], "members": []}
|
||||
records[int(ident)] = current
|
||||
elif current is not None and current["kind"] == "FUNC_PROTO":
|
||||
argument = re.fullmatch(r"\s*'(.*)' type_id=(\d+)", line)
|
||||
if not argument:
|
||||
raise ValueError("unexpected_native_prototype_row")
|
||||
current["args"].append((argument.group(1), int(argument.group(2))))
|
||||
elif current is not None and current['kind'] == 'STRUCT':
|
||||
member = re.fullmatch(r"\s*'(.*)' type_id=(\d+) bits_offset=(\d+)(?: bitfield_size=(\d+))?", line)
|
||||
if not member:
|
||||
raise ValueError('unexpected_native_struct_member_row')
|
||||
field, ty, offset, bits = member.groups()
|
||||
current['members'].append((field, int(ty), int(offset), int(bits or 0)))
|
||||
names = ["rustfs_btf_probe", "rustfs_btf_void"]
|
||||
command = [sys.executable, "-B", str(HERE / "inspect_btf_prototypes.py"), "--btf", str(path)]
|
||||
for name in names:
|
||||
@@ -445,10 +766,14 @@ def native_reference(path):
|
||||
if int(re.search(r"\btype_id=(\d+)", record["extra"]).group(1)) != qualifier["size_or_type"]:
|
||||
raise ValueError("native_reference_qualifier_mismatch")
|
||||
print("Validated both compiler-produced prototypes against independent bpftool output")
|
||||
if layout_reference is not None:
|
||||
native_scalar_reference(path, records, layout_reference, raw)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--native-reference":
|
||||
native_reference(sys.argv[2])
|
||||
if len(sys.argv) in (3, 5) and sys.argv[1] == "--native-reference":
|
||||
if len(sys.argv) == 5 and sys.argv[3] != '--layout-reference':
|
||||
raise ValueError('expected_layout_reference')
|
||||
native_reference(sys.argv[2], sys.argv[4] if len(sys.argv) == 5 else None)
|
||||
else:
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user