mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-29 07:17:52 +00:00
Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84e24e7673 | |||
| 4b87bd50c6 | |||
| 2d69b90cfb | |||
| e0444b519d | |||
| c90ba5922f | |||
| 4a63e53161 | |||
| 20de23f571 | |||
| 93462de9ed | |||
| 4e4a3eb226 | |||
| 9601a7c4bc | |||
| d16e3cf7b6 | |||
| 3e056ffb4d | |||
| 72b4f6182f | |||
| a9d3022f37 | |||
| c232f1552d | |||
| 7581a70412 | |||
| 8cb9296f20 | |||
| b1ba6cdfdc | |||
| 0bfc7612d4 | |||
| 4c997519e8 | |||
| 95c5d108d1 | |||
| f6ad79dd58 | |||
| 82947d3595 | |||
| aef67b6499 | |||
| 8e88419f3a | |||
| 0197a4b538 | |||
| 7a3bf8cdbd | |||
| 3d3957a248 | |||
| b4026b43b2 | |||
| 851c295753 | |||
| f879946ead | |||
| d88f524d9f | |||
| 54ac37ec0a | |||
| 2b56666123 | |||
| a24d305665 | |||
| c47c8f9f74 | |||
| 3a00e9bd42 | |||
| c0c60dd91d | |||
| b5c253f3ed | |||
| 63d97f0877 | |||
| 4f04338ee1 | |||
| f96c94f950 | |||
| 5b5d68a444 | |||
| 0fb650a408 | |||
| b9f01162b2 | |||
| 5ab1d5191e | |||
| acebee4ea3 | |||
| 268a5519f1 | |||
| e452d837dd | |||
| 1451f3189b | |||
| 59bc6d7c94 | |||
| a56104206e | |||
| be485e6cab | |||
| fbd94e129d | |||
| cf5a9e0ba3 | |||
| 254592717a | |||
| aba9e2e9e6 | |||
| 924700a24f | |||
| b8eef694c7 | |||
| 26d55bb04e | |||
| c9f7e48a44 | |||
| 11e1662c90 | |||
| 7ea3aa2a93 | |||
| 4e48c390e7 | |||
| 02030f90a3 | |||
| d4d416ed47 | |||
| fa0a056bc1 | |||
| 1880bdca91 | |||
| 1c2f69eba9 | |||
| e193d34444 | |||
| fca714efe8 | |||
| 65bd2d575b | |||
| 58cab30351 | |||
| 5e863badc2 | |||
| b78e2ad6e7 | |||
| 605176fe75 | |||
| fea5a584c3 | |||
| e28ac2c9d4 | |||
| 4a96f06983 | |||
| 927b3969bf | |||
| fab4bb0586 | |||
| b7b26a6a49 | |||
| 197ab19f0c | |||
| a5e1e9312d | |||
| 62d148b045 | |||
| 8e6dc42772 | |||
| af10369f34 | |||
| c610d11e8f | |||
| c721416e4c | |||
| 7b29e9d65f | |||
| 85231b71e6 | |||
| ceddcdd878 | |||
| 7728eeab1a | |||
| 13dc635d51 | |||
| ead77d799f | |||
| 67db0f241f | |||
| a94f625bc8 | |||
| b7835f0f9a | |||
| 299609bb83 | |||
| 1800b90d06 | |||
| 2ccc8d8647 | |||
| 124cbfc00a | |||
| 3aa7964c58 | |||
| 42d376c6b7 | |||
| 017b85a5bf | |||
| 55023ecd86 | |||
| 1cd62c4249 | |||
| 0f24511535 | |||
| 9517db6cf7 | |||
| 3bcba78ccb | |||
| af4ce32528 | |||
| 32c4593587 | |||
| 2f90fb19f5 | |||
| 17aa75a175 | |||
| d0f8475924 | |||
| 7130f25af5 | |||
| 900d082764 | |||
| b23723a51f | |||
| 07b7d4fc18 | |||
| d60ef4873a | |||
| aaeedcd1d7 | |||
| 8ebec9e71c | |||
| cced213b66 | |||
| 882217221e | |||
| f279cefa9c | |||
| 8aba025bd6 | |||
| 40837f67f8 | |||
| 042f6ba3aa | |||
| 63eac7a387 | |||
| e13336a116 | |||
| 8d5af5981d | |||
| f53c5d41d4 | |||
| 4096d05ca7 | |||
| d2c1434a02 | |||
| c8f14dc337 | |||
| 90c5050a0f | |||
| 334e8d9d3e | |||
| d8e8f6bfa4 | |||
| 4e64e86a29 | |||
| d229c79e38 | |||
| 0c8b05585e | |||
| 911ea265c5 | |||
| 8c7b3996cb | |||
| 68a5b2c89c | |||
| 7f1795f275 | |||
| 3092792c3c | |||
| e542e62f07 | |||
| d90419f4dc | |||
| bbe2f5a3d3 | |||
| b5f275ca3f | |||
| c2e6b974e1 | |||
| b39fcb2e85 | |||
| 301e3a9f34 | |||
| 6535c28f1a | |||
| 76f4342ae2 | |||
| 128634f102 | |||
| abe14e2f97 | |||
| 7d6172e666 | |||
| 3254e79efd | |||
| da7dc74147 | |||
| db55098aa6 | |||
| 47e1850d7e | |||
| 4e88ebcf30 | |||
| cfee2ffaba |
@@ -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,20 +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.
|
||||
- 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.
|
||||
|
||||
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
|
||||
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
|
||||
| Confirmation | Complete the gate below and end the turn. |
|
||||
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
|
||||
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
|
||||
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
|
||||
|
||||
## Human Confirmation
|
||||
|
||||
@@ -37,6 +37,6 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
|
||||
|
||||
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
|
||||
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
|
||||
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
|
||||
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting unpublished candidate branches or preview tags requires an explicit decision. Completed release branches follow Phase 7 cleanup; keep release tags.
|
||||
|
||||
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
|
||||
|
||||
@@ -48,3 +48,10 @@ Use the existing announcement configuration from [website PR #112](https://githu
|
||||
3. Use the site's current package manager and required checks. For this config-only change, type-check and lint the changed file, build the site, and inspect the generated homepage for the version, visible link text, and destination. Confirm `/download` actually offers the announced version before using that link.
|
||||
4. Commit, push, and create or update the website PR when included in the authorized release scope. Include the published RustFS Release URL and checks run. Keep merge and production-deployment authority separate; reuse permission already given, but this reference grants none by itself.
|
||||
5. After an authorized merge/deployment, verify the live `https://rustfs.com/` banner and its destination. If only the PR is ready, report that state and URL; do not report the banner as deployed. A website failure does not invalidate or move the published release tag: retry this phase after resolving the failure.
|
||||
|
||||
## Reconcile main and remove the completed release branch
|
||||
|
||||
1. Compare the exact published tag with current main. Audit every release-side commit since their merge base, including inherited backports. An ahead count is a history difference, not proof of missing changes. Use ancestry and patch equivalence, then inspect current code and regression evidence for squashed, adapted, superseded, or reverted changes. Record each commit as present, superseded, release-only, or missing; a positive `git cherry` result alone does not prove a missing fix.
|
||||
2. Bring missing shared changes to main through a reviewed PR and the required checks before closing the follow-up. Preserve main's later changes; decide version bumps and pinned Console configuration separately. If a history merge is needed, use a merge commit and verify the release commit is an ancestor of main; squash does not establish that ancestry. Do not merge main into the published release branch or move its tags. Report unresolved changes instead of claiming reconciliation is complete.
|
||||
3. After publication and authorized follow-ups are complete, delete the remote `release/<target>` branch. Immediately before deletion, verify its HEAD still equals the published tag's commit and no open PR targets or originates from it. Preserve a branch with newer work or open PRs and report the blocker. Use a deletion with an expected-old-SHA check to avoid removing a concurrently advanced branch.
|
||||
4. Verify the remote branch is absent and the final and preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
|
||||
|
||||
@@ -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=e31182a075f38b8a6680f4563ed674f0cb92d7ad9e764d3da667c99510656325
|
||||
sha256-darwin=e31182a075f38b8a6680f4563ed674f0cb92d7ad9e764d3da667c99510656325
|
||||
sha256-linux=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
|
||||
sha256-darwin=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=359b16f0bba8baa6e995fd3701062a61088fd4c0196ade3bac9e8fda7a050833
|
||||
sha256-linux=137098f8bc31a5ca38cac35a5b628346c1082b0277ec3982e2e0ffb69d0ea2e4
|
||||
sha256-darwin=67251cb7d9978d38e899268cac4d7898297ff7e5b5c48c9c1546dc760b93568d
|
||||
sha256-linux=673da76c2e5710c4622fe2fa32d2b297730895769487fcf1152ee1c2366f32f4
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
|
||||
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
|
||||
sha256-darwin=3814cdde8cacf4187e18188d342ed70931cc32e268e42e5bb438d565bb4d0f81
|
||||
sha256-linux=663025a80273579f95ae3f49af02c0afcbfce94df09701c360a13e607b7637db
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=eaa140bb7c7fff74c92a0cd191805f7fdee3174eaa6a649a1b807e1e6b497a31
|
||||
sha256=e9dc896774122a304e2b8b90f28c82d4d4e0af8ded36a82b147b30945ec994e3
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"invariant": "list-uncommitted-version",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
|
||||
"name": "metacache::tests::resolve_merge_keeps_the_newest_version_at_listing_quorum"
|
||||
},
|
||||
{
|
||||
"invariant": "minio-object-fixture",
|
||||
@@ -229,7 +229,7 @@
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence"
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_listing_preserves_inputs"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer",
|
||||
@@ -241,11 +241,6 @@
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_disk_lock"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-enabled-index-contracts",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_enabled_index_contracts"
|
||||
},
|
||||
{
|
||||
"invariant": "borrowed-context-boundary",
|
||||
"suite": "rustfs-ecstore",
|
||||
|
||||
@@ -52,6 +52,7 @@ script-tests: ## Run shell script tests
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
|
||||
@@ -63,6 +64,8 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_functional_chain_health.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_validation_runner.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_retain_validation_evidence.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
|
||||
|
||||
+58
-10
@@ -57,6 +57,11 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=4194304 >> "$NEXTEST_ENV"']
|
||||
[scripts.setup.ecstore-large-stack]
|
||||
command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||
|
||||
# The receipt corruption oracle does not simulate a crash. Prefer tmpfs only
|
||||
# when its full fixture fits; retain the ordinary temporary directory otherwise.
|
||||
[scripts.setup.ecstore-receipt-tmpdir]
|
||||
command = ['sh', 'scripts/ci/receipt-fixture-tmpdir.sh']
|
||||
|
||||
# The serial ILM selection builds the same deep storage futures in both the
|
||||
# lifecycle transition module and scanner integration binary. Different tests
|
||||
# in each have overflowed first across otherwise unrelated CI runs.
|
||||
@@ -68,6 +73,17 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||
# boundaries. Reserve the run's capacity so unrelated fixtures cannot starve
|
||||
# their existing deadlines and typed-failure assertions (rustfs/backlog#2706).
|
||||
# Keep each test's internal concurrency and zero-retry policy unchanged.
|
||||
[[profile.default.overrides]]
|
||||
filter = """
|
||||
package(rustfs-ecstore) & (
|
||||
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
|
||||
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
|
||||
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
|
||||
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
|
||||
)
|
||||
"""
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
||||
threads-required = "num-test-threads"
|
||||
@@ -80,7 +96,7 @@ threads-required = "num-test-threads"
|
||||
# acquisition budget. Reserve capacity from sibling test processes while
|
||||
# preserving the probes' internal concurrency, deadlines, and assertions.
|
||||
[[profile.default.overrides]]
|
||||
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
|
||||
filter = '(package(rustfs-ecstore) & (test(/^core::pools_test::decommission_lock_order_tests::(active_decommission_migration_does_not_invert_capacity_and_delete_object(_tag)?_locks|decommission_moves_a_transitioned_version_expired_by_a_delete_all_rule_without_stalling)$/) | test(=services::integrity::tests::audit_migration_and_ack_recovery_preserve_source_and_reject_conflicts))) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These eight-disk decommission fixtures exercise concurrent retries,
|
||||
@@ -117,7 +133,7 @@ setup = 'ecstore-large-stack'
|
||||
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
|
||||
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
|
||||
[[profile.default.scripts]]
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::plugin_attempt::v2_tests::delegation_v2_persists_and_vends_with_real_parent_intersection|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
@@ -134,10 +150,16 @@ setup = 'ecstore-base-stack'
|
||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||
setup = 'lifecycle-large-stack'
|
||||
|
||||
# The pool metadata placement regression composes the full erasure-heal
|
||||
# startup future and overflows the default Linux test-thread stack.
|
||||
# This conditional GET fixture composes real-store futures that overflowed
|
||||
# the default Linux test-thread stack.
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-heal) & test(=replacement_pool_metadata_follows_real_two_set_placement)'
|
||||
filter = 'package(rustfs) & test(=app::object::get::tests::conditional_get_304_uses_metadata_without_decoding_damaged_data)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These heal fixtures compose deep real-store recovery futures that can
|
||||
# overflow the default Linux test-thread stack.
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-heal) & (test(=replacement_pool_metadata_follows_real_two_set_placement) | (binary(heal_integration_test) & test(=bucket_removal_recovers_delete_proofs_before_removing_empty_volumes)))'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These real-disk scanner fixtures assert quiet-workload publication and
|
||||
@@ -351,6 +373,17 @@ path = "junit.xml"
|
||||
|
||||
# Match the local durable-progress reservations. All members, deadlines,
|
||||
# assertions, and internal concurrency remain enabled without retries.
|
||||
[[profile.ci.overrides]]
|
||||
filter = """
|
||||
package(rustfs-ecstore) & (
|
||||
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
|
||||
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
|
||||
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
|
||||
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
|
||||
)
|
||||
"""
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
||||
threads-required = "num-test-threads"
|
||||
@@ -363,7 +396,7 @@ threads-required = "num-test-threads"
|
||||
# acquisition budget. Reserve capacity from sibling test processes while
|
||||
# preserving the probes' internal concurrency, deadlines, and assertions.
|
||||
[[profile.ci.overrides]]
|
||||
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
|
||||
filter = '(package(rustfs-ecstore) & (test(/^core::pools_test::decommission_lock_order_tests::(active_decommission_migration_does_not_invert_capacity_and_delete_object(_tag)?_locks|decommission_moves_a_transitioned_version_expired_by_a_delete_all_rule_without_stalling)$/) | test(=services::integrity::tests::audit_migration_and_ack_recovery_preserve_source_and_reject_conflicts))) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These eight-disk decommission fixtures exercise concurrent retries,
|
||||
@@ -391,13 +424,22 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Emit this expensive fixture's stage timings in CI and use tmpfs when safe.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(=store::init::tests::decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page)'
|
||||
success-output = 'immediate'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(=store::init::tests::decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page)'
|
||||
setup = 'ecstore-receipt-tmpdir'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
|
||||
[[profile.ci.scripts]]
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::plugin_attempt::v2_tests::delegation_v2_persists_and_vends_with_real_parent_intersection|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
@@ -412,10 +454,16 @@ setup = 'ecstore-base-stack'
|
||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||
setup = 'lifecycle-large-stack'
|
||||
|
||||
# The pool metadata placement regression composes the full erasure-heal
|
||||
# startup future and overflows the default Linux test-thread stack.
|
||||
# This conditional GET fixture composes real-store futures that overflowed
|
||||
# the default Linux test-thread stack.
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-heal) & test(=replacement_pool_metadata_follows_real_two_set_placement)'
|
||||
filter = 'package(rustfs) & test(=app::object::get::tests::conditional_get_304_uses_metadata_without_decoding_damaged_data)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These heal fixtures compose deep real-store recovery futures that can
|
||||
# overflow the default Linux test-thread stack.
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-heal) & (test(=replacement_pool_metadata_follows_real_two_set_placement) | (binary(heal_integration_test) & test(=bucket_removal_recovers_delete_proofs_before_removing_empty_volumes)))'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These real-disk scanner fixtures assert quiet-workload publication and
|
||||
|
||||
@@ -74,7 +74,6 @@ Notes:
|
||||
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
|
||||
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
|
||||
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
|
||||
- `.github/workflows/performance-ab.yml` (nightly warp A/B gate)
|
||||
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
|
||||
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"never_ran_grace_until": "2026-09-08T00:00:00Z"
|
||||
},
|
||||
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
||||
{
|
||||
"workflow": ".github/workflows/runner-hygiene.yml",
|
||||
"max_age_hours": 792,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
|
||||
|
||||
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
|
||||
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
|
||||
|
||||
| Label | Paid by | What it is | Use it for |
|
||||
|---|---|---|---|
|
||||
@@ -15,8 +15,8 @@ Every other label in this repository is capacity RustFS pays for by the hour whe
|
||||
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
|
||||
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
|
||||
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
|
||||
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
|
||||
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
|
||||
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
|
||||
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | Docker workloads with a measured resource or environment requirement beyond the hosted runner |
|
||||
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
|
||||
|
||||
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
|
||||
@@ -27,11 +27,15 @@ A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason
|
||||
|
||||
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
|
||||
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
|
||||
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
|
||||
3. **Hardware or environment.** Native aarch64 or access to the lab machines. Docker alone is not a reason: hosted Ubuntu ships Docker and supports the QEMU and Buildx setup actions.
|
||||
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
|
||||
|
||||
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
|
||||
|
||||
The functional chain's `prepare` and `complete-chain` jobs, the performance runner availability check, and runner hygiene use `ubuntu-latest`: they inspect APIs or artifacts, not the lab hosts. The actual functional and performance suites remain on their lab runners. Functional-chain health collection stays on the operator-managed `smoke-testing` runner as part of the automation environment. Docker image packaging also uses `ubuntu-latest` for both musl and glibc variants; it downloads release binaries rather than compiling the workspace, and still sets up QEMU and Buildx explicitly.
|
||||
|
||||
Moving these jobs off AKS reduces capacity demand, not necessarily the Azure bill immediately. VM costs fall only when nodes scale down or smaller capacity replaces them.
|
||||
|
||||
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
|
||||
|
||||
## Cache rule
|
||||
@@ -40,7 +44,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
|
||||
|
||||
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
|
||||
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
|
||||
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
|
||||
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build. The `build.yml` aarch64 release legs append `-ubicloud-arm` for this reason. `cache-warm.yml` does not write those keys.
|
||||
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
|
||||
|
||||
## Where self-hosted minutes leak
|
||||
@@ -62,7 +66,7 @@ gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
|
||||
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
|
||||
```
|
||||
|
||||
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
|
||||
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
|
||||
|
||||
## Checklist for a new or moved job
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ on:
|
||||
- 'scripts/test_package_service_scripts.sh'
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/test_docker_workflow.py'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
@@ -48,7 +47,6 @@ on:
|
||||
- 'scripts/test_package_service_scripts.sh'
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/test_docker_workflow.py'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
@@ -155,9 +153,6 @@ jobs:
|
||||
- name: Check preview release workflow policy
|
||||
run: ./scripts/security/check_preview_release_workflow.sh
|
||||
|
||||
- name: Check performance A/B workflow trust boundary
|
||||
run: ./scripts/security/check_performance_ab_workflow.sh
|
||||
|
||||
- name: Check tier evidence workflow isolation
|
||||
run: ./scripts/security/check_tier_artifact_workflow.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: BTF prototype reference
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "scripts/inspect_btf_prototypes.py"
|
||||
- "scripts/test_inspect_btf_prototypes.py"
|
||||
- "scripts/fixtures/btf_prototypes.c"
|
||||
- ".github/workflows/btf-prototype-reference.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: btf-prototype-reference-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
reference:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Check prototype and CLI contracts
|
||||
run: python3 -B scripts/test_inspect_btf_prototypes.py
|
||||
- name: Install only missing native reference tools
|
||||
run: |
|
||||
select_bpftool() {
|
||||
local candidate
|
||||
local candidates=()
|
||||
if command -v bpftool >/dev/null; then
|
||||
candidates+=("$(command -v bpftool)")
|
||||
fi
|
||||
for candidate in /usr/lib/linux-tools-*/bpftool /usr/lib/linux-tools/*/bpftool; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
candidates+=("$candidate")
|
||||
fi
|
||||
done
|
||||
while IFS= read -r candidate; do
|
||||
if [[ -x "$candidate" ]] && file -Lb "$candidate" | grep -q 'ELF' \
|
||||
&& "$candidate" version >/dev/null 2>&1; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done < <(printf '%s\n' "${candidates[@]}" | sort -Vr)
|
||||
return 1
|
||||
}
|
||||
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
|
||||
sudo apt-get install --no-install-recommends -y "${packages[@]}"
|
||||
fi
|
||||
# File parsing needs a working binary, not a host-kernel wrapper.
|
||||
selected_bpftool="$(select_bpftool)"
|
||||
"$selected_bpftool" version
|
||||
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
|
||||
- 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"
|
||||
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"
|
||||
@@ -181,12 +181,18 @@ jobs:
|
||||
selected="all"
|
||||
fi
|
||||
|
||||
|
||||
# linux-aarch64-* need a real aarch64 host (placement rule: hardware)
|
||||
# so the packaged binary and console smoke execute the artifact.
|
||||
# ubicloud-standard-16-arm is that host: Ubuntu 24.04 arm64, alias of
|
||||
# ubicloud-standard-16-arm-ubuntu-2404 (16 vCPU, 48 GB). linux-x86_64-* stay on the
|
||||
# ARC sm-standard-4 pool. GNU rows keep cross=true so cargo-zigbuild
|
||||
# pins the glibc 2.31 floor on the same architecture; musl stays a
|
||||
# native cargo build.
|
||||
all='{"include":[
|
||||
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||
{"target_id":"linux-aarch64-musl","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
|
||||
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
|
||||
]}'
|
||||
@@ -244,7 +250,11 @@ jobs:
|
||||
with:
|
||||
rust-version: stable
|
||||
target: ${{ matrix.target }}
|
||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
|
||||
# rust-cache keys include OS and architecture but not the runner image.
|
||||
# The Ubicloud aarch64 legs therefore use their own suffix so they
|
||||
# cannot restore a cache written on a different arm64 image.
|
||||
# x86_64, macOS, and Windows keys stay unsuffixed.
|
||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
|
||||
# main only. A cache saved on refs/tags/X is scoped to that tag: no
|
||||
# other tag, no main run and no PR can restore it, so every release
|
||||
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
|
||||
@@ -764,9 +774,9 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The self-hosted Linux runners do not ship the aws CLI (GitHub-hosted
|
||||
# images did). Install the official CLI bundle on demand so R2 uploads
|
||||
# survive a fresh runner without relying on a distro package.
|
||||
# ARC pods (the x86_64 legs) do not ship the aws CLI. Ubicloud arm64
|
||||
# images follow the GitHub runner image and often do. Install the
|
||||
# official CLI bundle when it is missing so R2 uploads work on either.
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
echo "aws CLI not found on runner; installing..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
|
||||
@@ -45,7 +45,7 @@ on:
|
||||
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
|
||||
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
|
||||
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
|
||||
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
|
||||
# and e2e-replication-nightly (04:00) lanes.
|
||||
- cron: "43 7 * * 0"
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
name: Build Docker Images
|
||||
needs: build-check
|
||||
if: needs.build-check.outputs.should_build == 'true'
|
||||
runs-on: dind-sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -38,8 +38,8 @@ name: e2e-nightly
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
|
||||
# stale (01:30) and performance-ab (06:00).
|
||||
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00)
|
||||
# and stale (01:30).
|
||||
- cron: "29 4 * * *"
|
||||
|
||||
# Only alert-on-failure needs more than read access; it declares its own
|
||||
|
||||
@@ -19,12 +19,15 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/e2e-upgrade.yml"
|
||||
- "crates/e2e_test/src/common.rs"
|
||||
- "crates/e2e_test/src/distributed/**"
|
||||
- "crates/e2e_test/src/fake_s3_target/**"
|
||||
- "crates/e2e_test/src/lib.rs"
|
||||
- "crates/e2e_test/src/replication_extension_test.rs"
|
||||
- "crates/e2e_test/src/rpc_fault_proxy.rs"
|
||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
- "crates/heal/**"
|
||||
- "crates/kms/**"
|
||||
- "crates/storage-api/**"
|
||||
- "rustfs/**"
|
||||
@@ -127,6 +130,7 @@ jobs:
|
||||
run: chmod +x target/debug/rustfs
|
||||
|
||||
- name: Verify and unpack pinned previous release
|
||||
id: verify_source
|
||||
env:
|
||||
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||
run: |
|
||||
@@ -160,6 +164,7 @@ jobs:
|
||||
)
|
||||
if [[ "$UPGRADE_SOURCE_VERSION" == "1.0.0-rc.5" ]]; then
|
||||
cases+=(odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration)
|
||||
cases+=(checkpoint-upgrade:rc5_checkpoint_upgrade_rewinds_signed_progress_without_losing_objects)
|
||||
else
|
||||
cases+=(legacy-delete-reservation:stable_upgrade_deletes_foreign_reservations_without_losing_live_objects)
|
||||
fi
|
||||
@@ -183,6 +188,28 @@ jobs:
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Run mixed-version MPU recovery
|
||||
if: ${{ !cancelled() && steps.verify_source.outcome == 'success' }}
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
NO_PROXY: 127.0.0.1,localhost,::1
|
||||
HTTP_PROXY: ""
|
||||
HTTPS_PROXY: ""
|
||||
ALL_PROXY: ""
|
||||
RUSTFS_E2E_LOG_FILTER: "warn,rustfs_ecstore::cluster::rpc::client=debug"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export no_proxy="$NO_PROXY"
|
||||
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/mpu-mixed-recovery"
|
||||
mkdir -p "$RUSTFS_E2E_LOG_DIR"
|
||||
mpu_test='distributed::multipart_fence_acceptance_test::mixed_version_failed_complete_requires_current_coordinator_for_abort'
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
|
||||
"$mpu_test" -- --list --exact | tee "$RUSTFS_E2E_LOG_DIR/list.log"
|
||||
test "$(grep -Fxc "${mpu_test}: test" "$RUSTFS_E2E_LOG_DIR/list.log")" -eq 1
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
|
||||
"$mpu_test" -- --exact --nocapture --test-threads=1 \
|
||||
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"
|
||||
|
||||
- name: Upload scanner/heal G09 evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Warp A/B budget gate for the hotpath series (rustfs/backlog#935 HP-14, item 4).
|
||||
#
|
||||
# Two entry points, honestly scoped:
|
||||
# * schedule (nightly, on main): post-merge detection — catches a regression
|
||||
# within 24h of landing, not before merge.
|
||||
# * workflow_dispatch: an explicitly selected trusted ref.
|
||||
# The dispatch input can run the gate with --allow-regression so a deliberate
|
||||
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
|
||||
# blocked (rustfs/backlog#935 correction 1).
|
||||
|
||||
name: Performance A/B
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
duration:
|
||||
description: "warp duration per round"
|
||||
required: false
|
||||
default: "12s"
|
||||
type: string
|
||||
allow_regression:
|
||||
description: "Pass the gate despite a FAIL (deliberate tradeoff)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
warp-ab:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Warp A/B budget gate
|
||||
runs-on: sm-standard-2
|
||||
# A normal nightly restores the last successful binary and builds only the
|
||||
# candidate; daily access keeps that cache warm. A cache miss may build both
|
||||
# and needs room for the A/B run plus artifact and cache publication.
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # baseline may be an earlier successful scheduled head
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install warp
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WARP_VERSION="1.0.0"
|
||||
curl -fsSL "https://github.com/minio/warp/releases/download/v${WARP_VERSION}/warp_Linux_x86_64.tar.gz" \
|
||||
| sudo tar -xz -C /usr/local/bin warp
|
||||
warp --version
|
||||
|
||||
- name: Decide exemption
|
||||
id: exempt
|
||||
env:
|
||||
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
|
||||
run: |
|
||||
allow="false"
|
||||
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
|
||||
allow="true"
|
||||
fi
|
||||
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A failed regression run must keep comparing against the last known-good
|
||||
# scheduled head. Otherwise the next nightly would absorb the regression
|
||||
# into its baseline and turn green without a fix.
|
||||
- name: Find last successful scheduled baseline
|
||||
id: scheduled_baseline
|
||||
if: github.event_name == 'schedule'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: "performance-ab.yml",
|
||||
event: "schedule",
|
||||
status: "success",
|
||||
per_page: 1,
|
||||
});
|
||||
return data.workflow_runs[0]?.head_sha ?? "";
|
||||
|
||||
# Manual runs compare a selected ref with current main. Scheduled runs
|
||||
# compare current main with the last successful scheduled head. With no
|
||||
# history, the first run measures the candidate against itself and seeds
|
||||
# that head only if the complete rig succeeds.
|
||||
- name: Resolve baseline / candidate commits
|
||||
id: commits
|
||||
env:
|
||||
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
candidate_sha="$(git rev-parse HEAD)"
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
|
||||
else
|
||||
baseline_sha="$(git rev-parse origin/main)"
|
||||
fi
|
||||
git cat-file -e "${baseline_sha}^{commit}"
|
||||
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
|
||||
echo "::error::baseline $baseline_sha is not an ancestor of candidate $candidate_sha; update the selected ref before comparing" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "baseline commit: $baseline_sha"
|
||||
echo "candidate commit: $candidate_sha"
|
||||
|
||||
# Exact-key restore of the candidate binary saved by its successful
|
||||
# scheduled run. A miss leaves cache-hit unset and falls back to a source
|
||||
# build of that known-good head.
|
||||
- name: Restore cached baseline binary
|
||||
id: baseline_cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: baseline-bin/rustfs
|
||||
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
|
||||
|
||||
# Self-heal: on a nightly/dispatch run where the candidate commit IS the
|
||||
# baseline commit, a cache miss would make the rig build the same commit
|
||||
# twice (~65min per side with the post-#4806 LTO profile — no job budget
|
||||
# fits that). Build it once here, reuse it for both phases, and save it
|
||||
# back to the cache so the next run hits.
|
||||
- name: Build baseline on cache miss (same-commit self-heal)
|
||||
id: selfheal
|
||||
if: >-
|
||||
steps.baseline_cache.outputs.cache-hit != 'true' &&
|
||||
steps.commits.outputs.baseline_sha == steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --release --bin rustfs
|
||||
mkdir -p baseline-bin
|
||||
cp target/release/rustfs baseline-bin/rustfs
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build baseline on cache miss (different candidate)
|
||||
id: baseline_build
|
||||
if: >-
|
||||
steps.baseline_cache.outputs.cache-hit != 'true' &&
|
||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
baseline_root="$RUNNER_TEMP/rustfs-baseline-${{ github.run_id }}"
|
||||
baseline_target="$RUNNER_TEMP/rustfs-baseline-target-${{ github.run_id }}"
|
||||
git worktree add --detach "$baseline_root" "${{ steps.commits.outputs.baseline_sha }}"
|
||||
cargo build --release --manifest-path "$baseline_root/Cargo.toml" --bin rustfs --target-dir "$baseline_target"
|
||||
mkdir -p baseline-bin
|
||||
cp "$baseline_target/release/rustfs" baseline-bin/rustfs
|
||||
git worktree remove --force "$baseline_root"
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build candidate binary
|
||||
id: candidate_build
|
||||
if: steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --release --bin rustfs
|
||||
mkdir -p candidate-bin
|
||||
cp target/release/rustfs candidate-bin/rustfs
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Save self-healed baseline to cache
|
||||
if: steps.selfheal.outputs.built == 'true' || steps.baseline_build.outputs.built == 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: baseline-bin/rustfs
|
||||
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
|
||||
|
||||
- name: Run warp A/B and gate
|
||||
id: ab
|
||||
env:
|
||||
INPUT_DURATION: ${{ github.event.inputs.duration }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The formal runner executes A1 baseline -> B1 candidate -> B2 candidate
|
||||
# -> A2 baseline for each workload and drive-sync cell. It requires three
|
||||
# rounds per leg to emit tail latency and error-rate evidence.
|
||||
# --health-timeout 180 outlasts the server's own 120s startup-readiness
|
||||
# budget, which the rig's previous 60s health poll undershot (the first
|
||||
# two nightly failures). perf-6 recalibrates these once the noise study
|
||||
# lands.
|
||||
duration="${INPUT_DURATION:-12s}"
|
||||
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
|
||||
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
|
||||
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
|
||||
selfheal_built="${{ steps.selfheal.outputs.built }}"
|
||||
baseline_built="${{ steps.baseline_build.outputs.built }}"
|
||||
candidate_built="${{ steps.candidate_build.outputs.built }}"
|
||||
|
||||
args=(--duration "$duration" --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision "$baseline_sha" --candidate-revision "$candidate_sha")
|
||||
|
||||
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" || "$baseline_built" == "true" ]]; then
|
||||
chmod +x baseline-bin/rustfs
|
||||
base_bin="$PWD/baseline-bin/rustfs"
|
||||
args+=(--baseline-bin "$base_bin")
|
||||
if [[ "$baseline_hit" == "true" ]]; then
|
||||
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
|
||||
elif [[ "$selfheal_built" == "true" ]]; then
|
||||
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
|
||||
else
|
||||
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
|
||||
fi
|
||||
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
|
||||
# No commits landed since the last successful baseline, so reuse
|
||||
# the one binary for both phases and measure only rig drift.
|
||||
args+=(--candidate-bin "$base_bin")
|
||||
cand_src="same binary as baseline (same commit)"
|
||||
elif [[ "$candidate_built" == "true" ]]; then
|
||||
chmod +x candidate-bin/rustfs
|
||||
args+=(--candidate-bin "$PWD/candidate-bin/rustfs")
|
||||
cand_src="source build of the checked-out ref"
|
||||
else
|
||||
echo "::error::candidate binary was not built" >&2
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
echo "::error::baseline binary was not restored or built" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "baseline binary: $base_src"
|
||||
echo "candidate binary: $cand_src"
|
||||
|
||||
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
|
||||
args+=(--allow-regression --exemption-reason "workflow dispatch override")
|
||||
fi
|
||||
# Do not let a gate FAIL abort the job here; capture status and surface
|
||||
# it after the step summary is written.
|
||||
set +e
|
||||
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
|
||||
echo "status=$?" >> "$GITHUB_OUTPUT"
|
||||
set -e
|
||||
# Locate the newest run dir + gate.md for the summary/comment/artifact
|
||||
# steps. On a startup failure there is no gate.md, but the run dir still
|
||||
# holds server-logs/ for diagnosis.
|
||||
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
|
||||
# shellcheck disable=SC2012
|
||||
run_dir="$(ls -td target/hotpath-abba/*/ 2>/dev/null | head -n1 || true)"
|
||||
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
|
||||
# shellcheck disable=SC2012
|
||||
gate_md="$(ls -t target/hotpath-abba/*/candidate_gate.md 2>/dev/null | head -n1 || true)"
|
||||
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload A/B results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: hotpath-warp-ab-${{ github.run_number }}
|
||||
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
|
||||
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
|
||||
# is diagnosable. Short retention: this is churny nightly debug data.
|
||||
path: target/hotpath-abba/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Write gate summary
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="${{ steps.ab.outputs.status }}"
|
||||
gate_md="${{ steps.ab.outputs.gate_md }}"
|
||||
run_dir="${{ steps.ab.outputs.run_dir }}"
|
||||
{
|
||||
echo "## Hotpath warp A/B — run ${{ github.run_number }}"
|
||||
echo
|
||||
if [[ "$status" == "0" ]]; then
|
||||
echo "Rig/gate exit: \`0\` (pass or warn)."
|
||||
else
|
||||
echo "Rig/gate exit: \`${status:-unknown}\` — **FAILED**."
|
||||
fi
|
||||
echo
|
||||
if [[ -n "$gate_md" && -f "$gate_md" ]]; then
|
||||
cat "$gate_md"
|
||||
else
|
||||
echo "No \`gate.md\` produced — the rig failed **before** the gate"
|
||||
echo "(most likely server startup / health). Failing phase(s) below;"
|
||||
echo "full logs in the \`hotpath-warp-ab-${{ github.run_number }}\` artifact."
|
||||
if [[ -n "$run_dir" && -d "$run_dir/server-logs" ]]; then
|
||||
for f in "$run_dir"/server-logs/*.log; do
|
||||
[[ -f "$f" ]] || continue
|
||||
echo
|
||||
echo "<details><summary>$(basename "$f")</summary>"
|
||||
echo
|
||||
echo '```'
|
||||
tail -n 30 "$f"
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Stage successful candidate baseline
|
||||
if: >-
|
||||
steps.ab.outputs.status == '0' &&
|
||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp candidate-bin/rustfs baseline-bin/rustfs
|
||||
|
||||
- name: Cache successful candidate baseline
|
||||
if: >-
|
||||
steps.ab.outputs.status == '0' &&
|
||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: baseline-bin/rustfs
|
||||
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
|
||||
|
||||
# Scheduled failure alerting is handled by the alert-on-failure job below
|
||||
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
|
||||
|
||||
- name: Enforce gate
|
||||
if: always()
|
||||
run: |
|
||||
status="${{ steps.ab.outputs.status }}"
|
||||
if [[ -z "$status" ]]; then
|
||||
echo "::error::warp A/B setup failed before the rig ran. Check the first failed workflow step." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$status" != "0" ]]; then
|
||||
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
|
||||
exit "$status"
|
||||
fi
|
||||
echo "warp A/B budget gate passed."
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [warp-ab]
|
||||
# `always()` is required: without it this job is skipped when a needed
|
||||
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
|
||||
# ci-8); manual dispatch failures are already watched by a human.
|
||||
# `cancelled` is included alongside `failure` on purpose: a job that hits
|
||||
# timeout-minutes ends as `cancelled`, and the 2026-07-11..07-14 nightly
|
||||
# timeouts went silent precisely because the guard was failure-only. The
|
||||
# composite action already reports cancelled/timed-out jobs in the issue
|
||||
# body.
|
||||
if: >-
|
||||
github.repository == 'rustfs/rustfs' && (
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
check-ephemerality:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Check runner ephemerality
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -20,13 +20,91 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_run_id:
|
||||
description: Successful nightly build run on main
|
||||
description: Successful nightly or validation package run; empty for package-only build
|
||||
type: string
|
||||
required: true
|
||||
required: false
|
||||
default: ''
|
||||
build_run_attempt:
|
||||
description: Exact successful build attempt
|
||||
type: string
|
||||
required: true
|
||||
required: false
|
||||
default: ''
|
||||
validation_package_sha:
|
||||
description: Exact source SHA for an isolated package-only build
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_case:
|
||||
description: Canonical case ID, environment.inspect for read-only resources, or empty for functional chain
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
i05_diagnostic_branch:
|
||||
description: I05 source-plan runs both H1/H2 controllers with exact native evidence; subsets remain diagnostic
|
||||
type: choice
|
||||
options: [source-plan, timeout, queued-successor]
|
||||
required: false
|
||||
default: source-plan
|
||||
auto_testing_sha:
|
||||
description: Exact auto-testing commit for a validation case
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_retain_evidence_run_id:
|
||||
description: Upload existing persistent evidence from one interrupted validation run without fleet access
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_retain_evidence_run_attempt:
|
||||
description: Exact interrupted run attempt to retain
|
||||
type: string
|
||||
required: false
|
||||
default: '1'
|
||||
validation_runner_inspect_only:
|
||||
description: Inspect GitHub runner registration and queued jobs on a hosted runner without fleet access
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_tools_only:
|
||||
description: Build candidate-bound validation tools without running a fleet case
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_native_tools:
|
||||
description: Include exact-source native regressions in a tools-only build
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_shared_tools:
|
||||
description: Include the controlled C10 shared-reference encoder in a tools-only build
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_manager_tools:
|
||||
description: Include exact checkpoint/MRF/incarnation regressions in a tools-only build
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_cleanup_run_id:
|
||||
description: Failed validation run whose supported retained environment needs evidence-based finalization
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_cleanup_retained_evidence:
|
||||
description: Recover from verified persistent runner-local evidence when the failed run uploaded no artifact
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
validation_tools_run_id:
|
||||
description: Successful tool build run; required by Heal physical metadata checks
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
validation_tools_run_attempt:
|
||||
description: Exact tool build attempt
|
||||
type: string
|
||||
required: false
|
||||
default: '1'
|
||||
workflow_run:
|
||||
workflows: ["Nightly GNU Build"]
|
||||
types: [completed]
|
||||
@@ -36,13 +114,104 @@ permissions:
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: rustfs-functional-chain-runs
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_runner_inspect_only && 'rustfs-functional-validation-runner-inspection' || github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' && format('rustfs-functional-validation-package-{0}', inputs.validation_package_sha) || github.event_name == 'workflow_dispatch' && inputs.validation_tools_only && 'rustfs-functional-validation-tools' || 'rustfs-functional-chain-runs' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
|
||||
validation-runner-inspection:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_runner_inspect_only }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Inspect shared runner registration and queue without fleet access
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
RUNNER_INSPECTION_INPUTS: ${{ toJSON(inputs) }}
|
||||
run: >-
|
||||
python3 -B scripts/inspect_validation_runner.py
|
||||
--output "${RUNNER_TEMP}/validation-runner-inspection/report.json"
|
||||
- name: Upload read-only runner inspection including incomplete reports
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: validation-runner-inspection-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/validation-runner-inspection/report.json
|
||||
if-no-files-found: error
|
||||
validation-evidence-retention:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id != '' }}
|
||||
# The original interrupted evidence exists only on this long-lived runner.
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests-v2
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Verify existing interrupted evidence without changing it
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVIDENCE_RETAIN_INPUTS: ${{ toJSON(inputs) }}
|
||||
run: >-
|
||||
python3 -B scripts/retain_validation_evidence.py
|
||||
--report "${RUNNER_TEMP}/validation-evidence-retention/report.json"
|
||||
- name: Upload admitted original evidence under collection provenance
|
||||
if: ${{ env.VALIDATION_RETAIN_ROOT != '' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-retained-${{ env.VALIDATION_RETAIN_SOURCE_RUN }}-${{ env.VALIDATION_RETAIN_SOURCE_ATTEMPT }}-by-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.VALIDATION_RETAIN_ROOT }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
- name: Upload retention receipt including incomplete reports
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: validation-evidence-retention-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/validation-evidence-retention/report.json
|
||||
if-no-files-found: error
|
||||
validation-package-request:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Validate exclusive package-only request
|
||||
env:
|
||||
PACKAGE_INPUTS: ${{ toJSON(inputs) }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
value = json.loads(os.environ["PACKAGE_INPUTS"])
|
||||
if re.fullmatch(r"[0-9a-f]{40}", value["validation_package_sha"]) is None:
|
||||
raise ValueError("package build requires an exact source SHA")
|
||||
exclusive = ("validation_retain_evidence_run_id", "validation_runner_inspect_only", "build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha",
|
||||
"validation_tools_only", "validation_native_tools", "validation_shared_tools",
|
||||
"validation_manager_tools", "validation_cleanup_run_id", "validation_cleanup_retained_evidence", "validation_tools_run_id")
|
||||
if any(value.get(name) for name in exclusive):
|
||||
raise ValueError("package-only build cannot select existing candidate, tools, fleet or recovery execution")
|
||||
PY
|
||||
|
||||
validation-package:
|
||||
needs: validation-package-request
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha != '' }}
|
||||
uses: ./.github/workflows/rustfs-validation-package.yml
|
||||
with:
|
||||
source_sha: ${{ inputs.validation_package_sha }}
|
||||
secrets:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
|
||||
prepare:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && inputs.validation_case == '' && !inputs.validation_tools_only && !inputs.validation_native_tools && !inputs.validation_shared_tools && !inputs.validation_manager_tools && !inputs.validation_cleanup_retained_evidence && inputs.validation_cleanup_run_id == '') || github.event.workflow_run.event == 'schedule' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
manifest: ${{ steps.candidate.outputs.manifest }}
|
||||
@@ -89,6 +258,411 @@ jobs:
|
||||
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
validation:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.validation_runner_inspect_only && inputs.validation_retain_evidence_run_id == '' && inputs.validation_package_sha == '' && (inputs.validation_case != '' || inputs.validation_tools_only || inputs.validation_native_tools || inputs.validation_shared_tools || inputs.validation_manager_tools || inputs.validation_cleanup_run_id != '' || inputs.validation_cleanup_retained_evidence) }}
|
||||
runs-on: ${{ inputs.validation_tools_only && 'ubuntu-latest' || 'smoke-testing' }}
|
||||
timeout-minutes: 90
|
||||
outputs:
|
||||
source_sha: ${{ steps.candidate.outputs.source_sha }}
|
||||
concurrency:
|
||||
group: ${{ inputs.validation_tools_only && 'rustfs-validation-tools-resolver' || 'rustfs-shared-functional-tests-v2' }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_ACCESS_KEY || '' }}
|
||||
RUSTFS_SECRET_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_SECRET_KEY || '' }}
|
||||
RUSTFS_API_ENDPOINT: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT) || '' }}
|
||||
RUSTFS_NODES: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_NODES || vars.RUSTFS_NODES) || '' }}
|
||||
RUSTFS_SSH_USER: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER) || '' }}
|
||||
steps:
|
||||
- name: Checkout candidate resolver
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize validation evidence
|
||||
env:
|
||||
VALIDATION_TOOLS_ONLY: ${{ inputs.validation_tools_only }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
python3 -B scripts/initialize_validation_evidence.py
|
||||
|
||||
- name: Resolve immutable candidate
|
||||
id: candidate
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
BUILD_RUN_ID: ${{ inputs.build_run_id }}
|
||||
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
CLEANUP_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
|
||||
CLEANUP_RETAINED_EVIDENCE: ${{ inputs.validation_cleanup_retained_evidence }}
|
||||
SELECTED_CASE: ${{ inputs.validation_case }}
|
||||
TOOLS_ONLY: ${{ inputs.validation_tools_only }}
|
||||
NATIVE_TOOLS: ${{ inputs.validation_native_tools }}
|
||||
SHARED_TOOLS: ${{ inputs.validation_shared_tools }}
|
||||
MANAGER_TOOLS: ${{ inputs.validation_manager_tools }}
|
||||
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from scripts.resolve_functional_candidate import resolve
|
||||
|
||||
revision = os.environ["AUTO_TESTING_SHA"]
|
||||
cleanup = os.environ["CLEANUP_RUN_ID"]
|
||||
if os.environ["CLEANUP_RETAINED_EVIDENCE"] == "true" and not cleanup:
|
||||
raise ValueError("persistent recovery input requires one exact cleanup run")
|
||||
if os.environ["TOOLS_ONLY"] == "true" and (cleanup or os.environ["SELECTED_CASE"]):
|
||||
raise ValueError("tools-only execution cannot select a fleet case or cleanup")
|
||||
if os.environ["NATIVE_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
|
||||
raise ValueError("native regressions require tools-only execution, without a fleet case or cleanup")
|
||||
if os.environ["SHARED_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
|
||||
raise ValueError("shared fixture builds require tools-only execution, without a fleet case or cleanup")
|
||||
if os.environ["MANAGER_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
|
||||
raise ValueError("manager regressions require tools-only execution, without a fleet case or cleanup")
|
||||
if cleanup and (not cleanup.isdigit() or int(cleanup) <= 0 or os.environ["SELECTED_CASE"] or os.environ["TOOLS_ONLY"] == "true"):
|
||||
raise ValueError("cleanup requires one exact failed run and is exclusive with case/tool execution")
|
||||
diagnostic_branch = os.environ["I05_DIAGNOSTIC_BRANCH"] or "source-plan"
|
||||
if diagnostic_branch not in {"source-plan", "timeout", "queued-successor"}:
|
||||
raise ValueError("I05 diagnostic branch is not registered")
|
||||
if diagnostic_branch == "queued-successor" and os.environ["SELECTED_CASE"] != "heal.lock-timeout-and-caller-abort":
|
||||
raise ValueError("queued-successor is available only for the I05 diagnostic case")
|
||||
if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
|
||||
raise ValueError("validation requires an exact auto-testing commit SHA")
|
||||
candidate = resolve(int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"]), allow_validation=True)
|
||||
manifest = candidate["manifest"]
|
||||
root = Path(os.environ["VALIDATION_ROOT"])
|
||||
(root / "candidate-source.json").write_text(json.dumps(candidate, sort_keys=True) + "\n")
|
||||
(root / "candidate.json").write_text(json.dumps({
|
||||
"package_url": manifest["package_url"],
|
||||
"package_sha256": manifest["package_sha256"],
|
||||
"source_sha": manifest["source_sha"],
|
||||
}, sort_keys=True) + "\n")
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
|
||||
output.write("source_sha=" + manifest["source_sha"] + "\n")
|
||||
PY
|
||||
|
||||
- name: Checkout exact auto-testing revision
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ inputs.auto_testing_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare validation Python runtime
|
||||
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install validation Python client
|
||||
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
validation_python="${RUNNER_TEMP}/rustfs-validation-python-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
test ! -e "${validation_python}"
|
||||
python3 -m venv "${validation_python}"
|
||||
"${validation_python}/bin/python" -m pip install \
|
||||
-r auto-testing/scripts/validation_ilm/requirements.txt
|
||||
printf '%s\n' "${validation_python}/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Validate Heal tool build identity
|
||||
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TOOL_RUN_ID: ${{ inputs.validation_tools_run_id }}
|
||||
TOOL_RUN_ATTEMPT: ${{ inputs.validation_tools_run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${TOOL_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "${TOOL_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOOL_RUN_ID}/attempts/${TOOL_RUN_ATTEMPT}" \
|
||||
> "${VALIDATION_ROOT}/tool-run.json"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
value = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "tool-run.json").read_text())
|
||||
if (value.get("conclusion") != "success" or value.get("status") != "completed"
|
||||
or value.get("run_attempt") != int(os.environ["TOOL_RUN_ATTEMPT"])
|
||||
or value.get("path") not in {".github/workflows/rustfs-functional-chain.yml", ".github/workflows/rustfs-validation-tools.yml"}):
|
||||
raise ValueError("Heal tool run is not an exact successful validation build attempt")
|
||||
PY
|
||||
|
||||
- name: Download pinned Heal metadata inspector
|
||||
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.validation_tools_run_id }}
|
||||
name: rustfs-validation-tools-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Verify Heal metadata inspector
|
||||
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TOOL_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-tools-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
python3 -B auto-testing/scripts/validation_heal/inspector.py \
|
||||
--directory "${TOOL_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
> "${VALIDATION_ROOT}/tool-manifest.json"
|
||||
printf 'VALIDATION_HEAL_TOOL_DIRECTORY=%s\n' "${TOOL_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Select exact-source race native evidence
|
||||
id: race_native
|
||||
if: ${{ inputs.validation_case == 'heal.race-unversioned-overwrite' || inputs.validation_case == 'heal.race-versioned-put' || inputs.validation_case == 'heal.race-version-delete' || inputs.validation_case == 'heal.race-markers-and-batch-delete' || inputs.validation_case == 'heal.race-multipart-complete-abort' || inputs.validation_case == 'heal.admin-nolock-safety' || inputs.validation_case == 'heal.race-copy-and-metadata-update' }}
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ steps.candidate.outputs.source_sha }}
|
||||
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
required=false
|
||||
if [ -f auto-testing/scripts/validation_heal/race_native.py ]; then
|
||||
status=0
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
|
||||
--commit "${CANDIDATE_SHA}" --case-id "${VALIDATION_CASE_ID}" || status=$?
|
||||
if [ "${status}" -eq 0 ]; then
|
||||
required=true
|
||||
elif [ "${status}" -ne 2 ]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
fi
|
||||
reviewed=false
|
||||
case "${CANDIDATE_SHA}:${VALIDATION_CASE_ID}" in
|
||||
6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca:heal.race-unversioned-overwrite | \
|
||||
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-unversioned-overwrite | \
|
||||
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-versioned-put | \
|
||||
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-version-delete | \
|
||||
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-unversioned-overwrite | \
|
||||
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-versioned-put | \
|
||||
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-version-delete | \
|
||||
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-markers-and-batch-delete | \
|
||||
757b751482bb970bc321e92059690a7698554358:heal.admin-nolock-safety | \
|
||||
757b751482bb970bc321e92059690a7698554358:heal.race-multipart-complete-abort | \
|
||||
757b751482bb970bc321e92059690a7698554358:heal.race-copy-and-metadata-update) reviewed=true ;;
|
||||
esac
|
||||
if [ "${reviewed}" = true ] && [ "${required}" != true ]; then
|
||||
printf 'Race candidate requires its reviewed native collector before fleet execution\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Download pinned native storage evidence
|
||||
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || steps.race_native.outputs.required == 'true' || (inputs.validation_case == 'heal.lock-timeout-and-caller-abort' && inputs.i05_diagnostic_branch != 'timeout') || (inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' && steps.candidate.outputs.source_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac') }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.validation_tools_run_id }}
|
||||
name: rustfs-validation-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Verify C07 native source and exact test evidence
|
||||
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/authority.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
> "${VALIDATION_ROOT}/native-authority-manifest.json"
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Verify C11 physical storage and exact final-rename barrier
|
||||
if: ${{ inputs.validation_case == 'heal.bucket-incarnation-recreation' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/incarnation_native.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${{ inputs.auto_testing_sha }}" \
|
||||
> "${VALIDATION_ROOT}/native-incarnation-manifest.json"
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Verify exact-source race native and runtime evidence
|
||||
if: ${{ steps.race_native.outputs.required == 'true' }}
|
||||
env:
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${AUTO_TESTING_SHA}" --case-id "${VALIDATION_CASE_ID}" \
|
||||
> "${VALIDATION_ROOT}/native-race-manifest.json"
|
||||
runtime_driver=race.py
|
||||
if [ "${VALIDATION_CASE_ID}" = 'heal.admin-nolock-safety' ]; then
|
||||
runtime_driver=nolock.py
|
||||
fi
|
||||
VALIDATION_HEAL_NATIVE_DIRECTORY="${NATIVE_DIRECTORY}" \
|
||||
python3 -B "auto-testing/scripts/validation_heal/${runtime_driver}" preflight \
|
||||
--candidate "${VALIDATION_ROOT}/candidate.json" --testing-commit "${AUTO_TESTING_SHA}" \
|
||||
--case-id "${VALIDATION_CASE_ID}" \
|
||||
> "${VALIDATION_ROOT}/native-race-runtime-preflight.json"
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Verify I05 source-plan or queued-successor native prerequisites
|
||||
if: ${{ inputs.validation_case == 'heal.lock-timeout-and-caller-abort' && inputs.i05_diagnostic_branch != 'timeout' }}
|
||||
env:
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
if [ "${I05_DIAGNOSTIC_BRANCH}" = 'queued-successor' ]; then
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${AUTO_TESTING_SHA}" --case-id heal.lock-timeout-and-caller-abort \
|
||||
> "${VALIDATION_ROOT}/native-i05-prerequisite.json"
|
||||
else
|
||||
python3 -B auto-testing/scripts/validation_heal/lock_source.py \
|
||||
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${AUTO_TESTING_SHA}" \
|
||||
> "${VALIDATION_ROOT}/native-i05-source-prerequisites.json"
|
||||
fi
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Bind F07 exact-candidate directory fsync evidence
|
||||
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' && steps.candidate.outputs.source_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
|
||||
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Download pinned manager persistence and incarnation evidence
|
||||
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.validation_tools_run_id }}
|
||||
name: rustfs-validation-manager-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Verify exact manager native prerequisites before fleet mutation
|
||||
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
|
||||
env:
|
||||
VALIDATION_CASE: ${{ inputs.validation_case }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MANAGER_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-manager-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${MANAGER_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/manager_fixture.py \
|
||||
--directory "${MANAGER_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--case-id "${VALIDATION_CASE}" > "${VALIDATION_ROOT}/manager-prerequisite-manifest.json"
|
||||
printf 'VALIDATION_HEAL_MANAGER_DIRECTORY=%s\n' "${MANAGER_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Download pinned C10 shared-reference encoder
|
||||
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ inputs.validation_tools_run_id }}
|
||||
name: rustfs-validation-shared-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Verify C10 exact-source codec and original tests
|
||||
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHARED_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-shared-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
cp "${VALIDATION_ROOT}/tool-run.json" "${SHARED_DIRECTORY}/tool-run.json"
|
||||
python3 -B auto-testing/scripts/validation_heal/shared_fixture.py \
|
||||
--directory "${SHARED_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
> "${VALIDATION_ROOT}/shared-fixture-manifest.json"
|
||||
printf 'VALIDATION_HEAL_SHARED_DIRECTORY=%s\n' "${SHARED_DIRECTORY}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Run selected canonical validation case
|
||||
if: ${{ !inputs.validation_tools_only && inputs.validation_cleanup_run_id == '' && inputs.validation_case != 'environment.inspect' }}
|
||||
env:
|
||||
VALIDATION_CASE: ${{ inputs.validation_case }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
I05_DIAGNOSTIC_BRANCH: ${{ inputs.i05_diagnostic_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
./auto-testing/scripts/run_validation_case.sh \
|
||||
"${VALIDATION_CASE}" "${VALIDATION_ROOT}/candidate.json" \
|
||||
"${AUTO_TESTING_SHA}" "${VALIDATION_ROOT}/run"
|
||||
|
||||
- name: Inspect current Heal fleet resources without mutation
|
||||
if: ${{ !inputs.validation_tools_only && inputs.validation_case == 'environment.inspect' }}
|
||||
env:
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
python3 -B auto-testing/scripts/validation_heal/environment.py \
|
||||
--candidate "${VALIDATION_ROOT}/candidate.json" \
|
||||
--testing-commit "${AUTO_TESTING_SHA}" --evidence "${VALIDATION_ROOT}/environment"
|
||||
|
||||
- name: Audit and finalize retained validation environment
|
||||
if: ${{ inputs.validation_cleanup_run_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FAILED_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
CLEANUP_RETAINED_EVIDENCE: ${{ inputs.validation_cleanup_retained_evidence }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
gh run view "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--json status,conclusion,headBranch,workflowName > "${VALIDATION_ROOT}/failed-run.json"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
run = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "failed-run.json").read_text())
|
||||
if (run["status"] != "completed" or run["conclusion"] not in {"failure", "cancelled"}
|
||||
or run["workflowName"] != "RustFS Functional Chain" or run["headBranch"] != os.environ["GITHUB_REF_NAME"]):
|
||||
raise ValueError("cleanup source must be a completed failed or cancelled Functional Chain run")
|
||||
PY
|
||||
if [ "${CLEANUP_RETAINED_EVIDENCE}" = true ]; then
|
||||
python3 -B scripts/prepare_retained_validation_recovery.py \
|
||||
--report "${VALIDATION_ROOT}/retained-recovery-input.json"
|
||||
else
|
||||
gh run download "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern "rustfs-validation-${FAILED_RUN_ID}-*" --dir "${VALIDATION_ROOT}/failed-artifact"
|
||||
fi
|
||||
python3 -B auto-testing/scripts/validation_heal/recover_environment.py \
|
||||
--failed-run-id "${FAILED_RUN_ID}" --artifact "${VALIDATION_ROOT}/failed-artifact" \
|
||||
--candidate "${VALIDATION_ROOT}/candidate.json" --evidence "${VALIDATION_ROOT}/recovery"
|
||||
|
||||
- name: Show validation report
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
if [ -f "${VALIDATION_ROOT}/run/report.md" ]; then
|
||||
cat "${VALIDATION_ROOT}/run/report.md" >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
- name: Upload validation evidence
|
||||
if: ${{ always() && env.VALIDATION_ROOT != '' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.VALIDATION_ROOT }}
|
||||
if-no-files-found: error
|
||||
|
||||
validation-tools:
|
||||
needs: [validation]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_tools_only }}
|
||||
uses: ./.github/workflows/rustfs-validation-tools.yml
|
||||
with:
|
||||
candidate_sha: ${{ needs.validation.outputs.source_sha }}
|
||||
auto_testing_sha: ${{ inputs.auto_testing_sha }}
|
||||
build_native_heal: ${{ inputs.validation_native_tools }}
|
||||
build_shared_heal: ${{ inputs.validation_shared_tools }}
|
||||
build_manager_native: ${{ inputs.validation_manager_tools }}
|
||||
secrets: inherit
|
||||
|
||||
upgrade:
|
||||
needs: [prepare]
|
||||
if: ${{ always() && needs.prepare.result == 'success' }}
|
||||
@@ -188,7 +762,7 @@ jobs:
|
||||
complete-chain:
|
||||
needs: [prepare, upgrade, s3, kms, tier, storage, heal, pool, security, replication, fault-tolerance, table, performance]
|
||||
if: ${{ always() && needs.prepare.result != 'skipped' }}
|
||||
runs-on: smoke-testing
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
|
||||
@@ -90,7 +90,7 @@ env:
|
||||
jobs:
|
||||
runner-check:
|
||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
runs-on: sm-standard-2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# 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.
|
||||
|
||||
name: RustFS Validation Package
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Exact RustFS commit SHA to build
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
R2_ACCESS_KEY_ID:
|
||||
required: true
|
||||
R2_SECRET_ACCESS_KEY:
|
||||
required: true
|
||||
R2_ENDPOINT:
|
||||
required: true
|
||||
R2_BUCKET:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Exact RustFS commit SHA to build
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: rustfs-validation-package-${{ inputs.source_sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve-source:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Resolve exact validation source
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.source.outputs.sha }}
|
||||
source_ref: ${{ steps.source.outputs.ref }}
|
||||
steps:
|
||||
- name: Validate requested source
|
||||
id: source
|
||||
env:
|
||||
REQUESTED_SHA: ${{ inputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
source_directory="$(mktemp -d "${RUNNER_TEMP}/validation-source.XXXXXX")"
|
||||
trap 'rm -rf "${source_directory}"' EXIT
|
||||
git init -q "${source_directory}"
|
||||
git -C "${source_directory}" fetch --no-tags --depth=1 https://github.com/rustfs/rustfs.git "${REQUESTED_SHA}"
|
||||
test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"
|
||||
echo "sha=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
echo "ref=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
build:
|
||||
needs: resolve-source
|
||||
name: Build immutable validation DEB
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout exact source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
|
||||
- name: Confirm checked out source
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: validation-package-hosted-x86_64-unknown-linux-gnu
|
||||
cache-save-if: "false"
|
||||
install-build-packaging-tools: "false"
|
||||
install-test-tools: "false"
|
||||
|
||||
- name: Build RustFS
|
||||
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
|
||||
|
||||
- name: Build validation DEB
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
|
||||
package_dir="rustfs-validation-${VALIDATION_SOURCE_SHA}"
|
||||
package_file="${RUNNER_TEMP}/validation-package/rustfs.deb"
|
||||
mkdir -p "${RUNNER_TEMP}/validation-package"
|
||||
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
|
||||
test -x "${binary}"
|
||||
mkdir -p "${package_dir}/DEBIAN" "${package_dir}/usr/bin" "${package_dir}/etc/default" \
|
||||
"${package_dir}/lib/systemd/system" "${package_dir}/usr/share/doc/rustfs"
|
||||
cp "${binary}" "${package_dir}/usr/bin/rustfs"
|
||||
chmod 755 "${package_dir}/usr/bin/rustfs"
|
||||
cp deploy/build/rustfs.service "${package_dir}/lib/systemd/system/"
|
||||
cat > "${package_dir}/etc/default/rustfs" <<'ENVEOF'
|
||||
# RustFS Environment Configuration
|
||||
# RUSTFS_ACCESS_KEY=<changeme>
|
||||
# RUSTFS_SECRET_KEY=<changeme>
|
||||
# RUSTFS_VOLUMES=""
|
||||
# RUSTFS_ADDRESS=":9000"
|
||||
ENVEOF
|
||||
cat > "${package_dir}/DEBIAN/control" <<'CONTROLEOF'
|
||||
Package: rustfs
|
||||
Version: 0.0.0~validation
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends: libc6 (>= 2.31)
|
||||
Maintainer: RustFS Team <support@rustfs.com>
|
||||
Description: RustFS immutable validation package
|
||||
Candidate package for RustFS validation only.
|
||||
CONTROLEOF
|
||||
printf '%s\n' /etc/default/rustfs > "${package_dir}/DEBIAN/conffiles"
|
||||
./scripts/release/package_service_scripts.sh after-install > "${package_dir}/DEBIAN/postinst"
|
||||
./scripts/release/package_service_scripts.sh before-remove > "${package_dir}/DEBIAN/prerm"
|
||||
./scripts/release/package_service_scripts.sh after-remove > "${package_dir}/DEBIAN/postrm"
|
||||
chmod 755 "${package_dir}/DEBIAN/postinst" "${package_dir}/DEBIAN/prerm" "${package_dir}/DEBIAN/postrm"
|
||||
cp LICENSE README.md "${package_dir}/usr/share/doc/rustfs/"
|
||||
fakeroot dpkg-deb --build "${package_dir}" "${package_file}"
|
||||
test -s "${package_file}"
|
||||
|
||||
- name: Upload opaque validation package
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/validation-package/rustfs.deb
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: [resolve-source, build]
|
||||
name: Publish immutable validation DEB
|
||||
# Candidate-controlled build scripts never run on this fresh hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
|
||||
VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}
|
||||
steps:
|
||||
- name: Download opaque validation package
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/validation-package-input
|
||||
|
||||
- name: Validate and stage opaque package bytes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -I - <<'PYTHON'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
|
||||
root = Path(os.environ["RUNNER_TEMP"])
|
||||
incoming = root / "validation-package-input"
|
||||
if incoming.is_symlink() or not incoming.is_dir() or sorted(p.name for p in incoming.iterdir()) != ["rustfs.deb"]:
|
||||
raise SystemExit("Validation artifact must contain exactly one rustfs.deb file")
|
||||
fd = os.open(incoming / "rustfs.deb", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, "rb") as source:
|
||||
info = os.fstat(source.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size == 0:
|
||||
raise SystemExit("Validation package must be a nonempty regular file")
|
||||
staged = root / "validation-publication"
|
||||
staged.mkdir(mode=0o700)
|
||||
with (staged / "rustfs.deb").open("xb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
PYTHON
|
||||
|
||||
- name: Prepare trusted publication tools
|
||||
id: tools
|
||||
env:
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AWS_CLI=aws
|
||||
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y -qq python3-venv
|
||||
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/validation-awscli.XXXXXX")"
|
||||
python3 -m venv "${AWS_CLI_DIR}"
|
||||
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
|
||||
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
|
||||
fi
|
||||
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
|
||||
echo "aws_cli=${AWS_CLI}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload validation package to immutable R2 path
|
||||
id: publish
|
||||
env:
|
||||
PACKAGE_FILE: ${{ runner.temp }}/validation-publication/rustfs.deb
|
||||
AWS_CLI: ${{ steps.tools.outputs.aws_cli }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${R2_ACCESS_KEY_ID}" || -z "${R2_SECRET_ACCESS_KEY}" || -z "${R2_ENDPOINT}" || -z "${R2_BUCKET}" ]]; then
|
||||
echo "Validation package publication requires complete R2 configuration" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ "${VALIDATION_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "${VALIDATION_SOURCE_REF}" = "${VALIDATION_SOURCE_SHA}"
|
||||
test -s "${PACKAGE_FILE}" && test ! -L "${PACKAGE_FILE}"
|
||||
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION=auto
|
||||
package_sha256="$(sha256sum "${PACKAGE_FILE}" | cut -d ' ' -f 1)"
|
||||
key="artifacts/rustfs/packages/validation/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${package_sha256}/rustfs.deb"
|
||||
url="https://dl.rustfs.com/${key}"
|
||||
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${key}" --body "${PACKAGE_FILE}" \
|
||||
--if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
|
||||
downloaded_sha256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${url}" | sha256sum | cut -d ' ' -f 1)"
|
||||
test "${downloaded_sha256}" = "${package_sha256}"
|
||||
manifest="${RUNNER_TEMP}/validation-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||
jq -n --arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${VALIDATION_SOURCE_REF}" \
|
||||
--arg source_sha "${VALIDATION_SOURCE_SHA}" --arg channel validation \
|
||||
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--arg package_url "${url}" --arg package_sha256 "${package_sha256}" \
|
||||
'{schema: 3, workflow_sha: $workflow_sha, source_ref: $source_ref, source_sha: $source_sha, channel: $channel, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' > "${manifest}"
|
||||
echo "candidate_file=${manifest}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload validation candidate manifest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: validation-candidate-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.publish.outputs.candidate_file }}
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,343 @@
|
||||
# Build source-bound helpers separately so live validation needs only artifacts.
|
||||
name: RustFS Validation Tools
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
candidate_sha:
|
||||
type: string
|
||||
required: true
|
||||
auto_testing_sha:
|
||||
type: string
|
||||
required: true
|
||||
build_native_heal:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_shared_heal:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_manager_native:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
PF_TESTING_GH_TOKEN:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
candidate_sha:
|
||||
description: Exact RustFS source SHA from the validation candidate manifest
|
||||
type: string
|
||||
required: true
|
||||
auto_testing_sha:
|
||||
description: Exact auto-testing SHA containing the Heal metadata inspector
|
||||
type: string
|
||||
required: true
|
||||
build_native_heal:
|
||||
description: Also compile and run separate exact-source native Heal regressions
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_shared_heal:
|
||||
description: Build the controlled C10 shared-reference fixture encoder
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
build_manager_native:
|
||||
description: Build and run exact candidate checkpoint/MRF/incarnation regressions
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
manager-native-regressions:
|
||||
if: ${{ inputs.build_manager_native }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
steps:
|
||||
- name: Validate immutable manager native inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
|
||||
- name: Checkout exact manager candidate source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ inputs.candidate_sha }}
|
||||
path: candidate
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout exact manager collector source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ inputs.auto_testing_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install manager native build dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
|
||||
df -h .
|
||||
|
||||
- name: Build exact unmodified candidate manager test executable
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
python3 -B auto-testing/scripts/validation_heal/manager_native.py build \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --out manager-tools
|
||||
|
||||
- name: Run exact manager regressions independently
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -B auto-testing/scripts/validation_heal/manager_native.py run \
|
||||
--directory manager-tools --out manager-evidence
|
||||
|
||||
- name: Upload manager executable identities and original diagnostics
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
manager-tools/heal-manager-native-tests
|
||||
manager-tools/*.json
|
||||
manager-tools/*.jsonl
|
||||
manager-tools/*.stderr
|
||||
manager-tools/*.log
|
||||
manager-evidence/
|
||||
if-no-files-found: warn
|
||||
|
||||
native-heal-regressions:
|
||||
if: ${{ inputs.build_native_heal }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
steps:
|
||||
- name: Validate immutable native source inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
|
||||
- name: Checkout exact native candidate source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ inputs.candidate_sha }}
|
||||
path: candidate
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout exact native collector source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ inputs.auto_testing_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native build dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
|
||||
df -h .
|
||||
|
||||
- name: Build and enumerate the unmodified candidate test executable
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
use_race_native=false
|
||||
if [[ -f auto-testing/scripts/validation_heal/race_native.py ]]; then
|
||||
support_rc=0
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
|
||||
--commit "${CANDIDATE_SHA}" || support_rc=$?
|
||||
case "${support_rc}" in
|
||||
0) use_race_native=true ;;
|
||||
2) ;; # The original exact-source collector handles legacy candidates.
|
||||
*) exit "${support_rc}" ;;
|
||||
esac
|
||||
fi
|
||||
if [[ "${use_race_native}" == true ]]; then
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native.py build \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --testing-commit "${AUTO_TESTING_SHA}" --out native-tools
|
||||
else
|
||||
python3 -B auto-testing/scripts/validation_heal/native_regressions.py build \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --out native-tools
|
||||
fi
|
||||
|
||||
- name: Run exact native regressions separately from fleet validation
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -f native-tools/race-native.json ]]; then
|
||||
test ! -e native-tools/native-regressions.json
|
||||
python3 -B auto-testing/scripts/validation_heal/race_native.py run \
|
||||
--directory native-tools --out native-evidence
|
||||
else
|
||||
python3 -B auto-testing/scripts/validation_heal/native_regressions.py run \
|
||||
--directory native-tools --out native-evidence
|
||||
fi
|
||||
|
||||
- name: Run existing F07 directory fsync failure regressions
|
||||
if: ${{ inputs.candidate_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Older immutable harness pins retain their original native suite;
|
||||
# absence of this additive collector must never fabricate its proof.
|
||||
if [[ ! -f auto-testing/scripts/validation_heal/persistence_native.py ]]; then
|
||||
mkdir native-persistence-evidence
|
||||
echo '{"complete":false,"reason":"pinned harness predates F07 directory-fsync collector","requiredNativeCoverageComplete":false,"canonicalCasePassClaimed":false}' \
|
||||
> native-persistence-evidence/not-available.json
|
||||
exit 0
|
||||
fi
|
||||
python3 -B auto-testing/scripts/validation_heal/persistence_native.py run \
|
||||
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
|
||||
--testing-commit "${AUTO_TESTING_SHA}"
|
||||
python3 -B auto-testing/scripts/validation_heal/persistence_native.py verify \
|
||||
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
|
||||
--testing-commit "${AUTO_TESTING_SHA}"
|
||||
|
||||
- name: Upload native executable identities and original diagnostics
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
native-tools/ecstore-native-tests
|
||||
native-tools/ecstore-race-native-tests
|
||||
native-tools/*.json
|
||||
native-tools/*.jsonl
|
||||
native-tools/*.stderr
|
||||
native-tools/*.log
|
||||
native-evidence/
|
||||
native-persistence-evidence/
|
||||
if-no-files-found: warn
|
||||
|
||||
heal-metadata-inspector:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
|
||||
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
|
||||
steps:
|
||||
- name: Validate immutable source inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
|
||||
|
||||
- name: Checkout exact candidate source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ inputs.candidate_sha }}
|
||||
path: candidate
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout exact inspector source
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: ${{ inputs.auto_testing_sha }}
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build the read-only candidate metadata decoder
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git -C candidate rev-parse HEAD)" = "${CANDIDATE_SHA}"
|
||||
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
|
||||
df -h .
|
||||
python3 auto-testing/scripts/validation_heal/prepare_heal_meta_inspector.py \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --out inspector-build
|
||||
# Seed the helper's separate graph from the candidate's frozen graph.
|
||||
# Cargo adds the helper package; both lock identities are retained.
|
||||
cp candidate/Cargo.lock inspector-build/Cargo.lock
|
||||
cargo build --release --manifest-path inspector-build/Cargo.toml
|
||||
python3 auto-testing/scripts/validation_heal/check_bitrot_tool.py \
|
||||
--binary inspector-build/target/release/heal-meta-inspect --source candidate
|
||||
mkdir validation-tools
|
||||
cp inspector-build/target/release/heal-meta-inspect validation-tools/
|
||||
cp inspector-build/Cargo.lock validation-tools/inspector-Cargo.lock
|
||||
python3 - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
source = json.loads(Path("inspector-build/source.json").read_text())
|
||||
binary = Path("validation-tools/heal-meta-inspect")
|
||||
source.update(built=True, testingCommit=os.environ["AUTO_TESTING_SHA"],
|
||||
binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
inspectorLockSha256=hashlib.sha256(Path("validation-tools/inspector-Cargo.lock").read_bytes()).hexdigest(),
|
||||
toolchain=subprocess.check_output(["rustc", "-vV"], text=True),
|
||||
target="x86_64-unknown-linux-gnu", runId=os.environ["GITHUB_RUN_ID"],
|
||||
runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
|
||||
Path("validation-tools/heal-meta-inspect.json").write_text(json.dumps(source, indent=2) + "\n")
|
||||
PY
|
||||
git -C candidate diff --exit-code
|
||||
|
||||
- name: Build and test the controlled C10 metadata encoder
|
||||
if: ${{ inputs.build_shared_heal }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -B auto-testing/scripts/validation_heal/prepare_heal_shared_fixture.py \
|
||||
--source candidate --commit "${CANDIDATE_SHA}" --out shared-build
|
||||
cp candidate/Cargo.lock shared-build/Cargo.lock
|
||||
export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/inspector-build/target"
|
||||
cargo build --release --manifest-path shared-build/Cargo.toml
|
||||
mkdir shared-tools
|
||||
if ! cargo test --release --manifest-path shared-build/Cargo.toml -- --test-threads=1 \
|
||||
> shared-tools/shared-fixture-tests.log 2>&1; then
|
||||
cat shared-tools/shared-fixture-tests.log
|
||||
exit 1
|
||||
fi
|
||||
cp "${CARGO_TARGET_DIR}/release/heal-shared-fixture" shared-tools/
|
||||
cp shared-build/Cargo.lock shared-tools/shared-Cargo.lock
|
||||
python3 - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
source = json.loads(Path("shared-build/source.json").read_text())
|
||||
source.update(schemaVersion=1, built=True, sourceUnmodified=True,
|
||||
candidateSourceTree=subprocess.check_output(["git", "-C", "candidate", "rev-parse", "HEAD^{tree}"], text=True).strip(),
|
||||
testingCommit=os.environ["AUTO_TESTING_SHA"],
|
||||
binarySha256=hashlib.sha256(Path("shared-tools/heal-shared-fixture").read_bytes()).hexdigest(),
|
||||
sharedLockSha256=hashlib.sha256(Path("shared-tools/shared-Cargo.lock").read_bytes()).hexdigest(),
|
||||
toolchain=subprocess.check_output(["rustc", "-vV"], text=True), target="x86_64-unknown-linux-gnu",
|
||||
runId=os.environ["GITHUB_RUN_ID"], runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
|
||||
Path("shared-tools/heal-shared-fixture.json").write_text(json.dumps(source, indent=2) + "\n")
|
||||
PY
|
||||
git -C candidate diff --exit-code
|
||||
|
||||
- name: Upload controlled C10 fixture encoder evidence
|
||||
if: ${{ always() && inputs.build_shared_heal }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: shared-tools/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload immutable validation tools
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: validation-tools/
|
||||
if-no-files-found: error
|
||||
@@ -16,8 +16,8 @@
|
||||
#
|
||||
# Forces a job failure and then runs .github/actions/schedule-failure-issue
|
||||
# through the exact `needs` + `always() && contains(needs.*.result,
|
||||
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz,
|
||||
# performance-ab). Dispatch it twice to verify both the issue-creation and
|
||||
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz).
|
||||
# Dispatch it twice to verify both the issue-creation and
|
||||
# the dedupe-comment paths, then close the resulting
|
||||
# "[scheduled-failure] Schedule Failure Alert Drill" issue in rustfs/backlog
|
||||
# (the default target repository). Requires the BACKLOG_ISSUE_TOKEN secret
|
||||
|
||||
@@ -28,7 +28,6 @@ on:
|
||||
- "mint"
|
||||
- "minio-interop"
|
||||
- "Nightly GNU Build"
|
||||
- "Performance A/B"
|
||||
- "Runner Hygiene"
|
||||
types: [completed]
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ jobs:
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
|
||||
|
||||
- name: Test multipart parent durability barriers
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture
|
||||
|
||||
- name: Test Windows path conversion
|
||||
shell: pwsh
|
||||
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Deployment
|
||||
|
||||
- Existing volumes created by older root-running container images must be writable by the non-root runtime identity before upgrading. The default container UID/GID is `10001:10001`; stop the affected instances, verify host mount paths, and for ordinary bind mounts run `sudo chown -R 10001:10001 /actual/data /actual/logs` on each RustFS data/log mount. Account for ACLs, NFS, Kubernetes, and rootless UID mappings. Image-build ownership changes do not migrate mounted data. See the Docker Quick Start in [README.md](README.md).
|
||||
- Local disk initialization now checks file creation, writing, and deletion in the data root and fixed internal metadata directories. Unwritable disks are rejected using existing startup/quorum rules, with filesystem errors and Unix runtime/owner IDs logged for permission failures, instead of admitting those disks and discovering the mismatch on a later write. Existing object trees are not scanned and volume ownership is never changed automatically.
|
||||
|
||||
### Security
|
||||
|
||||
- **Header-signed SigV4 requests honour only signed headers** (GHSA-xm99-m3gq-83g8): a request authenticated with a SigV4 `Authorization` header that carries an `x-amz-*` request header not listed in its `SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3 and the presigned rule from GHSA-g8w9-qw9q-fghr. Previously anyone holding one header-signed `PutObject` request could add an unsigned `x-amz-copy-source` and turn it into a `CopyObject` that ran with the signer's permissions, copying any object the signer could read into the target. An `Authorization` header whose algorithm token is not `AWS4-HMAC-SHA256` is now rejected instead of being verified as SigV4. The request-envelope headers `x-amz-content-sha256`, `x-amz-decoded-content-length`, `x-amz-trailer` and `x-amz-checksum-algorithm` (the same set the upstream `s3s` fix exempts) and `x-amz-cf-id` (CloudFront) remain tolerated unsigned; AWS SDKs and RustFS's own signers already sign every other `x-amz-*` header. SigV2, JWT and anonymous requests are unchanged.
|
||||
|
||||
Generated
+369
-554
File diff suppressed because it is too large
Load Diff
+34
-24
@@ -66,6 +66,7 @@ members = [
|
||||
"crates/io-metrics", # I/O metrics collection for performance analysis
|
||||
"crates/io-core", # Buffered Bytes, mmap-then-copy, and aligned pread I/O helpers
|
||||
"crates/zip", # ZIP file handling and compression
|
||||
"crates/zstd-compat", # Fixed codec for dependencies requiring zstd 0.13
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
@@ -148,7 +149,7 @@ async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37.1" }
|
||||
async-compression = { version = "0.4.51" }
|
||||
async-trait = "0.1.92"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
async-nats = { version = "0.51.0", default-features = false }
|
||||
axum = "0.8.9"
|
||||
futures = "0.3.34"
|
||||
futures-core = "0.3.34"
|
||||
@@ -174,9 +175,9 @@ tokio-rustls = { default-features = false, version = "0.26.6" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.6"
|
||||
tokio-util = { version = "0.7.20" }
|
||||
tonic = { version = "0.14.6" }
|
||||
tonic-prost = { version = "0.14.6" }
|
||||
tonic-prost-build = { version = "0.14.6" }
|
||||
tonic = { version = "0.14.8" }
|
||||
tonic-prost = { version = "0.14.8" }
|
||||
tonic-prost-build = { version = "0.14.8" }
|
||||
tower = { version = "0.5.3" }
|
||||
tower-http = { version = "0.7.1" }
|
||||
h3 = "0.0.8"
|
||||
@@ -252,15 +253,15 @@ atoi = "3.1.1"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.13.0" }
|
||||
aws-credential-types = { version = "1.3.1" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.125.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.154.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.121.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.128.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.156.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.123.0" }
|
||||
aws-smithy-async = { version = "1.3.1" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.5.1" }
|
||||
aws-smithy-runtime-api = { version = "1.19.1" }
|
||||
aws-smithy-types = { version = "1.9.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.5.2" }
|
||||
aws-smithy-runtime-api = { version = "1.19.2" }
|
||||
aws-smithy-types = { version = "1.9.1" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "9.0.0"
|
||||
brotli = "9.1.1"
|
||||
clap = { version = "4.6.7" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.12.0"
|
||||
@@ -272,7 +273,7 @@ crossbeam-utils = "0.8.23"
|
||||
datafusion = { default-features = false, version = "55.2.0" }
|
||||
derive_builder = "0.20.2"
|
||||
ed25519-dalek = "3.0.0"
|
||||
enumset = "1.1.14"
|
||||
enumset = "1.1.15"
|
||||
faster-hex = "1.0.0"
|
||||
flate2 = "1.1.10"
|
||||
glob = "0.3.4"
|
||||
@@ -302,6 +303,7 @@ num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.13.0"
|
||||
object = { version = "=0.40.0", default-features = false }
|
||||
parking_lot = "0.13.0"
|
||||
same-file = "1.0.6"
|
||||
path-absolutize = "4.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||
@@ -317,9 +319,9 @@ rustfs-erasure-codec = { version = "9.0.0" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||
redis = { version = "1.7.1" }
|
||||
redis = { version = "1.8.0" }
|
||||
rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.5" }
|
||||
rustix = { version = "1.1.6" }
|
||||
rust-embed = { version = "8.13.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
|
||||
@@ -327,12 +329,12 @@ s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fc
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.4"
|
||||
smallvec = { version = "1.16.3" }
|
||||
smallvec = { version = "1.16.4" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.4.0" }
|
||||
strum = { version = "0.28.0" }
|
||||
sysinfo = "0.39.6"
|
||||
sysinfo = "0.40.0"
|
||||
temp-env = "0.3.6"
|
||||
tempfile = "3.27.0"
|
||||
test-case = "3.4.0"
|
||||
@@ -346,7 +348,7 @@ tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.28.0" }
|
||||
uuid = { version = "1.29.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
@@ -354,13 +356,13 @@ winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "9.0.5"
|
||||
zip = "9.1.3"
|
||||
zstd = "0.14.3"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
dial9 = { version = "0.5.5", default-features = false }
|
||||
dial9 = { version = "0.5.6", default-features = false }
|
||||
opentelemetry = { version = "0.33.1" }
|
||||
opentelemetry-appender-tracing = { version = "0.33.1" }
|
||||
opentelemetry-otlp = { version = "0.33.1" }
|
||||
@@ -368,23 +370,23 @@ opentelemetry-proto = { version = "0.33.1", default-features = false, features =
|
||||
opentelemetry_sdk = { version = "0.33.1" }
|
||||
opentelemetry-semantic-conventions = { version = "0.33.1" }
|
||||
opentelemetry-stdout = { version = "0.33.1" }
|
||||
pyroscope = { version = "2.1.4" }
|
||||
pyroscope = { version = "2.1.5" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.1" }
|
||||
libunftp = { version = "0.24.0" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "12.2.1" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.64.1" }
|
||||
russh = { version = "0.65.0" }
|
||||
russh-sftp = "3.0.1"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.7.1" }
|
||||
rustfs-mimalloc = { version = "0.7.2" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.28.7", default-features = false }
|
||||
hotpath = { version = "0.30.1", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
@@ -421,3 +423,11 @@ inherits = "release"
|
||||
inherits = "release"
|
||||
debug = true
|
||||
strip = "none"
|
||||
|
||||
# Pin upstream fixes until compatible crates.io releases are available.
|
||||
[patch.crates-io]
|
||||
zstd = { path = "crates/zstd-compat" }
|
||||
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
|
||||
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
|
||||
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
|
||||
oauth2 = { git = "https://github.com/ramosbugs/oauth2-rs", rev = "89585f4a280afd7b285eaf1c9be0068874e03d22" }
|
||||
|
||||
@@ -130,6 +130,10 @@ curl -O https://rustfs.com/install_rustfs.sh && bash install_rustfs.sh
|
||||
|
||||
The RustFS container runs as a non-root user `rustfs` (UID/GID `10001:10001`). If you bind-mount host directories with Docker or Compose, every mounted path must be writable by that user, otherwise startup may fail with permission denied errors. This applies to data directories, log directories, and TLS certificate directories when `RUSTFS_TLS_PATH` is enabled.
|
||||
|
||||
**Upgrading an existing data volume:** some older images ran as root. Replacing the image does not change ownership of mounted files. Before upgrading to a non-root image, stop the instances using the volume, confirm the actual host mount paths and runtime UID/GID, and grant that identity access to all existing data and log directories. For ordinary Docker bind mounts using the default identity, run `sudo chown -R 10001:10001 /actual/data /actual/logs` on the host. Repeat for each data mount; do not apply this recursively to unrelated shared directories. Kubernetes, NFS, ACLs, and rootless UID mappings may require a different ownership or access-control adjustment.
|
||||
|
||||
RustFS checks creation, writing, and deletion of a small temporary file in each data root and fixed internal metadata directories when opening a local disk. A failed check rejects that disk through the existing startup/quorum rules and logs the filesystem error; permission diagnostics include the runtime UID/GID and path owner on Unix. This is not a recursive scan of existing buckets or objects: permissions must also allow access to their contents. RustFS does not change ownership automatically. After migration, verify upload, download, and deletion rather than relying only on a green health endpoint.
|
||||
|
||||
```bash
|
||||
# Create data and logs directories
|
||||
mkdir -p data logs
|
||||
@@ -141,7 +145,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
@@ -220,7 +224,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# Build specific version
|
||||
./docker-buildx.sh --release 1.0.3 --push
|
||||
./docker-buildx.sh --release 1.0.4 --push
|
||||
|
||||
# Build for custom registry
|
||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||
@@ -238,7 +242,7 @@ You can also use Make targets for convenience:
|
||||
```bash
|
||||
make docker-buildx # Build locally
|
||||
make docker-buildx-push # Build and push
|
||||
make docker-buildx-version VERSION=1.0.3 # Build specific version
|
||||
make docker-buildx-version VERSION=1.0.4 # Build specific version
|
||||
make help-docker # Show all Docker-related commands
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# 构建指定版本
|
||||
./docker-buildx.sh --release 1.0.3 --push
|
||||
./docker-buildx.sh --release 1.0.4 --push
|
||||
|
||||
# 构建并推送到自定义仓库
|
||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
```bash
|
||||
make docker-buildx # 本地构建
|
||||
make docker-buildx-push # 构建并推送
|
||||
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
|
||||
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
|
||||
make help-docker # 显示所有 Docker 相关命令
|
||||
```
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ rustfs-io-core = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread"] }
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time"] }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,957 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
//! Mutable admission capacity without replacing or revoking held permits.
|
||||
|
||||
use std::num::NonZeroU16;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Notify, Semaphore};
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// A resource owner publishes a capacity; running units retain their permits
|
||||
/// after a reduction. Lease slots are reserved once, with no per-unit Arc
|
||||
/// allocation. Tokio wait queues may lazily initialize platform mutex storage
|
||||
/// on first contention. It does not discover devices, start
|
||||
/// workers, or decide resource policy. Cancellation drops the acquire future.
|
||||
///
|
||||
/// Uncontended admission is atomic. Parked work takes a FIFO admission turn,
|
||||
/// so repeated fast acquisitions cannot starve an older waiter. Source/age
|
||||
/// priority still belongs to the caller's scheduler, before joining this queue.
|
||||
pub struct AdaptiveAdmission {
|
||||
// One CAS observes both capacity and active count, so a concurrent shrink
|
||||
// cannot admit a new unit using an already superseded capacity.
|
||||
state: AtomicU64,
|
||||
origin: Instant,
|
||||
valid_until_millis: AtomicU64,
|
||||
// Only the FIFO turn holder subscribes. A stored notification is harmless:
|
||||
// capacity and freshness are always checked again after waking.
|
||||
changed: Notify,
|
||||
queued: AtomicUsize,
|
||||
// FIFO admission turn, not a mutable-state lock. No storage lock or
|
||||
// admitted permit may be held while joining. This private semaphore is
|
||||
// never closed; release and resize do not acquire it.
|
||||
turn: Semaphore,
|
||||
maximum: u32,
|
||||
slots: Box<[PermitSlots]>,
|
||||
next_chunk: AtomicUsize,
|
||||
}
|
||||
|
||||
struct PermitSlots {
|
||||
free: AtomicU64,
|
||||
references: [AtomicUsize; 64],
|
||||
}
|
||||
|
||||
struct QueuedAdmission<'a>(&'a AdaptiveAdmission);
|
||||
|
||||
impl Drop for QueuedAdmission<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.queued.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// One active unit. Clones share that unit without allocating; the final drop
|
||||
/// returns it to the same owner, including after resize or waiter cancellation.
|
||||
#[must_use = "hold the permit for the admitted unit's complete lifetime"]
|
||||
pub struct AdaptivePermit {
|
||||
owner: Arc<AdaptiveAdmission>,
|
||||
slot: usize,
|
||||
}
|
||||
|
||||
impl Clone for AdaptivePermit {
|
||||
fn clone(&self) -> Self {
|
||||
// Every slot reference owns a strong owner reference. Clone the Arc
|
||||
// first, so its overflow protection also bounds the slot refcount.
|
||||
let owner = Arc::clone(&self.owner);
|
||||
owner.slots[self.slot / 64].references[self.slot % 64].fetch_add(1, Ordering::Relaxed);
|
||||
Self { owner, slot: self.slot }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdaptivePermit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AdaptivePermit")
|
||||
.field("slot", &self.slot)
|
||||
.field("owner", &self.owner.snapshot())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// One coherent view of configured capacity and actual active units.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AdaptiveAdmissionSnapshot {
|
||||
/// Current cap on new admissions. Zero pauses all new units.
|
||||
pub limit: u32,
|
||||
/// Existing units, which may temporarily exceed a reduced limit.
|
||||
pub active: u32,
|
||||
}
|
||||
|
||||
/// Node scheduling capacity, separate from physical I/O and buffer ownership.
|
||||
/// Its non-cloneable units end with the async operation; a native tail must
|
||||
/// retain its own physical-resource unit, never this scheduling unit.
|
||||
pub struct NodeAdmission {
|
||||
capacity: Arc<AdaptiveAdmission>,
|
||||
completed: AtomicU64,
|
||||
}
|
||||
|
||||
/// One async operation's scheduling unit. Dropping a cancelled or failed
|
||||
/// operation releases capacity without claiming completed work.
|
||||
#[must_use = "retain the unit until the async operation ends"]
|
||||
pub struct NodePermit {
|
||||
_capacity: AdaptivePermit,
|
||||
owner: Arc<NodeAdmission>,
|
||||
}
|
||||
|
||||
impl NodeAdmission {
|
||||
/// Construct one node owner with no capacity until a resource sample arrives.
|
||||
pub fn new(maximum: NonZeroU16) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
capacity: AdaptiveAdmission::new(maximum, 0),
|
||||
completed: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish feedback with this owner's actual completed-work counter.
|
||||
pub fn update(&self, controller: &mut FeedbackController, observation: Option<FeedbackObservation>) -> FeedbackDecision {
|
||||
let observation = observation.map(|mut observation| {
|
||||
observation.completed_units = self.completed();
|
||||
observation
|
||||
});
|
||||
controller.update(&self.capacity, observation)
|
||||
}
|
||||
|
||||
/// Current leased scheduling cap and held async units.
|
||||
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
|
||||
self.capacity.snapshot()
|
||||
}
|
||||
|
||||
/// Completed operations, or unknown after the counter exhausts its range.
|
||||
pub fn completed(&self) -> Option<u64> {
|
||||
let value = self.completed.load(Ordering::Acquire);
|
||||
(value != u64::MAX).then_some(value)
|
||||
}
|
||||
|
||||
/// Try without waiting, including when the caller already owns a lock.
|
||||
pub fn try_acquire(self: &Arc<Self>) -> Option<NodePermit> {
|
||||
self.capacity.try_acquire().map(|capacity| NodePermit {
|
||||
_capacity: capacity,
|
||||
owner: self.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait in FIFO order. Callers must provide a deadline/cancellation and
|
||||
/// acquire before storage locks, not while retaining another node unit.
|
||||
pub async fn acquire(self: &Arc<Self>) -> NodePermit {
|
||||
NodePermit {
|
||||
_capacity: self.capacity.acquire().await,
|
||||
owner: self.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodePermit {
|
||||
/// Count a completed operation exactly once, then release its node unit.
|
||||
/// This counter is scheduling progress, not an authoritative heal receipt.
|
||||
pub fn complete(self) {
|
||||
let _ = self
|
||||
.owner
|
||||
.completed
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |value| value.checked_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Confidence-qualified pressure for one complete sampling window.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourcePressure {
|
||||
/// Missing, stale, reset, or insufficient observations; never evidence of idle.
|
||||
Unknown,
|
||||
/// A resource or foreground-protection threshold was reached.
|
||||
Busy,
|
||||
/// All required observations were fresh and below their low watermarks.
|
||||
Stable,
|
||||
}
|
||||
|
||||
/// Resource-owner input to the controller, after applying operator and kernel
|
||||
/// caps. This describes one owner; callers must not duplicate a device's budget
|
||||
/// for aliases, pools, or remote requesters.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FeedbackObservation {
|
||||
/// Identity of the resource/telemetry generation. Replacement resets probes.
|
||||
pub generation: u64,
|
||||
/// Upper bound imposed by resources and explicit configuration; zero blocks.
|
||||
pub max_concurrency: u32,
|
||||
/// Complete-window pressure, including the confidence of its sources.
|
||||
pub pressure: ResourcePressure,
|
||||
/// Cumulative completed work in this generation, not just scheduled work.
|
||||
pub completed_units: Option<u64>,
|
||||
}
|
||||
|
||||
/// Why the latest concurrency limit was selected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FeedbackReason {
|
||||
/// The resource cap is unavailable; no new unit can be bounded safely.
|
||||
Unavailable,
|
||||
/// A changed owner or reset work counter requires a new baseline.
|
||||
NewBaseline,
|
||||
/// The resource or operator cap reduced the permitted concurrency.
|
||||
CapReduced,
|
||||
/// Pressure caused an immediate multiplicative reduction.
|
||||
Pressure,
|
||||
/// Incomplete telemetry retains at most one maintenance unit.
|
||||
LowConfidence,
|
||||
/// Waiting for consecutive stable windows, work progress and cooldown.
|
||||
Recovering,
|
||||
/// One additional unit is being probed after a stable recovery period.
|
||||
Probe,
|
||||
}
|
||||
|
||||
/// One controller decision, suitable for metrics without dynamic labels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FeedbackDecision {
|
||||
/// New admissions use this cap; existing permits remain valid.
|
||||
pub limit: u32,
|
||||
/// Decision cause, distinct from a proof of work completion or storage health.
|
||||
pub reason: FeedbackReason,
|
||||
}
|
||||
|
||||
/// Single-owner additive probe/multiplicative decrease controller. It adjusts
|
||||
/// only concurrency. Every source must be fresh before declaring `Stable`.
|
||||
///
|
||||
/// The sampler runs every second. A gap over two seconds resets recovery; eight
|
||||
/// consecutive stable seconds and observed completed work allow one extra unit.
|
||||
/// Pressure or lower caps apply immediately. This policy is not a performance
|
||||
/// claim; its parameters require the same workload/foreground-SLO acceptance.
|
||||
pub struct FeedbackController {
|
||||
previous: Option<(Instant, FeedbackObservation)>,
|
||||
stable_since: Option<Instant>,
|
||||
last_change: Instant,
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
impl Default for FeedbackController {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FeedbackController {
|
||||
/// Start without a resource observation or an admission grant.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
previous: None,
|
||||
stable_since: None,
|
||||
last_change: Instant::now(),
|
||||
limit: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume one sample and publish a three-second lease to the same gate.
|
||||
/// If this function stops being called, admission expires independently.
|
||||
pub fn update(&mut self, gate: &AdaptiveAdmission, observation: Option<FeedbackObservation>) -> FeedbackDecision {
|
||||
let observation = observation.map(|mut observation| {
|
||||
observation.max_concurrency = observation.max_concurrency.min(gate.maximum);
|
||||
observation
|
||||
});
|
||||
let decision = self.decide(Instant::now(), observation);
|
||||
gate.set_sampled_limit(decision.limit, Duration::from_secs(3));
|
||||
decision
|
||||
}
|
||||
|
||||
fn decide(&mut self, now: Instant, observation: Option<FeedbackObservation>) -> FeedbackDecision {
|
||||
let Some(observation) = observation else {
|
||||
self.previous = None;
|
||||
self.stable_since = None;
|
||||
self.last_change = now;
|
||||
self.limit = 0;
|
||||
return FeedbackDecision {
|
||||
limit: 0,
|
||||
reason: FeedbackReason::Unavailable,
|
||||
};
|
||||
};
|
||||
let previous = self.previous.replace((now, observation));
|
||||
let contiguous = previous.is_some_and(|(at, old)| {
|
||||
now.saturating_duration_since(at) <= Duration::from_secs(2)
|
||||
&& old.generation == observation.generation
|
||||
&& matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b >= a)
|
||||
});
|
||||
let advanced = previous
|
||||
.is_some_and(|(_, old)| matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b > a));
|
||||
let old_limit = self.limit;
|
||||
let reason = if observation.max_concurrency == 0 {
|
||||
self.limit = 0;
|
||||
self.stable_since = None;
|
||||
FeedbackReason::CapReduced
|
||||
} else if observation.pressure == ResourcePressure::Busy {
|
||||
self.limit = (self.limit / 2).max(1).min(observation.max_concurrency);
|
||||
self.stable_since = None;
|
||||
// Repeated busy windows restart cooldown, even at the minimum cap.
|
||||
self.last_change = now;
|
||||
FeedbackReason::Pressure
|
||||
} else if observation.max_concurrency < self.limit {
|
||||
self.limit = observation.max_concurrency;
|
||||
self.stable_since = None;
|
||||
FeedbackReason::CapReduced
|
||||
} else if observation.pressure == ResourcePressure::Unknown || observation.completed_units.is_none() {
|
||||
self.limit = 1.min(observation.max_concurrency);
|
||||
self.stable_since = None;
|
||||
FeedbackReason::LowConfidence
|
||||
} else if !contiguous || self.limit == 0 {
|
||||
self.limit = 1.min(observation.max_concurrency);
|
||||
self.stable_since = None;
|
||||
self.last_change = now;
|
||||
FeedbackReason::NewBaseline
|
||||
} else {
|
||||
let since = *self.stable_since.get_or_insert(now);
|
||||
if advanced
|
||||
&& now.saturating_duration_since(since) >= Duration::from_secs(8)
|
||||
&& now.saturating_duration_since(self.last_change) >= Duration::from_secs(8)
|
||||
&& self.limit < observation.max_concurrency
|
||||
{
|
||||
self.limit += 1;
|
||||
FeedbackReason::Probe
|
||||
} else {
|
||||
FeedbackReason::Recovering
|
||||
}
|
||||
};
|
||||
if old_limit != self.limit {
|
||||
self.last_change = now;
|
||||
}
|
||||
FeedbackDecision {
|
||||
limit: self.limit,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdaptiveAdmission {
|
||||
/// Construct a bounded owner, with zero initial capacity allowed for unknown
|
||||
/// input. The immutable maximum reserves at most 65,535 lease slots once;
|
||||
/// acquiring or cloning a permit does not allocate a per-unit Arc.
|
||||
pub fn new(maximum: NonZeroU16, limit: u32) -> Arc<Self> {
|
||||
let maximum_u32 = u32::from(maximum.get());
|
||||
let maximum = usize::from(maximum.get());
|
||||
let slots = (0..maximum.div_ceil(64))
|
||||
.map(|chunk| {
|
||||
let count = (maximum - chunk * 64).min(64);
|
||||
PermitSlots {
|
||||
free: AtomicU64::new(if count == 64 { u64::MAX } else { (1_u64 << count) - 1 }),
|
||||
references: std::array::from_fn(|_| AtomicUsize::new(0)),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice();
|
||||
Arc::new(Self {
|
||||
state: AtomicU64::new(u64::from(limit.min(maximum_u32)) << 32),
|
||||
origin: Instant::now(),
|
||||
valid_until_millis: AtomicU64::new(u64::MAX),
|
||||
changed: Notify::new(),
|
||||
queued: AtomicUsize::new(0),
|
||||
turn: Semaphore::new(1),
|
||||
maximum: maximum_u32,
|
||||
slots,
|
||||
next_chunk: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a cap bounded by the immutable maximum, without losing held work
|
||||
/// or changing owner identity.
|
||||
pub fn set_limit(&self, limit: u32) {
|
||||
let limit = limit.min(self.maximum);
|
||||
let _ = self.state.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
|
||||
Some((u64::from(limit) << 32) | (state & u64::from(u32::MAX)))
|
||||
});
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
/// Publish a cap with a finite observation lifetime. Expired observations
|
||||
/// block new admissions even if the sampling worker has stopped. Existing
|
||||
/// permits are retained. A single resource owner must serialize publishers.
|
||||
///
|
||||
/// Capacity is published before freshness, so a concurrent reader can
|
||||
/// temporarily reject a fresh update but cannot use an increased lifetime
|
||||
/// with the previous, potentially larger capacity.
|
||||
pub fn set_sampled_limit(&self, limit: u32, valid_for: Duration) {
|
||||
self.set_limit(limit);
|
||||
let deadline = self
|
||||
.elapsed_millis()
|
||||
.saturating_add(u64::try_from(valid_for.as_millis()).unwrap_or(u64::MAX));
|
||||
self.valid_until_millis.store(deadline, Ordering::Release);
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
fn elapsed_millis(&self) -> u64 {
|
||||
u64::try_from(self.origin.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn fresh(&self) -> bool {
|
||||
self.elapsed_millis() < self.valid_until_millis.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Read the cap and active count from the same atomic observation.
|
||||
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
|
||||
// Acquire freshness first: a newly published lease must not be paired
|
||||
// with capacity loaded before its preceding reduction.
|
||||
let fresh = self.fresh();
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
AdaptiveAdmissionSnapshot {
|
||||
limit: if fresh {
|
||||
u32::try_from(state >> 32).unwrap_or(u32::MAX)
|
||||
} else {
|
||||
0
|
||||
},
|
||||
active: u32::try_from(state & u64::from(u32::MAX)).unwrap_or(u32::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit immediately if capacity remains, without a per-unit allocation.
|
||||
/// The uncontended path uses atomics; refunding a racing acquisition may
|
||||
/// wake the FIFO head through Tokio's notification queue.
|
||||
pub fn try_acquire(self: &Arc<Self>) -> Option<AdaptivePermit> {
|
||||
if self.queued.load(Ordering::Acquire) != 0 {
|
||||
return None;
|
||||
}
|
||||
let permit = self.acquire_capacity()?;
|
||||
// A waiter can register between the first queue check and the capacity
|
||||
// CAS. Refund before exposing the permit rather than stealing its turn.
|
||||
(self.queued.load(Ordering::Acquire) == 0).then_some(permit)
|
||||
}
|
||||
|
||||
fn acquire_capacity(self: &Arc<Self>) -> Option<AdaptivePermit> {
|
||||
if !self.fresh() {
|
||||
return None;
|
||||
}
|
||||
let state = self.state.load(Ordering::Acquire);
|
||||
if state & u64::from(u32::MAX) >= state >> 32 {
|
||||
return None;
|
||||
}
|
||||
let slot = self.reserve_slot()?;
|
||||
if self
|
||||
.state
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
|
||||
let active = state & u64::from(u32::MAX);
|
||||
(active < state >> 32).then(|| state + 1)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
self.release_slot(slot);
|
||||
self.changed.notify_one();
|
||||
return None;
|
||||
}
|
||||
self.slots[slot / 64].references[slot % 64].store(1, Ordering::Release);
|
||||
let permit = AdaptivePermit {
|
||||
owner: self.clone(),
|
||||
slot,
|
||||
};
|
||||
// The capacity CAS can retry across an observation expiry. Recheck
|
||||
// before giving ownership to the caller; dropping refunds this unit.
|
||||
self.fresh().then_some(permit)
|
||||
}
|
||||
|
||||
fn reserve_slot(&self) -> Option<usize> {
|
||||
let first = self.next_chunk.fetch_add(1, Ordering::Relaxed) % self.slots.len();
|
||||
for offset in 0..self.slots.len() {
|
||||
let chunk_index = (first + offset) % self.slots.len();
|
||||
let free = &self.slots[chunk_index].free;
|
||||
if let Ok(bits) = free.try_update(Ordering::AcqRel, Ordering::Acquire, |bits| (bits != 0).then(|| bits & (bits - 1)))
|
||||
{
|
||||
// trailing_zeros is in 0..64 because the zero word is rejected.
|
||||
return Some(chunk_index * 64 + bits.trailing_zeros() as usize);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn release_slot(&self, slot: usize) {
|
||||
self.slots[slot / 64].free.fetch_or(1_u64 << (slot % 64), Ordering::Release);
|
||||
}
|
||||
|
||||
/// Wait for capacity. The future owns no permit until it returns; dropping
|
||||
/// a pending future leaves capacity untouched. Wrap in the caller's existing
|
||||
/// cancellation/deadline select before acquiring any storage commit locks.
|
||||
pub async fn acquire(self: &Arc<Self>) -> AdaptivePermit {
|
||||
if let Some(permit) = self.try_acquire() {
|
||||
return permit;
|
||||
}
|
||||
self.queued.fetch_add(1, Ordering::AcqRel);
|
||||
let _queued = QueuedAdmission(self);
|
||||
// Retain the successful permit inside its Result. No code can close
|
||||
// this private semaphore, so acquisition cannot return an error.
|
||||
let _turn = self.turn.acquire().await;
|
||||
loop {
|
||||
let notified = self.changed.notified();
|
||||
tokio::pin!(notified);
|
||||
// Register before checking capacity to avoid a lost release/resize.
|
||||
notified.as_mut().enable();
|
||||
if let Some(permit) = self.acquire_capacity() {
|
||||
return permit;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AdaptivePermit {
|
||||
fn drop(&mut self) {
|
||||
if self.owner.slots[self.slot / 64].references[self.slot % 64].fetch_sub(1, Ordering::AcqRel) != 1 {
|
||||
return;
|
||||
}
|
||||
// Publish the free slot before exposing another admission capacity.
|
||||
self.owner.release_slot(self.slot);
|
||||
self.owner.state.fetch_sub(1, Ordering::AcqRel);
|
||||
self.owner.changed.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_progress_requires_explicit_completion_and_never_wraps() {
|
||||
let node = NodeAdmission::new(NonZeroU16::new(2).expect("two slots"));
|
||||
node.capacity.set_limit(2);
|
||||
drop(node.acquire().await);
|
||||
assert_eq!(node.completed(), Some(0), "cancellation is not progress");
|
||||
node.acquire().await.complete();
|
||||
assert_eq!(node.completed(), Some(1));
|
||||
node.completed.store(u64::MAX - 1, Ordering::Release);
|
||||
node.acquire().await.complete();
|
||||
node.acquire().await.complete();
|
||||
assert_eq!(node.completed(), None, "exhausted progress stays unknown");
|
||||
assert_eq!(node.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_node_work_does_not_reserve_capacity_for_unrelated_native_tail() {
|
||||
let node = NodeAdmission::new(NonZeroU16::new(1).expect("one slot"));
|
||||
node.capacity.set_limit(1);
|
||||
let physical = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one disk unit"), 1);
|
||||
let scheduling = node.acquire().await;
|
||||
let native_tail = physical.acquire().await;
|
||||
drop(scheduling);
|
||||
assert_eq!(node.completed(), Some(0));
|
||||
assert!(node.try_acquire().is_some(), "healthy resources can still be scheduled");
|
||||
assert!(physical.try_acquire().is_none(), "the stalled disk remains charged");
|
||||
drop(native_tail);
|
||||
assert!(physical.try_acquire().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn node_feedback_expires_without_revoking_held_work_or_fabricating_progress() {
|
||||
let node = NodeAdmission::new(NonZeroU16::new(2).expect("two slots"));
|
||||
let mut controller = FeedbackController::new();
|
||||
let input = FeedbackObservation {
|
||||
generation: 1,
|
||||
max_concurrency: 2,
|
||||
pressure: ResourcePressure::Stable,
|
||||
completed_units: Some(99),
|
||||
};
|
||||
node.update(&mut controller, Some(input));
|
||||
let held = node.acquire().await;
|
||||
assert_eq!(node.completed(), Some(0));
|
||||
tokio::time::advance(Duration::from_secs(4)).await;
|
||||
assert_eq!(node.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
|
||||
assert!(node.try_acquire().is_none());
|
||||
held.complete();
|
||||
assert_eq!(node.completed(), Some(1));
|
||||
assert_eq!(node.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_unit_stays_admitted_until_native_worker_releases_its_last_clone() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
|
||||
let outer = gate.acquire().await;
|
||||
let native = outer.clone();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let worker = tokio::task::spawn_blocking(move || {
|
||||
started_tx.send(()).expect("worker started");
|
||||
release_rx.blocking_recv().expect("release native worker");
|
||||
drop(native);
|
||||
});
|
||||
started_rx.await.expect("native worker is live");
|
||||
drop(outer);
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
assert!(gate.try_acquire().is_none());
|
||||
gate.set_limit(0);
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
release_tx.send(()).expect("release tail");
|
||||
worker.await.expect("native tail completed");
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
gate.set_limit(1);
|
||||
assert!(gate.try_acquire().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_slots_cross_word_boundaries_and_recycle_only_after_final_drop() {
|
||||
let maximum = NonZeroU16::new(65).expect("partial second chunk");
|
||||
let gate = AdaptiveAdmission::new(maximum, u32::MAX);
|
||||
assert_eq!(gate.snapshot().limit, 65);
|
||||
for _ in 0..4 {
|
||||
let units: Vec<_> = (0..65).map(|_| gate.try_acquire().expect("free slot")).collect();
|
||||
let tails = units.clone();
|
||||
assert_eq!(gate.snapshot().active, 65);
|
||||
assert!(gate.try_acquire().is_none());
|
||||
drop(units);
|
||||
assert_eq!(gate.snapshot().active, 65);
|
||||
gate.set_limit(0);
|
||||
for (remaining, tail) in (0..65).rev().zip(tails) {
|
||||
drop(tail);
|
||||
assert_eq!(gate.snapshot().active, remaining);
|
||||
}
|
||||
gate.set_limit(u32::MAX);
|
||||
assert_eq!(gate.snapshot().limit, 65);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn shared_units_survive_concurrent_clone_drop_reuse_and_resize() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded owner"), 8);
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let gate = gate.clone();
|
||||
workers.push(tokio::spawn(async move {
|
||||
for _ in 0..64 {
|
||||
let root = gate.acquire().await;
|
||||
let one = root.clone();
|
||||
let two = one.clone();
|
||||
drop(root);
|
||||
tokio::task::yield_now().await;
|
||||
drop(two);
|
||||
tokio::task::yield_now().await;
|
||||
drop(one);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for limit in [0, 1, 8, 2, 0, 8] {
|
||||
gate.set_limit(limit);
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
for worker in workers {
|
||||
worker.await.expect("lease worker");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all lease workers complete");
|
||||
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
|
||||
let full: Vec<_> = (0..8).map(|_| gate.try_acquire().expect("all slots returned")).collect();
|
||||
assert!(gate.try_acquire().is_none());
|
||||
drop(full);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_work_cannot_be_starved_by_new_fast_acquisitions() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 1);
|
||||
let held = gate.acquire().await;
|
||||
let mut first = Box::pin(gate.acquire());
|
||||
let mut second = Box::pin(gate.acquire());
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(first.as_mut().poll(&mut context).is_pending());
|
||||
assert!(second.as_mut().poll(&mut context).is_pending());
|
||||
drop(held);
|
||||
assert!(gate.try_acquire().is_none(), "new work must not steal a parked worker's turn");
|
||||
assert!(second.as_mut().poll(&mut context).is_pending(), "later work cannot pass the queue head");
|
||||
let Poll::Ready(first_permit) = first.as_mut().poll(&mut context) else {
|
||||
panic!("queue head must run")
|
||||
};
|
||||
drop(first_permit);
|
||||
let Poll::Ready(second_permit) = second.as_mut().poll(&mut context) else {
|
||||
panic!("second waiter must run")
|
||||
};
|
||||
drop(second_permit);
|
||||
assert!(gate.try_acquire().is_some(), "drained queue restores the atomic fast path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_queued_work_preserves_fifo_capacity_and_fast_path() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut head = Box::pin(gate.acquire());
|
||||
let mut middle = Box::pin(gate.acquire());
|
||||
let mut tail = Box::pin(gate.acquire());
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(head.as_mut().poll(&mut context).is_pending());
|
||||
assert!(middle.as_mut().poll(&mut context).is_pending());
|
||||
assert!(tail.as_mut().poll(&mut context).is_pending());
|
||||
drop(middle);
|
||||
drop(head);
|
||||
gate.set_limit(1);
|
||||
assert!(
|
||||
gate.try_acquire().is_none(),
|
||||
"cancelled head must not expose the surviving waiter's capacity"
|
||||
);
|
||||
let Poll::Ready(permit) = tail.as_mut().poll(&mut context) else {
|
||||
panic!("surviving waiter must run")
|
||||
};
|
||||
drop(permit);
|
||||
assert!(gate.try_acquire().is_some());
|
||||
|
||||
gate.set_limit(0);
|
||||
let mut cancelled = Box::pin(gate.acquire());
|
||||
assert!(cancelled.as_mut().poll(&mut context).is_pending());
|
||||
drop(cancelled);
|
||||
gate.set_limit(1);
|
||||
assert!(
|
||||
gate.try_acquire().is_some(),
|
||||
"cancelling the final waiter must not strand an admission turn"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_a_notified_head_hands_capacity_to_the_next_waiter() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
|
||||
let mut head = Box::pin(gate.acquire());
|
||||
let mut next = Box::pin(gate.acquire());
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(head.as_mut().poll(&mut context).is_pending());
|
||||
assert!(next.as_mut().poll(&mut context).is_pending());
|
||||
gate.set_limit(1);
|
||||
// Cancel after notification, before the head consumes its capacity.
|
||||
drop(head);
|
||||
let Poll::Ready(permit) = next.as_mut().poll(&mut context) else {
|
||||
panic!("successor must observe capacity without another notification")
|
||||
};
|
||||
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
|
||||
drop(permit);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn one_resize_serves_multiple_fifo_turns_but_stored_wakes_cannot_bypass_expiry() {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
|
||||
let mut first = Box::pin(gate.acquire());
|
||||
let mut second = Box::pin(gate.acquire());
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
assert!(first.as_mut().poll(&mut context).is_pending());
|
||||
assert!(second.as_mut().poll(&mut context).is_pending());
|
||||
gate.set_sampled_limit(2, Duration::from_secs(1));
|
||||
let Poll::Ready(one) = first.as_mut().poll(&mut context) else {
|
||||
panic!("first turn must observe resize")
|
||||
};
|
||||
let Poll::Ready(two) = second.as_mut().poll(&mut context) else {
|
||||
panic!("second turn must observe resize without releasing the first unit")
|
||||
};
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
drop(one);
|
||||
drop(two);
|
||||
let mut expired = Box::pin(gate.acquire());
|
||||
assert!(expired.as_mut().poll(&mut context).is_pending(), "stored wake is not a fresh lease");
|
||||
gate.set_sampled_limit(1, Duration::from_secs(1));
|
||||
let Poll::Ready(renewed) = expired.as_mut().poll(&mut context) else {
|
||||
panic!("fresh observation must wake expired admission")
|
||||
};
|
||||
drop(renewed);
|
||||
assert_eq!(gate.snapshot().active, 0);
|
||||
}
|
||||
use std::future::Future;
|
||||
use std::task::Poll;
|
||||
|
||||
#[tokio::test]
|
||||
async fn shrink_preserves_all_held_units_and_blocks_until_below_new_limit() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 3);
|
||||
let first = owner.acquire().await;
|
||||
let second = owner.acquire().await;
|
||||
let third = owner.acquire().await;
|
||||
owner.set_limit(1);
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 3 });
|
||||
drop(first);
|
||||
assert!(owner.try_acquire().is_none());
|
||||
drop(second);
|
||||
assert!(owner.try_acquire().is_none());
|
||||
drop(third);
|
||||
let fourth = owner.try_acquire().expect("last old permit returned to original owner");
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
|
||||
drop(fourth);
|
||||
assert_eq!(owner.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_capacity_and_cancelled_waiter_do_not_leak_or_bypass() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut waiting = Box::pin(owner.acquire());
|
||||
std::future::poll_fn(|cx| {
|
||||
assert!(waiting.as_mut().poll(cx).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
drop(waiting);
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 0 });
|
||||
assert!(owner.try_acquire().is_none());
|
||||
owner.set_limit(1);
|
||||
let permit = owner.acquire().await;
|
||||
assert!(owner.try_acquire().is_none());
|
||||
drop(permit);
|
||||
assert_eq!(owner.snapshot().active, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_wakes_a_registered_waiter_without_an_unrelated_release() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut waiting = Box::pin(owner.acquire());
|
||||
std::future::poll_fn(|cx| {
|
||||
assert!(waiting.as_mut().poll(cx).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
owner.set_limit(1);
|
||||
let permit = tokio::time::timeout(std::time::Duration::from_secs(1), waiting)
|
||||
.await
|
||||
.expect("resize notification");
|
||||
assert_eq!(owner.snapshot().active, 1);
|
||||
drop(permit);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_resize_release_and_acquire_keep_the_owner_count_exact() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 8);
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let owner = owner.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
for _ in 0..100 {
|
||||
let permit = owner.acquire().await;
|
||||
tokio::task::yield_now().await;
|
||||
drop(permit);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for limit in [1, 0, 4, 2, 8, 0, u32::MAX, 1] {
|
||||
owner.set_limit(limit);
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
owner.set_limit(8);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
for task in tasks {
|
||||
task.await.expect("admission task");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all waiters make progress");
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn expired_observation_blocks_new_work_without_revoking_held_permits() {
|
||||
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
owner.set_sampled_limit(2, Duration::from_secs(2));
|
||||
let held = owner.acquire().await;
|
||||
tokio::time::advance(Duration::from_secs(2)).await;
|
||||
assert!(owner.try_acquire().is_none());
|
||||
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
|
||||
drop(held);
|
||||
assert_eq!(owner.snapshot().active, 0);
|
||||
owner.set_sampled_limit(1, Duration::from_secs(2));
|
||||
let fresh = owner.try_acquire().expect("fresh observation reopens same owner");
|
||||
owner.set_sampled_limit(8, Duration::ZERO);
|
||||
assert!(owner.try_acquire().is_none());
|
||||
drop(fresh);
|
||||
assert_eq!(owner.snapshot().active, 0);
|
||||
}
|
||||
|
||||
fn healthy(completed: u64) -> FeedbackObservation {
|
||||
FeedbackObservation {
|
||||
generation: 1,
|
||||
max_concurrency: 8,
|
||||
pressure: ResourcePressure::Stable,
|
||||
completed_units: Some(completed),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn controller_grows_one_unit_per_cooldown_and_cuts_pressure_immediately() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut controller = FeedbackController::new();
|
||||
assert_eq!(controller.update(&gate, Some(healthy(0))).limit, 1);
|
||||
for completed in 1..=25 {
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
let limit = controller.update(&gate, Some(healthy(completed))).limit;
|
||||
assert_eq!(
|
||||
limit,
|
||||
if completed < 9 {
|
||||
1
|
||||
} else if completed < 17 {
|
||||
2
|
||||
} else if completed < 25 {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
}
|
||||
);
|
||||
}
|
||||
let held = gate.try_acquire().expect("existing work");
|
||||
let mut pressure = healthy(26);
|
||||
pressure.pressure = ResourcePressure::Busy;
|
||||
assert_eq!(
|
||||
controller.update(&gate, Some(pressure)),
|
||||
FeedbackDecision {
|
||||
limit: 2,
|
||||
reason: FeedbackReason::Pressure
|
||||
}
|
||||
);
|
||||
pressure.max_concurrency = 0;
|
||||
assert_eq!(controller.update(&gate, Some(pressure)).limit, 0);
|
||||
assert_eq!(gate.snapshot().active, 1);
|
||||
assert!(gate.try_acquire().is_none());
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn controller_missing_low_sample_stalled_or_changed_sources_never_expand() {
|
||||
for kind in 0..5 {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut controller = FeedbackController::new();
|
||||
for tick in 0..32 {
|
||||
let mut observation = healthy(tick);
|
||||
match kind {
|
||||
0 => observation.pressure = ResourcePressure::Unknown,
|
||||
1 => observation.completed_units = None,
|
||||
2 => observation.completed_units = Some(0),
|
||||
3 => observation.generation = tick,
|
||||
_ => {}
|
||||
}
|
||||
tokio::time::advance(Duration::from_secs(if kind == 4 { 3 } else { 1 })).await;
|
||||
assert!(controller.update(&gate, Some(observation)).limit <= 1, "kind {kind}");
|
||||
}
|
||||
assert_eq!(
|
||||
controller.update(&gate, None),
|
||||
FeedbackDecision {
|
||||
limit: 0,
|
||||
reason: FeedbackReason::Unavailable
|
||||
}
|
||||
);
|
||||
assert!(gate.try_acquire().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn controller_resumes_after_zero_cap_without_requiring_impossible_progress() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut controller = FeedbackController::new();
|
||||
let mut sample = healthy(0);
|
||||
sample.max_concurrency = 0;
|
||||
assert_eq!(controller.update(&gate, Some(sample)).limit, 0);
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
sample.max_concurrency = 8;
|
||||
assert_eq!(controller.update(&gate, Some(sample)).limit, 1);
|
||||
assert!(gate.try_acquire().is_some(), "minimum progress restarts inside the restored cap");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn controller_applies_pressure_reduction_and_lower_cap_in_the_same_window() {
|
||||
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
|
||||
let mut controller = FeedbackController::new();
|
||||
for completed in 0..=25 {
|
||||
controller.update(&gate, Some(healthy(completed)));
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
}
|
||||
assert_eq!(gate.snapshot().limit, 4);
|
||||
let mut sample = healthy(26);
|
||||
sample.max_concurrency = 3;
|
||||
sample.pressure = ResourcePressure::Busy;
|
||||
assert_eq!(controller.update(&gate, Some(sample)).limit, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
//! Bounded foreground completion windows for background resource feedback.
|
||||
//! Recording uses fixed atomic storage, without an allocation, mutex or task
|
||||
//! per request. A single reader rotates banks and waits for the retired bank's
|
||||
//! writers before reading it. Delayed writers delay publication, not the
|
||||
//! recorded window boundary. Percentiles are intervals, never exact values.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SUB_BUCKETS: usize = 64;
|
||||
const FINITE_BUCKETS: usize = 27 * SUB_BUCKETS;
|
||||
const BUCKETS: usize = FINITE_BUCKETS + 1;
|
||||
const MAX_MICROS: u64 = u32::MAX as u64;
|
||||
|
||||
/// Bounded foreground categories; no bucket, object or request identifiers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ForegroundClass {
|
||||
/// Object body reads.
|
||||
Read,
|
||||
/// Object writes, including multipart and copy operations.
|
||||
Write,
|
||||
/// Metadata and listing operations.
|
||||
Metadata,
|
||||
/// Other operations, including rejection before typed dispatch.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ForegroundClass {
|
||||
/// Stable order shared by window summaries.
|
||||
pub const ALL: [Self; 4] = [Self::Read, Self::Write, Self::Metadata, Self::Other];
|
||||
|
||||
/// Fixed metric label.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "read",
|
||||
Self::Write => "write",
|
||||
Self::Metadata => "metadata",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
const fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Read => 0,
|
||||
Self::Write => 1,
|
||||
Self::Metadata => 2,
|
||||
Self::Other => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome at the observed response-body boundary, not a client ACK.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CompletionOutcome {
|
||||
/// Successful response body completed.
|
||||
Success,
|
||||
/// Error response, service error, or body-stream failure.
|
||||
Failed,
|
||||
/// The request/body was dropped before completion.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Inclusive bounds containing the observed percentile.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LatencyBounds {
|
||||
/// Inclusive lower bound in nanoseconds.
|
||||
pub lower_nanos: u64,
|
||||
/// Inclusive upper bound; absent for the overflow bucket.
|
||||
pub upper_nanos: Option<u64>,
|
||||
}
|
||||
|
||||
/// One class in a complete observation window.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ClassCompletions {
|
||||
/// All finishes, including errors and cancellation.
|
||||
pub finished: u64,
|
||||
/// Error responses, service errors and body-stream failures.
|
||||
pub failed: u64,
|
||||
/// Censored observations; these must not be treated as successful latency.
|
||||
pub cancelled: u64,
|
||||
/// P99 of non-cancelled finishes, when at least 100 samples exist.
|
||||
/// This count permits computing the rank; it is not statistical confidence.
|
||||
pub p99: Option<LatencyBounds>,
|
||||
}
|
||||
|
||||
/// Clock readings bracketing an atomic event-window cut. A scheduler pause
|
||||
/// between these readings is visible, rather than disguised as precise timing.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WindowBoundary {
|
||||
/// Read immediately before the event cut.
|
||||
pub earliest: Instant,
|
||||
/// Read immediately after the event cut.
|
||||
pub latest: Instant,
|
||||
}
|
||||
|
||||
/// Complete windows retain their actual cut brackets, even if publication waits.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ForegroundWindow {
|
||||
/// Start of this event window.
|
||||
pub start: WindowBoundary,
|
||||
/// Cut of the retired bank, possibly older than publication time.
|
||||
pub end: WindowBoundary,
|
||||
/// Request starts in this window, including pre-dispatch rejection.
|
||||
pub started: Option<u64>,
|
||||
/// Requests still alive at the cut; absent after accounting overflow.
|
||||
pub in_flight: Option<u64>,
|
||||
/// Per-class completions in ForegroundClass::ALL order.
|
||||
pub classes: [Option<ClassCompletions>; 4],
|
||||
/// False after a counter overflow or an inconsistent event count.
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
struct ClassBank {
|
||||
latencies: [AtomicU64; BUCKETS],
|
||||
failed: AtomicU64,
|
||||
cancelled: AtomicU64,
|
||||
}
|
||||
|
||||
impl ClassBank {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
latencies: std::array::from_fn(|_| AtomicU64::new(0)),
|
||||
failed: AtomicU64::new(0),
|
||||
cancelled: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Bank {
|
||||
writers: AtomicUsize,
|
||||
started: AtomicU64,
|
||||
overflowed: AtomicBool,
|
||||
classes: [ClassBank; 4],
|
||||
}
|
||||
|
||||
impl Bank {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
writers: AtomicUsize::new(0),
|
||||
started: AtomicU64::new(0),
|
||||
overflowed: AtomicBool::new(false),
|
||||
classes: std::array::from_fn(|_| ClassBank::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn increment(&self, counter: &AtomicU64) {
|
||||
if counter.fetch_add(1, Ordering::Relaxed) == u64::MAX {
|
||||
self.overflowed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Writer<'a>(&'a Bank);
|
||||
|
||||
impl Drop for Writer<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.writers.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// One process-wide recorder. Its fixed storage is allocated with the owner.
|
||||
/// A request holds only a reference, timestamps and bounded enum state.
|
||||
pub struct ForegroundRecorder {
|
||||
created_at: Instant,
|
||||
epoch: AtomicU64,
|
||||
reader_taken: AtomicBool,
|
||||
banks: [Bank; 2],
|
||||
}
|
||||
|
||||
impl Default for ForegroundRecorder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ForegroundRecorder {
|
||||
/// Construct empty accounting for one lifetime of the owning process.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
created_at: Instant::now(),
|
||||
epoch: AtomicU64::new(0),
|
||||
reader_taken: AtomicBool::new(false),
|
||||
banks: std::array::from_fn(|_| Bank::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn writer(&self) -> Writer<'_> {
|
||||
loop {
|
||||
let epoch = self.epoch.load(Ordering::SeqCst);
|
||||
let bank = &self.banks[usize::from(epoch & 1 != 0)];
|
||||
bank.writers.fetch_add(1, Ordering::SeqCst);
|
||||
let writer = Writer(bank);
|
||||
// Sequential ordering joins registration, the reader's cut, and
|
||||
// its zero-writer check. A late old-epoch registrant cannot write
|
||||
// into a bank that the reader has begun draining or reusing.
|
||||
if self.epoch.load(Ordering::SeqCst) == epoch {
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin at the external service-call boundary. Drop records cancellation
|
||||
/// unless finish has already recorded a response-body outcome.
|
||||
pub fn start(&self) -> ForegroundObservation<'_> {
|
||||
let started_at = Instant::now();
|
||||
let writer = self.writer();
|
||||
writer.0.increment(&writer.0.started);
|
||||
ForegroundObservation {
|
||||
recorder: self,
|
||||
started_at: Some(started_at),
|
||||
class: ForegroundClass::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self, class: ForegroundClass, elapsed: Duration, outcome: CompletionOutcome) {
|
||||
let writer = self.writer();
|
||||
let bank = writer.0;
|
||||
let class = &bank.classes[class.index()];
|
||||
if outcome == CompletionOutcome::Cancelled {
|
||||
bank.increment(&class.cancelled);
|
||||
return;
|
||||
}
|
||||
let bucket = latency_bucket(elapsed);
|
||||
bank.increment(&class.latencies[bucket]);
|
||||
if outcome == CompletionOutcome::Failed {
|
||||
bank.increment(&class.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim the sole reader. It cannot be replaced after being dropped:
|
||||
/// resetting its carried in-flight count would falsify later windows.
|
||||
pub fn take_reader(&self) -> Option<ForegroundReader<'_>> {
|
||||
self.reader_taken
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.ok()?;
|
||||
Some(ForegroundReader {
|
||||
recorder: self,
|
||||
start: WindowBoundary {
|
||||
earliest: self.created_at,
|
||||
latest: self.created_at,
|
||||
},
|
||||
retired: None,
|
||||
in_flight: Some(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-cloneable request ownership, moved from the service future to its body.
|
||||
pub struct ForegroundObservation<'a> {
|
||||
recorder: &'a ForegroundRecorder,
|
||||
started_at: Option<Instant>,
|
||||
class: ForegroundClass,
|
||||
}
|
||||
|
||||
impl ForegroundObservation<'_> {
|
||||
/// Set the first dispatched operation's bounded category.
|
||||
pub fn set_class(&mut self, class: ForegroundClass) {
|
||||
self.class = class;
|
||||
}
|
||||
|
||||
/// Finish once. Later calls or Drop do not produce another observation.
|
||||
pub fn finish(&mut self, outcome: CompletionOutcome) {
|
||||
if let Some(started_at) = self.started_at.take() {
|
||||
self.recorder.finish(self.class, started_at.elapsed(), outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ForegroundObservation<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.finish(CompletionOutcome::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-reader state; never acquired by a request path.
|
||||
pub struct ForegroundReader<'a> {
|
||||
recorder: &'a ForegroundRecorder,
|
||||
start: WindowBoundary,
|
||||
retired: Option<(usize, WindowBoundary)>,
|
||||
in_flight: Option<u64>,
|
||||
}
|
||||
|
||||
impl ForegroundReader<'_> {
|
||||
/// Cut a new window, or finish publishing an earlier cut. A live retired
|
||||
/// writer returns None without blocking, discarding data or moving its cut.
|
||||
pub fn sample(&mut self) -> Option<ForegroundWindow> {
|
||||
if self.retired.is_none() {
|
||||
let epoch = self.recorder.epoch.load(Ordering::SeqCst);
|
||||
let next = epoch.checked_add(1)?;
|
||||
let earliest = Instant::now();
|
||||
self.recorder.epoch.store(next, Ordering::SeqCst);
|
||||
let cut = WindowBoundary {
|
||||
earliest,
|
||||
latest: Instant::now(),
|
||||
};
|
||||
self.retired = Some((usize::from(epoch & 1 != 0), cut));
|
||||
}
|
||||
let (index, end) = self.retired?;
|
||||
let bank = &self.recorder.banks[index];
|
||||
if bank.writers.load(Ordering::SeqCst) != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let started = bank.started.swap(0, Ordering::Relaxed);
|
||||
let overflowed = bank.overflowed.swap(false, Ordering::Relaxed);
|
||||
let classes = std::array::from_fn(|index| {
|
||||
let class = &bank.classes[index];
|
||||
let failed = class.failed.swap(0, Ordering::Relaxed);
|
||||
let cancelled = class.cancelled.swap(0, Ordering::Relaxed);
|
||||
let buckets = std::array::from_fn::<_, BUCKETS, _>(|i| class.latencies[i].swap(0, Ordering::Relaxed));
|
||||
let non_cancelled = buckets.iter().try_fold(0_u64, |sum, count| sum.checked_add(*count));
|
||||
let finished = non_cancelled.and_then(|total| total.checked_add(cancelled));
|
||||
let finished = finished.filter(|_| !overflowed && non_cancelled.is_some_and(|total| failed <= total))?;
|
||||
Some(ClassCompletions {
|
||||
finished,
|
||||
failed,
|
||||
cancelled,
|
||||
p99: non_cancelled
|
||||
.filter(|total| *total >= 100)
|
||||
.and_then(|total| percentile_99(&buckets, total)),
|
||||
})
|
||||
});
|
||||
let started = (!overflowed).then_some(started);
|
||||
let finished = classes
|
||||
.iter()
|
||||
.try_fold(0_u64, |sum, class| sum.checked_add(class.as_ref()?.finished));
|
||||
self.in_flight = self
|
||||
.in_flight
|
||||
.zip(finished)
|
||||
.and_then(|(active, finished)| active.checked_add(started?)?.checked_sub(finished));
|
||||
let window = ForegroundWindow {
|
||||
start: self.start,
|
||||
end,
|
||||
started,
|
||||
in_flight: self.in_flight,
|
||||
classes,
|
||||
valid: self.in_flight.is_some(),
|
||||
};
|
||||
self.start = end;
|
||||
self.retired = None;
|
||||
Some(window)
|
||||
}
|
||||
}
|
||||
|
||||
fn latency_bucket(elapsed: Duration) -> usize {
|
||||
let micros = elapsed.as_nanos().div_ceil(1_000);
|
||||
let Ok(micros) = u64::try_from(micros) else {
|
||||
return FINITE_BUCKETS;
|
||||
};
|
||||
if micros > MAX_MICROS {
|
||||
return FINITE_BUCKETS;
|
||||
}
|
||||
if micros < SUB_BUCKETS as u64 {
|
||||
return usize::try_from(micros).unwrap_or(FINITE_BUCKETS);
|
||||
}
|
||||
let shift = 63 - micros.leading_zeros() - 6;
|
||||
usize::try_from((u64::from(shift) + 1) * SUB_BUCKETS as u64 + (micros >> shift) - SUB_BUCKETS as u64)
|
||||
.unwrap_or(FINITE_BUCKETS)
|
||||
}
|
||||
|
||||
fn bucket_bounds(index: usize) -> LatencyBounds {
|
||||
if index == FINITE_BUCKETS {
|
||||
return LatencyBounds {
|
||||
lower_nanos: MAX_MICROS * 1_000 + 1,
|
||||
upper_nanos: None,
|
||||
};
|
||||
}
|
||||
let (lower, upper) = if index < SUB_BUCKETS {
|
||||
(index as u64, index as u64)
|
||||
} else {
|
||||
let shift = index / SUB_BUCKETS - 1;
|
||||
let lower = (SUB_BUCKETS + index % SUB_BUCKETS) as u64 * (1_u64 << shift);
|
||||
(lower, lower + (1_u64 << shift) - 1)
|
||||
};
|
||||
LatencyBounds {
|
||||
lower_nanos: lower.saturating_sub(1) * 1_000 + u64::from(lower != 0),
|
||||
upper_nanos: Some(upper * 1_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn percentile_99(buckets: &[u64; BUCKETS], total: u64) -> Option<LatencyBounds> {
|
||||
let rank = (u128::from(total) * 99).div_ceil(100);
|
||||
let mut cumulative = 0_u128;
|
||||
for (index, count) in buckets.iter().enumerate() {
|
||||
cumulative += u128::from(*count);
|
||||
if cumulative >= rank {
|
||||
return Some(bucket_bounds(index));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn percentile_intervals_contain_each_bucket_edge_and_neighbour() {
|
||||
for index in 0..BUCKETS {
|
||||
let bounds = bucket_bounds(index);
|
||||
assert_eq!(latency_bucket(Duration::from_nanos(bounds.lower_nanos)), index);
|
||||
if let Some(upper) = bounds.upper_nanos {
|
||||
assert!(upper >= bounds.lower_nanos);
|
||||
assert_eq!(latency_bucket(Duration::from_nanos(upper)), index);
|
||||
assert_eq!(latency_bucket(Duration::from_nanos(upper + 1)), index + 1);
|
||||
}
|
||||
}
|
||||
assert_eq!(latency_bucket(Duration::MAX), FINITE_BUCKETS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_ownership_carries_in_flight_counts_across_windows() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
let mut request = recorder.start();
|
||||
request.set_class(ForegroundClass::Read);
|
||||
let first = reader.sample().expect("cut with a live request");
|
||||
assert!(first.valid);
|
||||
assert_eq!(first.started, Some(1));
|
||||
assert_eq!(first.in_flight, Some(1));
|
||||
assert_eq!(first.classes[0].expect("valid read count").finished, 0);
|
||||
|
||||
request.finish(CompletionOutcome::Success);
|
||||
request.finish(CompletionOutcome::Failed);
|
||||
drop(request);
|
||||
let second = reader.sample().expect("body finished");
|
||||
assert!(second.valid);
|
||||
assert_eq!(second.started, Some(0));
|
||||
assert_eq!(second.in_flight, Some(0));
|
||||
let read = second.classes[0].expect("valid read summary");
|
||||
assert_eq!((read.finished, read.failed, read.cancelled, read.p99), (1, 0, 0, None));
|
||||
assert_eq!(first.end.earliest, second.start.earliest);
|
||||
assert_eq!(first.end.latest, second.start.latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellations_are_counted_without_fabricating_success_latencies() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
for _ in 0..150 {
|
||||
let mut request = recorder.start();
|
||||
request.set_class(ForegroundClass::Write);
|
||||
}
|
||||
let window = reader.sample().expect("cancelled requests");
|
||||
let write = window.classes[1].expect("known write count");
|
||||
assert!(window.valid);
|
||||
assert_eq!(window.in_flight, Some(0));
|
||||
assert_eq!((write.finished, write.cancelled, write.failed, write.p99), (150, 150, 0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_retired_writer_preserves_the_original_cut_and_next_bank() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
let writer = recorder.writer();
|
||||
assert!(reader.sample().is_none(), "never read a live writer's bank");
|
||||
let original_cut = reader.retired.expect("pending cut").1;
|
||||
let request = recorder.start(); // a start after the cut belongs to the new bank
|
||||
writer.0.increment(&writer.0.started);
|
||||
drop(writer);
|
||||
let previous = reader.sample().expect("retired writer drained");
|
||||
assert_eq!(previous.end.earliest, original_cut.earliest);
|
||||
assert_eq!(previous.end.latest, original_cut.latest);
|
||||
assert_eq!(previous.started, Some(1));
|
||||
assert_eq!(previous.in_flight, Some(1));
|
||||
drop(request);
|
||||
let current = reader.sample().expect("next bank");
|
||||
assert_eq!(current.started, Some(1));
|
||||
assert_eq!(current.in_flight, Some(1));
|
||||
assert_eq!(current.classes[3].expect("unclassified cancellation").cancelled, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_percentile_uses_rank_and_never_turns_maximum_into_p99() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
for i in 0..100 {
|
||||
let writer = recorder.writer();
|
||||
writer.0.increment(&writer.0.started);
|
||||
drop(writer);
|
||||
recorder.finish(
|
||||
ForegroundClass::Read,
|
||||
if i == 99 {
|
||||
Duration::from_secs(60)
|
||||
} else {
|
||||
Duration::from_millis(2)
|
||||
},
|
||||
if i == 99 {
|
||||
CompletionOutcome::Failed
|
||||
} else {
|
||||
CompletionOutcome::Success
|
||||
},
|
||||
);
|
||||
}
|
||||
let window = reader.sample().expect("complete window");
|
||||
let read = window.classes[0].expect("read summary");
|
||||
assert_eq!((read.finished, read.failed, read.cancelled), (100, 1, 0));
|
||||
let p99 = read.p99.expect("100 non-cancelled samples");
|
||||
assert!(p99.lower_nanos <= 2_000_000);
|
||||
assert!(p99.upper_nanos.is_some_and(|upper| (2_000_000..3_000_000).contains(&upper)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_stays_unknown_and_reader_cannot_reset_event_accounting() {
|
||||
let recorder = ForegroundRecorder::new();
|
||||
{
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
recorder.banks[0].started.store(u64::MAX, Ordering::Relaxed);
|
||||
drop(recorder.start());
|
||||
let invalid = reader.sample().expect("overflow window");
|
||||
assert!(!invalid.valid);
|
||||
assert_eq!(invalid.started, None);
|
||||
assert_eq!(invalid.in_flight, None);
|
||||
assert_eq!(invalid.classes, [None; 4]);
|
||||
let later = reader.sample().expect("later empty window");
|
||||
assert!(!later.valid, "a lost carried count cannot recover by inventing zero active work");
|
||||
}
|
||||
assert!(recorder.take_reader().is_none(), "reader replacement would lose live request ownership");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_rotation_preserves_every_start_finish_and_cancellation() {
|
||||
let recorder = Arc::new(ForegroundRecorder::new());
|
||||
let mut reader = recorder.take_reader().expect("first reader");
|
||||
let workers: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let recorder = Arc::clone(&recorder);
|
||||
std::thread::spawn(move || {
|
||||
for index in 0..10_000 {
|
||||
let mut request = recorder.start();
|
||||
request.set_class(ForegroundClass::Read);
|
||||
if index % 10 != 0 {
|
||||
request.finish(CompletionOutcome::Success);
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let (mut started, mut finished, mut cancelled) = (0, 0, 0);
|
||||
let mut add_window = |window: ForegroundWindow| {
|
||||
assert!(window.valid, "concurrent event cuts must stay balanced");
|
||||
started += window.started.expect("valid start count");
|
||||
let reads = window.classes[0].expect("valid read count");
|
||||
finished += reads.finished;
|
||||
cancelled += reads.cancelled;
|
||||
};
|
||||
while workers.iter().any(|worker| !worker.is_finished()) {
|
||||
if let Some(window) = reader.sample() {
|
||||
add_window(window);
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
for worker in workers {
|
||||
worker.join().expect("writer must finish");
|
||||
}
|
||||
add_window(reader.sample().expect("drain after writers join"));
|
||||
// A previously retired window may have been published by the last
|
||||
// sample; cut once more to include the current, still open bank.
|
||||
let final_window = reader.sample().expect("final open bank");
|
||||
assert_eq!(final_window.in_flight, Some(0));
|
||||
add_window(final_window);
|
||||
assert_eq!((started, finished, cancelled), (40_000, 40_000, 4_000));
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,10 @@
|
||||
// Re-exported io-core type used by downstream timeout implementations.
|
||||
pub use rustfs_io_core::OperationProgress;
|
||||
|
||||
pub mod adaptive;
|
||||
mod backpressure;
|
||||
mod deadlock;
|
||||
pub mod foreground;
|
||||
mod queue;
|
||||
pub mod workers;
|
||||
pub mod workload;
|
||||
|
||||
@@ -49,12 +49,28 @@ impl Workers {
|
||||
|
||||
/// Acquire a worker slot, waiting until one becomes available.
|
||||
pub async fn take(&self) {
|
||||
struct AcquireCancelGuard<'a> {
|
||||
workers: &'a Workers,
|
||||
completed: bool,
|
||||
}
|
||||
impl Drop for AcquireCancelGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed {
|
||||
self.workers.in_use.send_modify(|_| {});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut guard = AcquireCancelGuard {
|
||||
workers: self,
|
||||
completed: false,
|
||||
};
|
||||
let permit = match self.semaphore.acquire().await {
|
||||
Ok(permit) => permit,
|
||||
// The semaphore is owned by `self` and never closed.
|
||||
Err(_) => unreachable!("worker semaphore is never closed"),
|
||||
};
|
||||
permit.forget(); // returned manually via give()
|
||||
guard.completed = true;
|
||||
self.in_use.send_modify(|held| *held += 1);
|
||||
trace!(
|
||||
event = "worker_slot.acquire",
|
||||
@@ -82,6 +98,7 @@ impl Workers {
|
||||
});
|
||||
if released {
|
||||
self.semaphore.add_permits(1);
|
||||
self.in_use.send_modify(|_| {});
|
||||
}
|
||||
trace!(
|
||||
event = "worker_slot.release",
|
||||
@@ -99,7 +116,9 @@ impl Workers {
|
||||
pub async fn wait(&self) {
|
||||
let mut rx = self.in_use.subscribe();
|
||||
// The sender is owned by `self`, so it outlives this borrow.
|
||||
let _ = rx.wait_for(|&held| held == 0).await;
|
||||
let _ = rx
|
||||
.wait_for(|&held| held == 0 && self.semaphore.available_permits() == self.limit)
|
||||
.await;
|
||||
debug!(
|
||||
event = "worker_slot.wait",
|
||||
component = "concurrency",
|
||||
@@ -114,7 +133,9 @@ impl Workers {
|
||||
|
||||
/// Return the current number of available worker slots.
|
||||
pub async fn available(&self) -> usize {
|
||||
self.limit.saturating_sub(*self.in_use.borrow())
|
||||
self.limit
|
||||
.saturating_sub(*self.in_use.borrow())
|
||||
.min(self.semaphore.available_permits())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,4 +238,36 @@ mod tests {
|
||||
}
|
||||
assert_eq!(workers.available().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_does_not_complete_while_permit_is_in_transit_to_queued_taker() {
|
||||
use std::future::Future;
|
||||
use std::pin::pin;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
let workers = Workers::new(1).unwrap();
|
||||
workers.take().await;
|
||||
|
||||
let mut taker = pin!(workers.take());
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
let mut waiter = pin!(workers.wait());
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
// Hand off the slot to the queued taker before `taker` is polled again.
|
||||
workers.give().await;
|
||||
assert_eq!(workers.available().await, 0);
|
||||
assert!(
|
||||
matches!(waiter.as_mut().poll(&mut cx), Poll::Pending),
|
||||
"wait() must not complete while a permit is in transit to a woken taker"
|
||||
);
|
||||
|
||||
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Ready(())));
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
workers.give().await;
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Ready(())));
|
||||
assert_eq!(workers.available().await, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ when restoring `/rustfs/console` for a custom OEM build.
|
||||
## S3 API environment variables
|
||||
|
||||
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
|
||||
- `RUSTFS_VEEAM_SOSAPI_ENABLE` serves Veeam SOSAPI `system.xml` and `capacity.xml` for an authenticated GET or HEAD of those exact bucket-root keys. It defaults to `true`. Explicit version reads use the normal object path. Capacity requests return `ServiceUnavailable` until the scanner publishes a complete snapshot certified for the current bucket incarnation and authoritative quota metadata can be read. Legacy or mixed-version snapshots without an incarnation proof cannot serve capacity until a compatible scanner publishes a certified snapshot. Capacity also returns `ServiceUnavailable` when cluster or disk observations are missing, stale, or degraded. Retry after scanner convergence and storage or metadata recovery. Capacity assumes shard paths have independent backing space; shared physical storage can make the estimate optimistic. Set the flag to `false` to restore the normal object path, including deletion of a real object previously stored at that key.
|
||||
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
|
||||
|
||||
## Distributed endpoint locality
|
||||
@@ -116,6 +117,7 @@ when restoring `/rustfs/console` for a custom OEM build.
|
||||
- `RUSTFS_DATA_SCANNER_START_DELAY_SECS` (deprecated alias for compatibility)
|
||||
- `RUSTFS_SCANNER_IDLE_MODE` (canonical)
|
||||
- `RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS` (canonical)
|
||||
- `RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS` (canonical)
|
||||
- `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` (canonical)
|
||||
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
|
||||
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
|
||||
@@ -127,6 +129,12 @@ Scanner cycle budget controls:
|
||||
|
||||
- When `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` is unset, the finite default is 1800 seconds (30 minutes), matching the scanner benchmark guidance.
|
||||
- An explicit `0` preserves the compatibility behavior of an unbounded runtime budget. Object and directory budgets likewise remain unbounded when explicitly set to `0`.
|
||||
|
||||
Scanner recovery retry controls:
|
||||
|
||||
- `RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS` caps the wait between automatic startup recovery retries. The persistent admin key is `scanner.recovery_backoff_cap`; the environment takes precedence over the persistent value, and the unset default remains 600 seconds (10 minutes). Values must be positive integer seconds within the platform timer range; `0`, malformed values, and overflow are rejected. A valid environment override does not hide an invalid persistent value from `admin config set` validation.
|
||||
- The cap bounds the existing exponential retry schedule. Updating the persistent value applies to the next computed retry; it does not reschedule a wait already in progress. Retryable observations continue retrying after the short retry budget. Admin scanner status reports the effective value and source at `runtime_config.recovery_backoff_cap_seconds`.
|
||||
|
||||
Scanner walker controls:
|
||||
|
||||
- `RUSTFS_SCANNER_WALKERS_PER_DISK` sets how many bucket walkers each local disk of an erasure set runs; the default `1` walks one bucket per disk at a time, which is the right value for rotational media. On flash, `2` to `4` lets one disk walk several buckets concurrently, which matters most for single-disk deployments with many buckets, where the walk is otherwise fully serial. Values outside `1` through `16` are rejected by `admin config set`; an out-of-range or non-numeric environment value is logged as a scanner runtime config error (`state = "startup_apply_failed"`) and the scanner keeps the default. The walkers of one disk share that disk's scanner admission, so peers still see one scan session per physical disk, and `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` still caps the total.
|
||||
|
||||
@@ -222,6 +222,16 @@ pub const ENV_RUSTFS_TLS_PATH: &str = "RUSTFS_TLS_PATH";
|
||||
/// Environment variable for server KMS enable.
|
||||
pub const ENV_RUSTFS_KMS_ENABLE: &str = "RUSTFS_KMS_ENABLE";
|
||||
|
||||
/// Environment variable for Veeam SOSAPI virtual system objects.
|
||||
///
|
||||
/// When true (the default), authenticated GET and HEAD of the two Veeam
|
||||
/// SOSAPI keys return generated XML. When false, those keys use the normal
|
||||
/// object path so an operator can remove a real object stored there earlier.
|
||||
pub const ENV_VEEAM_SOSAPI_ENABLE: &str = "RUSTFS_VEEAM_SOSAPI_ENABLE";
|
||||
|
||||
/// Default Veeam SOSAPI enablement.
|
||||
pub const DEFAULT_VEEAM_SOSAPI_ENABLE: bool = true;
|
||||
|
||||
/// Default KMS enable for server-side encryption
|
||||
/// This is the default value for enabling KMS encryption for server-side encryption.
|
||||
/// Default value: false
|
||||
@@ -374,6 +384,9 @@ pub const DEFAULT_OBS_PROFILING_EXPORT_ENABLED: bool = true;
|
||||
/// Environment variable: RUSTFS_OBS_LOG_STDOUT_ENABLED
|
||||
pub const DEFAULT_OBS_LOG_STDOUT_ENABLED: bool = false;
|
||||
|
||||
/// Fixed target for sampled local-batch diagnostics with isolated JSON output.
|
||||
pub const LOCAL_BATCH_DIAGNOSTIC_LOG_TARGET: &str = "rustfs_local_batch_diagnostic";
|
||||
|
||||
/// Constant representing 1 Kibibyte (1024 bytes)
|
||||
/// Default value: 1024
|
||||
pub const KI_B: usize = 1024;
|
||||
|
||||
@@ -197,6 +197,10 @@ pub const ENV_HEAL_MRF_REPLAY_BATCH: &str = "RUSTFS_HEAL_MRF_REPLAY_BATCH";
|
||||
/// Default behavior keeps the MRF intent pipeline enabled.
|
||||
pub const DEFAULT_HEAL_MRF_ENABLE: bool = true;
|
||||
|
||||
/// Opt in to lifecycle V2 transitions after qualifying every reader and the rollback binary.
|
||||
pub const ENV_MRF_LIFECYCLE_V2_ENABLE: &str = "RUSTFS_MRF_LIFECYCLE_V2_ENABLE";
|
||||
pub const DEFAULT_MRF_LIFECYCLE_V2_ENABLE: bool = false;
|
||||
|
||||
/// Default MRF queue capacity (matches MinIO's 100k MRF list ceiling).
|
||||
pub const DEFAULT_HEAL_MRF_QUEUE_SIZE: usize = 100_000;
|
||||
|
||||
|
||||
@@ -276,6 +276,9 @@ pub const DEFAULT_INTERNODE_BULK_CHANNELS: usize = 2;
|
||||
pub const ENV_INTERNODE_HTTP_TUNING_PROFILE: &str = "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE";
|
||||
pub const DEFAULT_INTERNODE_HTTP_TUNING_PROFILE: &str = "legacy";
|
||||
|
||||
/// Internode HTTP data-plane protocol: `http1` (default) or `auto` (TLS ALPN).
|
||||
pub const ENV_INTERNODE_HTTP_PROTOCOL: &str = "RUSTFS_INTERNODE_HTTP_PROTOCOL";
|
||||
|
||||
/// Internode HTTP connection pool maximum idle connections per host.
|
||||
pub const ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST: &str = "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST";
|
||||
|
||||
@@ -287,8 +290,9 @@ pub const ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_HT
|
||||
/// NOTE (backlog#805-C3): all `RUSTFS_INTERNODE_HTTP2_*` tuning (window sizes,
|
||||
/// adaptive window, keepalive timeout) only takes effect when the internode
|
||||
/// connection actually negotiates HTTP/2, which happens via TLS-ALPN. Over
|
||||
/// plaintext internode transport the connection is HTTP/1.1 and these settings
|
||||
/// are silently inert — enable internode TLS to use them.
|
||||
/// plaintext internode transport or with the default `http1` protocol policy,
|
||||
/// these settings are inert. Select `RUSTFS_INTERNODE_HTTP_PROTOCOL=auto` and
|
||||
/// enable internode TLS to use them.
|
||||
pub const ENV_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_HTTP2_INITIAL_STREAM_WINDOW_SIZE";
|
||||
|
||||
/// Internode HTTP/2 initial connection window size in bytes.
|
||||
|
||||
@@ -53,6 +53,9 @@ pub const SCANNER_IDLE_MODE: &str = "idle_mode";
|
||||
/// Scanner config key controlling scanner cache save timeout in seconds.
|
||||
pub const SCANNER_CACHE_SAVE_TIMEOUT: &str = "cache_save_timeout";
|
||||
|
||||
/// Scanner config key capping automatic recovery retry waits in seconds.
|
||||
pub const SCANNER_RECOVERY_BACKOFF_CAP: &str = "recovery_backoff_cap";
|
||||
|
||||
/// Scanner config key capping concurrent scanner set tasks.
|
||||
pub const SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "max_concurrent_set_scans";
|
||||
|
||||
@@ -93,6 +96,7 @@ pub const SCANNER_KEYS: &[&str] = &[
|
||||
SCANNER_BITROT_CYCLE,
|
||||
SCANNER_IDLE_MODE,
|
||||
SCANNER_CACHE_SAVE_TIMEOUT,
|
||||
SCANNER_RECOVERY_BACKOFF_CAP,
|
||||
SCANNER_MAX_CONCURRENT_SET_SCANS,
|
||||
SCANNER_MAX_CONCURRENT_DISK_SCANS,
|
||||
SCANNER_WALKERS_PER_DISK,
|
||||
@@ -216,6 +220,14 @@ pub const ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS: &str = "RUSTFS_SCANNER_CACHE_SAVE
|
||||
/// Default scanner cache save timeout in seconds.
|
||||
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 14;
|
||||
|
||||
/// Environment variable that caps automatic scanner recovery retry waits.
|
||||
/// The value must be a positive integer number of seconds within the timer range.
|
||||
/// - Example: `export RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS=600`
|
||||
pub const ENV_SCANNER_RECOVERY_BACKOFF_CAP_SECS: &str = "RUSTFS_SCANNER_RECOVERY_BACKOFF_CAP_SECS";
|
||||
|
||||
/// Default maximum wait between automatic scanner recovery retries.
|
||||
pub const DEFAULT_SCANNER_RECOVERY_BACKOFF_CAP_SECS: u64 = 10 * 60;
|
||||
|
||||
/// Environment variable that caps concurrent scanner set tasks.
|
||||
/// A value of `0` keeps the existing topology-based concurrency.
|
||||
/// - Example: `export RUSTFS_SCANNER_MAX_CONCURRENT_SET_SCANS=2`
|
||||
|
||||
@@ -466,6 +466,9 @@ pub struct BucketTargetUsageInfo {
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BucketUsageInfo {
|
||||
pub size: u64,
|
||||
/// Bucket identity certified by every set contributing to this snapshot.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bucket_incarnation: Option<String>,
|
||||
// Following five fields suffixed with V1 are here for backward compatibility
|
||||
// Total Size for objects that have not yet been replicated
|
||||
pub replication_pending_size_v1: u64,
|
||||
@@ -2316,6 +2319,9 @@ impl BucketUsageInfo {
|
||||
/// Add size summary to this bucket usage
|
||||
/// Merge another BucketUsageInfo into this one
|
||||
pub fn merge(&mut self, other: &BucketUsageInfo) {
|
||||
if self.bucket_incarnation != other.bucket_incarnation {
|
||||
self.bucket_incarnation = None;
|
||||
}
|
||||
self.size += other.size;
|
||||
self.objects_count += other.objects_count;
|
||||
self.versions_count += other.versions_count;
|
||||
@@ -3299,6 +3305,31 @@ mod tests {
|
||||
assert_eq!(usage1.versions_count, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_usage_merge_discards_incompatible_incarnation_proofs() {
|
||||
let id = "00000000-0000-4000-8000-000000000001";
|
||||
let mut same = BucketUsageInfo {
|
||||
bucket_incarnation: Some(id.into()),
|
||||
..Default::default()
|
||||
};
|
||||
same.merge(&BucketUsageInfo {
|
||||
bucket_incarnation: Some(id.into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(same.bucket_incarnation.as_deref(), Some(id));
|
||||
same.merge(&BucketUsageInfo::default());
|
||||
assert!(same.bucket_incarnation.is_none());
|
||||
let mut different = BucketUsageInfo {
|
||||
bucket_incarnation: Some(id.into()),
|
||||
..Default::default()
|
||||
};
|
||||
different.merge(&BucketUsageInfo {
|
||||
bucket_incarnation: Some("00000000-0000-4000-8000-000000000002".into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(different.bucket_incarnation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_summary_add_saturates_instead_of_overflowing() {
|
||||
// The scanner folds one summary per object into a per-prefix total, so a
|
||||
|
||||
@@ -107,7 +107,7 @@ flate2.workspace = true
|
||||
http.workspace = true
|
||||
http-body-util.workspace = true
|
||||
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "client-legacy", "tracing"] }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
|
||||
rustfs-signer.workspace = true
|
||||
|
||||
@@ -146,6 +146,8 @@ zip.workspace = true
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
|
||||
[dev-dependencies]
|
||||
same-file.workspace = true
|
||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
|
||||
rustfs-heal.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
//! 3. Starting with the default `rustfsadmin` credentials emits the
|
||||
//! default-credentials startup warning (observable operator signal).
|
||||
|
||||
// Keep inventory acceptance in the admin-auth PR smoke selection.
|
||||
#[cfg(test)]
|
||||
mod target_inventory_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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.
|
||||
|
||||
//! Exercise the target inventory through HTTP authentication and authorization.
|
||||
//! Handler-level tests separately control reconciliation locks and config faults.
|
||||
|
||||
use crate::common::{
|
||||
RustFSTestEnvironment, admin_create_user, admin_request, admin_request_with_session_token, build_test_sts_client,
|
||||
init_logging, local_http_client,
|
||||
};
|
||||
use http::header::{AUTHORIZATION, HOST};
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::{constants::UNSIGNED_PAYLOAD, sign_v4};
|
||||
use s3s::Body;
|
||||
use serde_json::Value;
|
||||
use std::{error::Error, time::Duration};
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
const SECRET: &str = "inventory-acceptance-token";
|
||||
const LIMITED_KEY: &str = "inventory-limited";
|
||||
const LIMITED_SECRET: &str = "inventory-limited-secret";
|
||||
const PATHS: [&str; 5] = [
|
||||
"/rustfs/admin/v3/target/list",
|
||||
"/rustfs/admin/v3/audit/target/list",
|
||||
"/rustfs/admin/v4/plugins/instances",
|
||||
"/rustfs/admin/v4/plugins/instances/builtin:webhook:notify:acceptance",
|
||||
"/rustfs/admin/v4/extensions/instances",
|
||||
];
|
||||
|
||||
async fn duplicate_authorization(
|
||||
env: &RustFSTestEnvironment,
|
||||
path: &str,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}{path}", env.url);
|
||||
let uri: http::Uri = url.parse()?;
|
||||
let authority = uri.authority().ok_or("inventory URL missing authority")?.to_string();
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::GET)
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||
.body(Body::empty())?;
|
||||
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||
let mut headers = signed.headers().clone();
|
||||
let authorization = headers.get(AUTHORIZATION).ok_or("signer omitted authorization")?.clone();
|
||||
headers.append(AUTHORIZATION, authorization);
|
||||
assert_eq!(headers.get_all(AUTHORIZATION).iter().count(), 2);
|
||||
Ok(local_http_client().get(url).headers(headers).send().await?)
|
||||
}
|
||||
|
||||
fn assert_unloaded_target(entry: &Value, path: &str) {
|
||||
assert_eq!(entry["service"], "webhook", "{path}: unexpected target service");
|
||||
assert!(
|
||||
entry["account_id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance")),
|
||||
"{path}: fixture target is missing"
|
||||
);
|
||||
assert_eq!(entry["status"], "offline", "{path}: unloaded targets must remain visible as offline");
|
||||
if !path.contains("/extensions/") {
|
||||
assert_eq!(entry["health_reason"], "not_loaded_in_runtime", "{path}: unexpected health reason");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn target_inventory_preserves_unloaded_targets_and_http_auth_boundaries() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
// Enabled destinations remain configured while both delivery modules are
|
||||
// disabled, so inventory reads must not require a loaded target runtime.
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_NOTIFY_ENABLE", "false"),
|
||||
("RUSTFS_AUDIT_ENABLE", "false"),
|
||||
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
|
||||
("RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
|
||||
("RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
|
||||
("RUSTFS_AUDIT_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
|
||||
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
|
||||
("RUSTFS_AUDIT_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
admin_create_user(&env, LIMITED_KEY, LIMITED_SECRET).await?;
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(60), async {
|
||||
let assumed = build_test_sts_client(&env.url, &env.access_key, &env.secret_key, None, "e2e-target-inventory")
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/target-inventory")
|
||||
.role_session_name("target-inventory-console-session")
|
||||
.send()
|
||||
.await?;
|
||||
let temporary = assumed
|
||||
.credentials()
|
||||
.ok_or("AssumeRole should return temporary inventory credentials")?;
|
||||
assert_ne!(
|
||||
temporary.access_key_id(),
|
||||
env.access_key,
|
||||
"inventory must exercise a distinct session credential"
|
||||
);
|
||||
assert!(
|
||||
!temporary.session_token().is_empty(),
|
||||
"temporary inventory credentials must include a token"
|
||||
);
|
||||
|
||||
for path in PATHS {
|
||||
for (principal, access_key, secret_key, session_token) in [
|
||||
("root", env.access_key.as_str(), env.secret_key.as_str(), None),
|
||||
(
|
||||
"temporary admin",
|
||||
temporary.access_key_id(),
|
||||
temporary.secret_access_key(),
|
||||
Some(temporary.session_token()),
|
||||
),
|
||||
] {
|
||||
let (status, body) = admin_request_with_session_token(
|
||||
&env.url,
|
||||
http::Method::GET,
|
||||
path,
|
||||
None,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "{path}: signed {principal} inventory failed");
|
||||
assert!(!body.contains(SECRET), "{path}: inventory exposed a target secret");
|
||||
let value: Value = serde_json::from_str(&body)?;
|
||||
let entries: Vec<&Value> = if path.ends_with("/target/list") {
|
||||
let field = if path.contains("/audit/") {
|
||||
"audit_endpoints"
|
||||
} else {
|
||||
"notification_endpoints"
|
||||
};
|
||||
value[field].as_array().ok_or("missing endpoint inventory")?.iter().collect()
|
||||
} else if path.ends_with("/instances") {
|
||||
value["instances"]
|
||||
.as_array()
|
||||
.ok_or("missing instance inventory")?
|
||||
.iter()
|
||||
.collect()
|
||||
} else {
|
||||
vec![&value]
|
||||
};
|
||||
let targets: Vec<_> = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry["account_id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance"))
|
||||
})
|
||||
.collect();
|
||||
let expected = if path.ends_with("/instances") { 2 } else { 1 };
|
||||
assert_eq!(targets.len(), expected, "{path}: configured targets disappeared");
|
||||
for entry in &targets {
|
||||
assert_unloaded_target(entry, path);
|
||||
if path.contains("/v4/") {
|
||||
assert_eq!(entry["config"]["auth_token"], "***redacted***", "{path}: missing secret redaction");
|
||||
}
|
||||
}
|
||||
if expected == 2 {
|
||||
for domain in ["notify", "audit"] {
|
||||
assert!(targets.iter().any(|entry| entry["domain"] == domain), "{path}: missing {domain} domain");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
|
||||
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN, "{path}: unsigned request was not denied");
|
||||
let body = unsigned.text().await?;
|
||||
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
|
||||
|
||||
let ambiguous = duplicate_authorization(&env, path).await?;
|
||||
assert_eq!(
|
||||
ambiguous.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"{path}: duplicate authorization was not rejected"
|
||||
);
|
||||
let body = ambiguous.text().await?;
|
||||
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
|
||||
|
||||
let (status, body) = admin_request(&env.url, http::Method::GET, path, None, LIMITED_KEY, LIMITED_SECRET).await?;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{path}: principal without admin policy was not denied");
|
||||
assert!(body.contains("<Code>AccessDenied</Code>"), "{path}: unexpected authorization error");
|
||||
assert!(!body.contains("Signature is required") && !body.contains("SignatureDoesNotMatch"));
|
||||
}
|
||||
Ok::<(), Box<dyn Error + Send + Sync>>(())
|
||||
})
|
||||
.await??;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
+111
-14
@@ -34,7 +34,7 @@ use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde_json;
|
||||
use std::fs as stdfs;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::net::{Ipv6Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
@@ -1272,6 +1272,7 @@ pub struct RustFSTestClusterEnvironment {
|
||||
/// Optional socket proxies used for the corresponding node's volume
|
||||
/// endpoints. Proxies must be installed before [`Self::start`].
|
||||
volume_proxy_addresses: Vec<Option<SocketAddr>>,
|
||||
tls_ca_pem: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl RustFSTestClusterEnvironment {
|
||||
@@ -1312,7 +1313,10 @@ impl RustFSTestClusterEnvironment {
|
||||
pub async fn with_topology(topology: ClusterTopology) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
topology.validate()?;
|
||||
|
||||
let temp_dir = format!("/tmp/rustfs_cluster_test_{}", Uuid::new_v4());
|
||||
let temp_dir = std::env::temp_dir()
|
||||
.join(format!("rustfs_cluster_test_{}", Uuid::new_v4()))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
fs::create_dir_all(&temp_dir).await?;
|
||||
|
||||
// Map every node to its owning pool index.
|
||||
@@ -1374,9 +1378,35 @@ impl RustFSTestClusterEnvironment {
|
||||
node_capture_log_paths: vec![None; topology.node_count],
|
||||
topology,
|
||||
volume_proxy_addresses: vec![None; node_count],
|
||||
tls_ca_pem: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configure verified HTTPS for both volume endpoints and S3 clients.
|
||||
/// The bundle must be generated before any node starts.
|
||||
pub async fn enable_tls(&mut self, directory: &Path) -> std::io::Result<()> {
|
||||
if self.nodes.iter().any(|node| node.process.is_some()) {
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"configure cluster TLS before starting nodes",
|
||||
));
|
||||
}
|
||||
let directory = fs::canonicalize(directory).await?;
|
||||
let ca_pem = fs::read(directory.join("ca.crt")).await?;
|
||||
reqwest::Certificate::from_pem(&ca_pem).map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?;
|
||||
self.set_env(
|
||||
"RUSTFS_TLS_PATH",
|
||||
directory
|
||||
.to_str()
|
||||
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "TLS directory is not UTF-8"))?,
|
||||
);
|
||||
for node in &mut self.nodes {
|
||||
node.url = format!("https://{}", node.address);
|
||||
}
|
||||
self.tls_ca_pem = Some(ca_pem);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an extra environment variable applied to every cluster node process.
|
||||
pub fn set_env<K, V>(&mut self, key: K, value: V)
|
||||
where
|
||||
@@ -1467,6 +1497,29 @@ impl RustFSTestClusterEnvironment {
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
/// Route internode traffic through a method-aware HTTP proxy.
|
||||
pub(crate) async fn start_rpc_proxy_for_node(
|
||||
&mut self,
|
||||
node_idx: usize,
|
||||
) -> io::Result<crate::rpc_fault_proxy::RpcFaultProxy> {
|
||||
self.ensure_node_index(node_idx)
|
||||
.map_err(|error| io::Error::new(ErrorKind::InvalidInput, error))?;
|
||||
if self.volume_proxy_addresses[node_idx].is_some() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::AlreadyExists,
|
||||
format!("a volume proxy is already configured for node {node_idx}"),
|
||||
));
|
||||
}
|
||||
let target = self.nodes[node_idx]
|
||||
.address
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|error| io::Error::new(ErrorKind::InvalidInput, error))?;
|
||||
let listen = SocketAddr::from((Ipv6Addr::LOCALHOST, target.port()));
|
||||
let proxy = crate::rpc_fault_proxy::RpcFaultProxy::start_on(listen, target).await?;
|
||||
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
fn volume_address(&self, node_idx: usize) -> String {
|
||||
self.volume_proxy_addresses[node_idx]
|
||||
.map(|address| address.to_string())
|
||||
@@ -1475,6 +1528,7 @@ impl RustFSTestClusterEnvironment {
|
||||
|
||||
fn build_volumes_arg(&self) -> String {
|
||||
let pools = self.topology.normalized_pools();
|
||||
let scheme = if self.tls_ca_pem.is_some() { "https" } else { "http" };
|
||||
|
||||
if pools.len() <= 1 {
|
||||
// Single pool: explicit enumeration of every drive on every node.
|
||||
@@ -1484,7 +1538,7 @@ impl RustFSTestClusterEnvironment {
|
||||
.enumerate()
|
||||
.flat_map(|(node_idx, n)| {
|
||||
let address = self.volume_address(node_idx);
|
||||
n.data_dirs.iter().map(move |dir| format!("http://{}{}", address, dir))
|
||||
n.data_dirs.iter().map(move |dir| format!("{scheme}://{address}{dir}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
@@ -1504,7 +1558,7 @@ impl RustFSTestClusterEnvironment {
|
||||
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
|
||||
.unwrap_or(&node.data_dir);
|
||||
format!(
|
||||
"http://{}{}/drive{{0...{}}}",
|
||||
"{scheme}://{}{}/drive{{0...{}}}",
|
||||
self.volume_address(node_idx),
|
||||
base,
|
||||
self.topology.drives_per_node - 1
|
||||
@@ -1693,13 +1747,20 @@ impl RustFSTestClusterEnvironment {
|
||||
if node_idx >= self.nodes.len() {
|
||||
return Err("node_idx is invalid".into());
|
||||
}
|
||||
Ok(Client::from_conf(build_test_s3_config(
|
||||
&self.nodes[node_idx].url,
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
None,
|
||||
"cluster-test",
|
||||
)))
|
||||
let mut config =
|
||||
build_test_s3_config(&self.nodes[node_idx].url, &self.access_key, &self.secret_key, None, "cluster-test");
|
||||
if let Some(ca_pem) = &self.tls_ca_pem {
|
||||
use aws_smithy_http_client::tls::{Provider, TlsContext, TrustStore, rustls_provider::CryptoMode};
|
||||
let tls = TlsContext::builder()
|
||||
.with_trust_store(TrustStore::empty().with_pem_certificate(ca_pem.clone()))
|
||||
.build()?;
|
||||
let client = SmithyHttpClientBuilder::new()
|
||||
.tls_provider(Provider::Rustls(CryptoMode::AwsLc))
|
||||
.tls_context(tls)
|
||||
.build_https();
|
||||
config = config.to_builder().http_client(client).build();
|
||||
}
|
||||
Ok(Client::from_conf(config))
|
||||
}
|
||||
|
||||
/// Create S3 clients for all nodes in the RustFS cluster and collect them into a vector.
|
||||
@@ -1824,7 +1885,7 @@ impl RustFSTestClusterEnvironment {
|
||||
}
|
||||
|
||||
self.nodes.push(ClusterNode {
|
||||
url: format!("http://{address}"),
|
||||
url: format!("{}://{address}", if self.tls_ca_pem.is_some() { "https" } else { "http" }),
|
||||
address,
|
||||
data_dir: data_dirs[0].clone(),
|
||||
data_dirs,
|
||||
@@ -2438,6 +2499,7 @@ mod tests {
|
||||
node_capture_log_paths: vec![None; topology.node_count],
|
||||
topology,
|
||||
volume_proxy_addresses: vec![None; node_count],
|
||||
tls_ca_pem: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2455,8 +2517,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volumes_single_pool_multidrive_enumerates_every_drive() {
|
||||
#[tokio::test]
|
||||
async fn volumes_single_pool_multidrive_enumerates_every_drive() {
|
||||
// 4 nodes x 2 drives -> 8 explicit endpoints, one legacy pool. No
|
||||
// ellipses, so the server parser keeps this as a single DistErasure pool.
|
||||
let env = fake_cluster(ClusterTopology::single_pool_multidrive(4, 2));
|
||||
@@ -2468,6 +2530,25 @@ mod tests {
|
||||
.join(" ");
|
||||
assert_eq!(env.build_volumes_arg(), expected);
|
||||
assert_eq!(env.build_volumes_arg().split(' ').count(), 8);
|
||||
|
||||
let directory = tempfile::tempdir().expect("TLS fixture directory");
|
||||
crate::tls_gen::run(crate::tls_gen::Args {
|
||||
out_dir: directory.path().to_owned(),
|
||||
days: 1,
|
||||
force: false,
|
||||
})
|
||||
.expect("generate TLS bundle");
|
||||
for topology in [
|
||||
ClusterTopology::single_pool_multidrive(4, 4),
|
||||
ClusterTopology::per_node_pools(4, vec![vec![0], vec![1]]),
|
||||
] {
|
||||
let mut cluster = fake_cluster(topology);
|
||||
let plaintext_volumes = cluster.build_volumes_arg();
|
||||
cluster.enable_tls(directory.path()).await.expect("configure TLS");
|
||||
assert_eq!(cluster.build_volumes_arg(), plaintext_volumes.replace("http://", "https://"));
|
||||
assert!(cluster.nodes.iter().all(|node| node.url.starts_with("https://")));
|
||||
assert!(cluster.create_s3_client(0).is_ok(), "trusted SDK client should build");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2555,6 +2636,22 @@ mod tests {
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_data_dirs_use_os_temporary_directory() {
|
||||
let cluster = RustFSTestClusterEnvironment::new(1).await.expect("allocate isolated cluster");
|
||||
let root = PathBuf::from(&cluster.temp_dir);
|
||||
assert!(
|
||||
root.starts_with(std::env::temp_dir()),
|
||||
"cluster must honor the selected temporary filesystem"
|
||||
);
|
||||
for node in &cluster.nodes {
|
||||
assert!(Path::new(&node.data_dir).starts_with(&root));
|
||||
assert!(node.data_dirs.iter().all(|drive| Path::new(drive).starts_with(&root)));
|
||||
}
|
||||
drop(cluster);
|
||||
assert!(!root.exists(), "dropping the fixture must remove its owned directories");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_node_env_supports_per_node_overrides() {
|
||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use futures::{StreamExt, TryStreamExt, stream};
|
||||
use rustfs_data_usage::DataUsageInfo;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
@@ -74,16 +75,23 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
|
||||
// Create bucket and upload objects
|
||||
client.create_bucket().bucket(TEST_BUCKET).send().await?;
|
||||
|
||||
for i in 0..1000 {
|
||||
let key = format!("obj-{i:04}");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"hello-world"))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
// Keep the full fixture and await every durable PUT before checking publication.
|
||||
stream::iter(0..1000)
|
||||
.map(Ok)
|
||||
.try_for_each_concurrent(16, |i| {
|
||||
let client = &client;
|
||||
async move {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(format!("obj-{i:04}"))
|
||||
.body(ByteStream::from_static(b"hello-world"))
|
||||
.send()
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let usage = wait_for_bucket_usage(&env, TEST_BUCKET, |usage| {
|
||||
usage
|
||||
|
||||
@@ -20,15 +20,14 @@
|
||||
//! offline before heal converges, a strict latest-listing quorum of 3 can only
|
||||
//! ever observe 2 copies, so ListObjectsV2 silently dropped the object even
|
||||
//! though GetObject (read quorum 2) still succeeded. Exposed by the flaky
|
||||
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853); the
|
||||
//! product fix relaxes the listing's required object quorum by the number of
|
||||
//! set drives the listing could not consult (see
|
||||
//! `latest_listing_required_object_quorum` in
|
||||
//! `crates/ecstore/src/store/list_objects.rs`).
|
||||
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853). The
|
||||
//! listing now follows MinIO's rule: an entry needs `ceil(asked/2)` copies
|
||||
//! among the online drives it asked, so three online drives need two copies
|
||||
//! (see `SetDisks::list_path` in `crates/ecstore/src/store/list_objects.rs`).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use bytes::Bytes;
|
||||
use std::collections::HashSet;
|
||||
@@ -51,13 +50,13 @@ mod tests {
|
||||
format!("degraded-object-{idx:02}")
|
||||
}
|
||||
|
||||
async fn list_all_keys(client: &Client) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
|
||||
async fn list_all_keys(client: &Client, bucket: &str) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
|
||||
let mut keys = HashSet::new();
|
||||
let mut continuation_token: Option<String> = None;
|
||||
loop {
|
||||
let response = client
|
||||
.list_objects_v2()
|
||||
.bucket(BUCKET)
|
||||
.bucket(bucket)
|
||||
.set_continuation_token(continuation_token.clone())
|
||||
.send()
|
||||
.await?;
|
||||
@@ -147,7 +146,7 @@ mod tests {
|
||||
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
|
||||
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||
let listed = loop {
|
||||
let listed = match list_all_keys(&client).await {
|
||||
let listed = match list_all_keys(&client, BUCKET).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) if Instant::now() < deadline => {
|
||||
info!("retrying degraded listing: {error}");
|
||||
@@ -171,4 +170,97 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 4-node x 4-drive cluster (one 16-drive set), #8828 shape:
|
||||
/// 1. PUT objects while the storage class is EC:8, so each is 8+8.
|
||||
/// 2. Restart with the EC:4 default, as after a storage-class change.
|
||||
/// 3. Stop two nodes (8 drives). Each object keeps eight data shards, so
|
||||
/// GET succeeds and ListObjectsV2 must list every key: the listing
|
||||
/// quorum is half of the eight drives it can ask.
|
||||
/// 4. Stop a third node. Four online drives are below the set's read
|
||||
/// quorum, so LIST must report 503 instead of a partial page.
|
||||
#[tokio::test]
|
||||
async fn ec8_objects_stay_listable_with_half_of_an_ec4_set_offline() -> TestResult {
|
||||
const EC8_BUCKET: &str = "degraded-listing-ec8";
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
|
||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:8");
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(EC8_BUCKET).await?;
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
for idx in 0..OBJECT_COUNT {
|
||||
let key = object_key(idx);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(EC8_BUCKET)
|
||||
.key(&key)
|
||||
.body(Bytes::from(format!("ec8 listing payload {idx}")).into())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("EC:8 PUT for {key} failed: {error}"))?;
|
||||
}
|
||||
|
||||
info!("restarting with the EC:4 default storage class");
|
||||
cluster.stop();
|
||||
// A later entry for the same variable wins when the node process starts.
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4");
|
||||
cluster.start().await?;
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
|
||||
info!("stopping nodes 2 and 3 (eight of sixteen drives)");
|
||||
cluster.stop_node(2)?;
|
||||
cluster.stop_node(3)?;
|
||||
for idx in 0..OBJECT_COUNT {
|
||||
let key = object_key(idx);
|
||||
let deadline = Instant::now() + GET_RETRY_DEADLINE;
|
||||
loop {
|
||||
match client.get_object().bucket(EC8_BUCKET).key(&key).send().await {
|
||||
Ok(response) => {
|
||||
let body = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(body.as_ref(), format!("ec8 listing payload {idx}").as_bytes());
|
||||
break;
|
||||
}
|
||||
Err(error) if Instant::now() < deadline => {
|
||||
info!("retrying EC:8 GET for {key}: {error}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(error) => return Err(format!("EC:8 object {key} failed GET with eight drives: {error}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
|
||||
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||
loop {
|
||||
match list_all_keys(&client, EC8_BUCKET).await {
|
||||
Ok(listed) if listed == expected => break,
|
||||
Ok(listed) => assert!(
|
||||
Instant::now() < deadline,
|
||||
"EC:8 objects readable with eight drives stayed missing from ListObjectsV2: listed={listed:?}"
|
||||
),
|
||||
Err(error) => assert!(
|
||||
Instant::now() < deadline,
|
||||
"ListObjectsV2 kept failing with eight of sixteen drives online: {error}"
|
||||
),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
info!("stopping node 1 (four of sixteen drives remain)");
|
||||
cluster.stop_node(1)?;
|
||||
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||
loop {
|
||||
match client.list_objects_v2().bucket(EC8_BUCKET).send().await {
|
||||
Err(error) if error.raw_response().is_some_and(|response| response.status().as_u16() == 503) => break,
|
||||
Err(error) => assert!(Instant::now() < deadline, "below the set read quorum LIST must report 503, got {error}"),
|
||||
Ok(response) => panic!("below the set read quorum LIST must fail, but listed {} keys", response.contents().len()),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,163 @@
|
||||
//! `xl.meta` immediately visible, and the old file-cache shortcut has been
|
||||
//! removed from the read path.
|
||||
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::retry::RetryConfig;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
async fn delete_exact_marker_versions(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
versions: &[(String, bool)],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let objects = versions
|
||||
.iter()
|
||||
.map(|(version, _)| ObjectIdentifier::builder().key(key).version_id(version).build())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let response = client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(Delete::builder().set_objects(Some(objects)).build()?)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(response.errors().is_empty(), "explicit batch returned errors: {response:?}");
|
||||
let mut actual = BTreeMap::new();
|
||||
for deleted in response.deleted() {
|
||||
assert_eq!(deleted.key(), Some(key));
|
||||
*actual
|
||||
.entry((
|
||||
deleted.version_id().ok_or("Deleted entry omitted version ID")?.to_string(),
|
||||
deleted.delete_marker().unwrap_or(false),
|
||||
deleted.delete_marker_version_id().map(str::to_string),
|
||||
))
|
||||
.or_insert(0_usize) += 1;
|
||||
}
|
||||
let mut expected = BTreeMap::new();
|
||||
for (version, marker) in versions {
|
||||
*expected
|
||||
.entry((version.clone(), *marker, marker.then(|| version.clone())))
|
||||
.or_insert(0_usize) += 1;
|
||||
}
|
||||
assert_eq!(actual, expected, "every requested occurrence must report its own version identity");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn assert_explicit_marker_batch_cleanup(
|
||||
clients: &[Client],
|
||||
bucket: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
assert!(!clients.is_empty());
|
||||
let clients: Vec<_> = clients
|
||||
.iter()
|
||||
.map(|client| {
|
||||
Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let writer = &clients[0];
|
||||
let marker_writer = &clients[1 % clients.len()];
|
||||
let deleter = &clients[2 % clients.len()];
|
||||
let key = "same-key-history";
|
||||
writer.create_bucket().bucket(bucket).send().await?;
|
||||
writer
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let mut data = Vec::new();
|
||||
for byte in [41_u8, 73_u8] {
|
||||
let body = vec![byte; 1024 * 1024];
|
||||
let uploaded = writer
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(body.clone().into())
|
||||
.send()
|
||||
.await?;
|
||||
data.push((uploaded.version_id().ok_or("versioned PUT omitted ID")?.to_string(), body));
|
||||
}
|
||||
let mut markers = Vec::with_capacity(16);
|
||||
for _ in 0..16 {
|
||||
let deleted = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(deleted.delete_marker(), Some(true));
|
||||
markers.push(deleted.version_id().ok_or("marker creation omitted ID")?.to_string());
|
||||
}
|
||||
let mut requested: Vec<_> = markers.iter().map(|id| (id.clone(), true)).collect();
|
||||
requested.push((markers[0].clone(), true));
|
||||
requested.push((markers[15].clone(), true));
|
||||
delete_exact_marker_versions(deleter, bucket, key, &requested).await?;
|
||||
for client in &clients {
|
||||
let versions = client.list_object_versions().bucket(bucket).send().await?;
|
||||
assert!(versions.delete_markers().is_empty(), "marker survived successful batch: {versions:?}");
|
||||
let mut actual: Vec<_> = versions
|
||||
.versions()
|
||||
.iter()
|
||||
.filter_map(|version| version.version_id())
|
||||
.collect();
|
||||
actual.sort_unstable();
|
||||
let mut expected: Vec<_> = data.iter().map(|(id, _)| id.as_str()).collect();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
let current = client.get_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(current.body.collect().await?.into_bytes().as_ref(), data[1].1.as_slice());
|
||||
let historical = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(&data[0].0)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), data[0].1.as_slice());
|
||||
}
|
||||
let uuid_marker = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
|
||||
let uuid_marker = uuid_marker.version_id().ok_or("marker creation omitted UUID")?.to_string();
|
||||
writer
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Suspended)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let null_marker = marker_writer.delete_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(null_marker.delete_marker(), Some(true));
|
||||
assert_eq!(null_marker.version_id(), Some("null"));
|
||||
let requested = [
|
||||
(uuid_marker.clone(), true),
|
||||
(data[1].0.clone(), false),
|
||||
("null".to_string(), true),
|
||||
(data[0].0.clone(), false),
|
||||
(uuid_marker, true),
|
||||
];
|
||||
delete_exact_marker_versions(deleter, bucket, key, &requested).await?;
|
||||
for client in &clients {
|
||||
let versions = client.list_object_versions().bucket(bucket).send().await?;
|
||||
assert!(versions.versions().is_empty() && versions.delete_markers().is_empty(), "{versions:?}");
|
||||
}
|
||||
clients
|
||||
.last()
|
||||
.expect("nonempty clients")
|
||||
.delete_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
@@ -35,6 +192,16 @@ mod tests {
|
||||
env.create_s3_client()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_explicit_delete_marker_batch_removes_versions_without_client_retries()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await?;
|
||||
super::assert_explicit_marker_batch_cleanup(&[env.create_s3_client()], "explicit-marker-batch").await
|
||||
}
|
||||
|
||||
/// Regression test for Issue #1878.
|
||||
///
|
||||
/// Verifies that after calling `delete_objects` (the batch plural API) on
|
||||
|
||||
@@ -13,18 +13,20 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
|
||||
wait_for_ready, wait_until,
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, get_object_bytes, payload_for, put_object,
|
||||
retrying_get_equals, retrying_put, s3_probe_client, s3_probe_error_code, unique_bucket, wait_for_ready, wait_until,
|
||||
};
|
||||
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
|
||||
use crate::common::{build_test_s3_config, init_logging};
|
||||
use crate::fault_proxy::FaultMode;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Barrier, mpsc};
|
||||
use tokio::time::timeout;
|
||||
use tokio::time::{Instant, timeout};
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||
@@ -44,6 +46,133 @@ async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ec4_partial_node_return_restores_read_write_quorum() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start_with_env(
|
||||
DistLayout::FourByFour,
|
||||
&[
|
||||
("RUSTFS_ERASURE_SET_DRIVE_COUNT", "16"),
|
||||
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let storage = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||
let storage: serde_json::Value = serde_json::from_str(&storage)?;
|
||||
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
|
||||
assert_eq!(storage.disks.len(), 16);
|
||||
assert_eq!(storage.backend.total_sets, vec![1]);
|
||||
assert_eq!(storage.backend.drives_per_set, vec![16]);
|
||||
assert_eq!(storage.backend.standard_sc_parity, Some(4));
|
||||
|
||||
let bucket = unique_bucket("partial-ec4");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = s3_probe_client(&dist.client(0)?);
|
||||
let baseline = payload_for("partial-recovery/baseline", 8 * 1024 * 1024);
|
||||
put_object(&client, &bucket, "baseline.bin", baseline.clone()).await?;
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
|| async {
|
||||
for node in &dist.cluster.nodes {
|
||||
for directory in &node.data_dirs {
|
||||
let census = census_object_version_on_disk(std::path::Path::new(directory), &bucket, "baseline.bin", None)?;
|
||||
if !census.is_complete() {
|
||||
return Ok(false);
|
||||
}
|
||||
assert_eq!(census.data_blocks, Some(12), "the object must require twelve data shards");
|
||||
assert_eq!(census.parity_blocks, Some(4), "the object must have four parity shards");
|
||||
assert!(!census.expected_part_numbers.is_empty(), "the baseline must use physical shards");
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
"the baseline is complete on all sixteen EC:4 drives",
|
||||
)
|
||||
.await?;
|
||||
|
||||
dist.cluster.stop_node(2)?;
|
||||
dist.cluster.stop_node(3)?;
|
||||
let read_error = timeout(Duration::from_secs(60), get_object_bytes(&client, &bucket, "baseline.bin"))
|
||||
.await?
|
||||
.expect_err("eight unavailable drives must prevent the baseline GET");
|
||||
assert!(
|
||||
matches!(
|
||||
s3_probe_error_code(read_error.as_ref()),
|
||||
Some("SlowDown" | "SlowDownRead" | "ServiceUnavailable")
|
||||
),
|
||||
"quorum loss must produce an availability error: {read_error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
read_error
|
||||
.downcast_ref::<SdkError<GetObjectError>>()
|
||||
.and_then(SdkError::raw_response)
|
||||
.map(|response| response.status().as_u16()),
|
||||
Some(503),
|
||||
"read quorum loss must remain a service availability failure"
|
||||
);
|
||||
let write_error = timeout(Duration::from_secs(60), put_object(&client, &bucket, "unavailable.bin", baseline.clone()))
|
||||
.await?
|
||||
.expect_err("eight unavailable drives must prevent a successful PUT");
|
||||
assert!(
|
||||
matches!(s3_probe_error_code(write_error.as_ref()), Some("SlowDown" | "ServiceUnavailable")),
|
||||
"quorum loss must produce an availability error: {write_error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
write_error
|
||||
.downcast_ref::<SdkError<PutObjectError>>()
|
||||
.and_then(SdkError::raw_response)
|
||||
.map(|response| response.status().as_u16()),
|
||||
Some(503),
|
||||
"write quorum loss must remain a service availability failure"
|
||||
);
|
||||
|
||||
// Recover only four drives. The remaining node must stay stopped while
|
||||
// every live coordinator serves old data and acknowledges new writes.
|
||||
dist.cluster.start_node_process(2).await?;
|
||||
let recovery_deadline = Instant::now() + Duration::from_secs(90);
|
||||
let mut recovered_objects = Vec::new();
|
||||
for node in 0..3 {
|
||||
assert!(dist.cluster.nodes[3].process.is_none(), "the fourth node must remain unavailable");
|
||||
let client = s3_probe_client(&dist.client(node)?);
|
||||
retrying_get_equals(
|
||||
&client,
|
||||
&bucket,
|
||||
"baseline.bin",
|
||||
&baseline,
|
||||
recovery_deadline.saturating_duration_since(Instant::now()),
|
||||
)
|
||||
.await?;
|
||||
let key = format!("partial-{node}.bin");
|
||||
let body = payload_for(&key, 1024 * 1024);
|
||||
retrying_put(
|
||||
&client,
|
||||
&bucket,
|
||||
&key,
|
||||
body.clone(),
|
||||
recovery_deadline.saturating_duration_since(Instant::now()),
|
||||
)
|
||||
.await?;
|
||||
recovered_objects.push((key, body));
|
||||
}
|
||||
for node in 0..3 {
|
||||
assert!(dist.cluster.nodes[3].process.is_none(), "reads must complete before all drives return");
|
||||
let client = s3_probe_client(&dist.client(node)?);
|
||||
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
|
||||
for (key, body) in &recovered_objects {
|
||||
assert_object_bytes(&client, &bucket, key, body).await?;
|
||||
}
|
||||
}
|
||||
|
||||
dist.cluster.start_node(3).await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
let client = s3_probe_client(&dist.client(3)?);
|
||||
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
|
||||
for (key, body) in &recovered_objects {
|
||||
assert_object_bytes(&client, &bucket, key, body).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_cluster_restart_preserves_objects() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -34,7 +34,7 @@ use crate::common::{
|
||||
};
|
||||
use crate::replication_extension_test::LOOPBACK_REPLICATION_TARGET_ENV;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::config::retry::RetryConfig;
|
||||
use aws_sdk_s3::config::{Credentials, retry::RetryConfig};
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
@@ -185,13 +185,12 @@ impl DistCluster {
|
||||
if node_idx >= self.cluster.nodes.len() {
|
||||
return Err("node_idx is invalid".into());
|
||||
}
|
||||
Ok(Client::from_conf(build_test_s3_config(
|
||||
&self.cluster.nodes[node_idx].url,
|
||||
access_key,
|
||||
secret_key,
|
||||
None,
|
||||
"cluster-iam-test",
|
||||
)))
|
||||
let config = self.cluster.create_s3_client(node_idx)?.config().to_builder();
|
||||
Ok(Client::from_conf(
|
||||
config
|
||||
.credentials_provider(Credentials::new(access_key, secret_key, None, None, "cluster-iam-test"))
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Stop the fleet, drop retired pools from the command line, and restart
|
||||
@@ -347,7 +346,10 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
|
||||
Ok(pool_run_root)
|
||||
}
|
||||
|
||||
fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
|
||||
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
|
||||
if let Ok(filter) = std::env::var("RUSTFS_E2E_LOG_FILTER") {
|
||||
cluster.set_env("RUST_LOG", filter);
|
||||
}
|
||||
if let Some(directory) = capture_dir {
|
||||
std::fs::create_dir_all(directory)?;
|
||||
for node_idx in 0..cluster.nodes.len() {
|
||||
@@ -1179,11 +1181,16 @@ pub(crate) async fn retrying_get_equals(
|
||||
)
|
||||
.into()),
|
||||
Err(error) => {
|
||||
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
let retryable = match s3_probe_error_code(error.as_ref()) {
|
||||
Some("SlowDown" | "ServiceUnavailable") => true,
|
||||
// GET preserves this compatibility code for a lost data quorum.
|
||||
Some("SlowDownRead") => error
|
||||
.downcast_ref::<SdkError<GetObjectError>>()
|
||||
.and_then(SdkError::raw_response)
|
||||
.is_some_and(|response| response.status().as_u16() == 503),
|
||||
_ => false,
|
||||
};
|
||||
if retryable { Ok(false) } else { Err(error) }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1322,6 +1329,62 @@ mod retry_tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retrying_get_allows_read_quorum_recovery() -> TestResult {
|
||||
use aws_sdk_s3::config::Region;
|
||||
use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
|
||||
use aws_smithy_types::body::SdkBody;
|
||||
|
||||
let endpoint = "http://read-quorum.example.invalid";
|
||||
let event = |status: u16, body: &'static str| {
|
||||
ReplayEvent::new(
|
||||
http::Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("{endpoint}/retry-probe/key?x-id=GetObject"))
|
||||
.body(SdkBody::empty())
|
||||
.expect("build replay request"),
|
||||
http::Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/xml")
|
||||
.header("content-length", body.len())
|
||||
.body(SdkBody::from(body))
|
||||
.expect("build replay response"),
|
||||
)
|
||||
};
|
||||
for status in [503, 403, 500] {
|
||||
let transport = StaticReplayClient::new(vec![
|
||||
event(status, "<Error><Code>SlowDownRead</Code></Error>"),
|
||||
event(200, "expected bytes"),
|
||||
]);
|
||||
let client = Client::from_conf(
|
||||
aws_sdk_s3::Config::builder()
|
||||
.behavior_version_latest()
|
||||
.credentials_provider(Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "read-quorum-test"))
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(endpoint)
|
||||
.force_path_style(true)
|
||||
.retry_config(RetryConfig::disabled())
|
||||
.http_client(transport.clone())
|
||||
.build(),
|
||||
);
|
||||
let result = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await;
|
||||
if status == 503 {
|
||||
result?;
|
||||
assert_eq!(
|
||||
transport.actual_requests().count(),
|
||||
2,
|
||||
"the probe must retry the read-quorum error exactly once"
|
||||
);
|
||||
transport.assert_requests_match(&[]);
|
||||
} else {
|
||||
let error = result.expect_err("the compatibility code must not hide a non-503 failure");
|
||||
assert_eq!(s3_probe_error_code(error.as_ref()), Some("SlowDownRead"));
|
||||
assert_eq!(transport.actual_requests().count(), 1, "non-availability errors must fail immediately");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
|
||||
let target = FakeS3Target::start().await?;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
|
||||
use crate::tls_gen;
|
||||
use aws_sdk_s3::{Client, config::retry::RetryConfig, primitives::ByteStream};
|
||||
use rand::{Rng, SeedableRng, rngs::StdRng};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
struct ExpectedRead {
|
||||
range: Option<(u64, u64)>,
|
||||
length: u64,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
struct ObjectFixture {
|
||||
key: String,
|
||||
path: PathBuf,
|
||||
length: u64,
|
||||
reads: Vec<ExpectedRead>,
|
||||
}
|
||||
|
||||
fn fixture(directory: &Path, length: u64) -> TestResult<ObjectFixture> {
|
||||
let key = format!("object-{length}");
|
||||
let path = directory.join(&key);
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
let mut rng = StdRng::seed_from_u64(length);
|
||||
let mut block = vec![0; usize::try_from(MIB)?];
|
||||
let mut remaining = length;
|
||||
let mut digest = Sha256::new();
|
||||
while remaining != 0 {
|
||||
let count = usize::try_from(remaining.min(MIB))?;
|
||||
rng.fill_bytes(&mut block[..count]);
|
||||
file.write_all(&block[..count])?;
|
||||
digest.update(&block[..count]);
|
||||
remaining -= u64::try_from(count)?;
|
||||
}
|
||||
drop(file);
|
||||
let mut reads = vec![ExpectedRead {
|
||||
range: None,
|
||||
length,
|
||||
sha256: hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower),
|
||||
}];
|
||||
// Unaligned ranges cross TLS frames and EC blocks; the tail detects
|
||||
// truncation and incorrect range offsets independently of a full GET.
|
||||
for (start, end) in [(17, (MIB + 31).min(length - 1)), (length.saturating_sub(65537), length - 1)] {
|
||||
let mut input = std::fs::File::open(&path)?;
|
||||
input.seek(SeekFrom::Start(start))?;
|
||||
let range_length = end - start + 1;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut input = input.take(range_length);
|
||||
loop {
|
||||
let count = input.read(&mut block)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&block[..count]);
|
||||
}
|
||||
reads.push(ExpectedRead {
|
||||
range: Some((start, end)),
|
||||
length: range_length,
|
||||
sha256: hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower),
|
||||
});
|
||||
}
|
||||
Ok(ObjectFixture {
|
||||
key,
|
||||
path,
|
||||
length,
|
||||
reads,
|
||||
})
|
||||
}
|
||||
|
||||
async fn verified_get(
|
||||
client: &Client,
|
||||
node: usize,
|
||||
bucket: &str,
|
||||
object: &ObjectFixture,
|
||||
expected: &ExpectedRead,
|
||||
concurrent: bool,
|
||||
) -> TestResult<serde_json::Value> {
|
||||
let started = Instant::now();
|
||||
let mut received = 0u64;
|
||||
let mut stage = "response headers";
|
||||
tokio::time::timeout(READ_TIMEOUT, async {
|
||||
let mut request = client.get_object().bucket(bucket).key(&object.key);
|
||||
if let Some((start, end)) = expected.range {
|
||||
request = request.range(format!("bytes={start}-{end}"));
|
||||
}
|
||||
let mut output = request.send().await?;
|
||||
assert_eq!(output.content_length(), Some(i64::try_from(expected.length)?));
|
||||
if let Some((start, end)) = expected.range {
|
||||
assert_eq!(output.content_range(), Some(format!("bytes {start}-{end}/{}", object.length).as_str()));
|
||||
} else {
|
||||
assert_eq!(output.content_range(), None);
|
||||
}
|
||||
stage = "response body";
|
||||
let mut digest = Sha256::new();
|
||||
while let Some(chunk) = output.body.next().await {
|
||||
let chunk = chunk.inspect_err(|error| {
|
||||
eprintln!(
|
||||
"TLS acceptance: GET body failed: node={node}, key={}, range={:?}, concurrent={concurrent}, received={received}/{}, elapsed={:?}, error={error}",
|
||||
object.key, expected.range, expected.length, started.elapsed()
|
||||
);
|
||||
})?;
|
||||
received += u64::try_from(chunk.len())?;
|
||||
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
|
||||
digest.update(&chunk);
|
||||
}
|
||||
let actual_sha256 = hex_simd::encode_to_string(digest.finalize(), hex_simd::AsciiCase::Lower);
|
||||
assert_eq!(received, expected.length, "node {node}: truncated GET {}", object.key);
|
||||
assert_eq!(actual_sha256, expected.sha256, "node {node}: corrupt GET {}", object.key);
|
||||
eprintln!(
|
||||
"TLS acceptance: GET node={node}, key={}, range={:?}, concurrent={concurrent}, bytes={received}, elapsed={:?}",
|
||||
object.key, expected.range, started.elapsed()
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"operation": "get", "node": node, "key": object.key,
|
||||
"range": expected.range, "bytes": received, "sha256": actual_sha256,
|
||||
"concurrent": concurrent, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"TLS GET timed out: node={node}, key={}, range={:?}, concurrent={concurrent}, stage={stage}, received={received}/{}, elapsed={:?}: {error}",
|
||||
object.key, expected.range, expected.length, started.elapsed()
|
||||
),
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
/// This case always exercises the full acceptance sizes; do not substitute
|
||||
/// tiny objects and count that as a large distributed-read pass.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn four_node_tls_large_and_range_gets_preserve_bytes() -> TestResult {
|
||||
let mode = std::env::var("RUSTFS_E2E_INTERNODE_HTTP_MODE").unwrap_or_else(|_| "http1".into());
|
||||
let protocol = match mode.as_str() {
|
||||
"http1" => "http1",
|
||||
"auto" | "unpooled" => "auto",
|
||||
_ => return Err("RUSTFS_E2E_INTERNODE_HTTP_MODE must be http1, auto or unpooled".into()),
|
||||
};
|
||||
let mut dist = DistCluster::new_stopped_with_env(
|
||||
DistLayout::FourByFour,
|
||||
&[
|
||||
("RUSTFS_INTERNODE_HTTP_PROTOCOL", protocol),
|
||||
("RUSTFS_SCANNER_ENABLED", "true"),
|
||||
("RUSTFS_SCANNER_START_DELAY_SECS", "0"),
|
||||
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||
("RUSTFS_HEAL_ENABLED", "true"),
|
||||
("RUST_LOG", "warn"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if mode == "unpooled" {
|
||||
dist.cluster.set_env("RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST", "0");
|
||||
}
|
||||
let directory = PathBuf::from(&dist.cluster.temp_dir);
|
||||
let tls_directory = directory.join("tls");
|
||||
tls_gen::run(tls_gen::Args {
|
||||
out_dir: tls_directory.clone(),
|
||||
days: 1,
|
||||
force: false,
|
||||
})?;
|
||||
dist.cluster.enable_tls(&tls_directory).await?;
|
||||
let report_path = std::env::var_os("RUSTFS_INTERNODE_ACCEPTANCE_REPORT").map(PathBuf::from);
|
||||
if let Some(path) = &report_path
|
||||
&& (path.exists() || path.with_extension("pids.json").exists())
|
||||
{
|
||||
return Err("acceptance report paths must be unused".into());
|
||||
}
|
||||
let log_directory = report_path.as_ref().map_or_else(
|
||||
|| {
|
||||
std::env::var_os("RUSTFS_E2E_LOG_DIR")
|
||||
.map_or_else(|| directory.join("logs"), |root| PathBuf::from(root).join(unique_bucket("internode-tls")))
|
||||
},
|
||||
|path| path.with_extension("logs"),
|
||||
);
|
||||
std::fs::create_dir(&log_directory)?;
|
||||
dist.capture_logs_under(&log_directory)?;
|
||||
dist.cluster.start().await?;
|
||||
let clients = dist
|
||||
.clients()?
|
||||
.into_iter()
|
||||
.map(|client| Client::from_conf(client.config().to_builder().retry_config(RetryConfig::disabled()).build()))
|
||||
.collect::<Vec<_>>();
|
||||
let bucket = unique_bucket("internode-tls");
|
||||
clients[0].create_bucket().bucket(&bucket).send().await?;
|
||||
let pids = dist
|
||||
.cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.process.as_ref().map(std::process::Child::id))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(path) = &report_path {
|
||||
std::fs::write(path.with_extension("pids.json"), serde_json::to_vec(&pids)?)?;
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
for length in [32 * 1024, 100 * MIB, 1024 * MIB] {
|
||||
let fixture_directory = directory.clone();
|
||||
let object = Arc::new(tokio::task::spawn_blocking(move || fixture(&fixture_directory, length)).await??);
|
||||
eprintln!("TLS acceptance: fixture key={}, bytes={length}", object.key);
|
||||
let started = Instant::now();
|
||||
tokio::time::timeout(
|
||||
READ_TIMEOUT,
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object.key)
|
||||
.body(ByteStream::from_path(&object.path).await?)
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"TLS PUT timed out: key={}, bytes={length}, elapsed={:?}: {error}",
|
||||
object.key,
|
||||
started.elapsed()
|
||||
),
|
||||
)
|
||||
})??;
|
||||
eprintln!("TLS acceptance: PUT key={}, bytes={length}, elapsed={:?}", object.key, started.elapsed());
|
||||
results.push(
|
||||
serde_json::json!({"operation": "put", "bytes": length, "elapsed_ms": started.elapsed().as_secs_f64() * 1000.0}),
|
||||
);
|
||||
if length == 32 * 1024 {
|
||||
let alternate = dist.client_with_credentials(0, &dist.cluster.access_key, &dist.cluster.secret_key)?;
|
||||
let alternate = Client::from_conf(alternate.config().to_builder().retry_config(RetryConfig::disabled()).build());
|
||||
results.push(verified_get(&alternate, 0, &bucket, &object, &object.reads[0], false).await?);
|
||||
let invalid = dist.client_with_credentials(0, "invalid-tls-user", "invalid-tls-secret-password")?;
|
||||
let invalid = Client::from_conf(invalid.config().to_builder().retry_config(RetryConfig::disabled()).build());
|
||||
let denied = invalid
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect_err("alternate credentials must not inherit the root identity");
|
||||
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
}
|
||||
for expected in &object.reads {
|
||||
for (node, client) in clients.iter().enumerate() {
|
||||
results.push(verified_get(client, node, &bucket, &object, expected, false).await?);
|
||||
}
|
||||
}
|
||||
// Each reader owns a Tokio task and its cooperative budget. Polling
|
||||
// every body and synchronous digest in one parent task can let an
|
||||
// always-ready reader starve its siblings and their deadlines.
|
||||
let mut readers = JoinSet::new();
|
||||
for (node, client) in clients.iter().enumerate() {
|
||||
let client = client.clone();
|
||||
let bucket = bucket.clone();
|
||||
let object = Arc::clone(&object);
|
||||
readers.spawn(async move { verified_get(&client, node, &bucket, &object, &object.reads[0], true).await });
|
||||
}
|
||||
while let Some(result) = readers.join_next().await {
|
||||
results.push(result??);
|
||||
}
|
||||
tokio::fs::remove_file(&object.path).await?;
|
||||
}
|
||||
for node in 0..clients.len() {
|
||||
let log = tokio::fs::read_to_string(log_directory.join(format!("node-{node}.log"))).await?;
|
||||
assert!(
|
||||
!log.contains("internode body stalled"),
|
||||
"node {node}: internode body stall during verified reads"
|
||||
);
|
||||
}
|
||||
let report = serde_json::json!({
|
||||
"schema": 1, "mode": mode, "tls_verified": true, "nodes": 4, "drives_per_node": 4,
|
||||
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||
"source_dirty": env!("RUSTFS_E2E_BUILD_DIRTY"),
|
||||
"scanner_enabled": true, "heal_enabled": true,
|
||||
"scanner_progress_verified": false, "heal_progress_verified": false,
|
||||
"results": results,
|
||||
});
|
||||
if let Some(path) = report_path {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&report)?)?;
|
||||
}
|
||||
eprintln!("internode_tls_acceptance={report}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -26,14 +26,20 @@ mod expand_decommission_rebalance_test;
|
||||
mod extra_test;
|
||||
mod harness;
|
||||
mod heal_test;
|
||||
mod internode_tls_test;
|
||||
mod multipart_fence_acceptance_test;
|
||||
mod multipart_rpc_delay_test;
|
||||
mod object_lock_test;
|
||||
mod observability_test;
|
||||
mod publication_log;
|
||||
mod replication_delete_marker_test;
|
||||
mod replication_quota_test;
|
||||
mod s3_basic_test;
|
||||
mod s3_during_data_movement_test;
|
||||
mod scanner_heal_topology_fixture;
|
||||
mod scanner_heal_topology_test;
|
||||
mod scanner_heal_topology_tier_test;
|
||||
mod site_replication_test;
|
||||
mod table_catalog_test;
|
||||
mod upgrade_test;
|
||||
mod versioning_test;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Follow the same append-only log files without rescanning previous JSON rows.
|
||||
|
||||
use same_file::Handle;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Captured RPC spans already produce valid rows above 2 MiB. Keep complete
|
||||
// rows bounded independently from the much smaller selected-event history.
|
||||
const MAX_LINE_BYTES: usize = 4 << 20;
|
||||
const MAX_EVENTS: usize = 128;
|
||||
const MAX_EVENT_BYTES: usize = 1 << 20;
|
||||
|
||||
fn invalid(reason: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, reason)
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct Selector {
|
||||
#[serde(default)]
|
||||
event: Value,
|
||||
#[serde(default)]
|
||||
op: Value,
|
||||
#[serde(default)]
|
||||
bucket: Value,
|
||||
#[serde(default)]
|
||||
object: Value,
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
fn matches(&self, bucket: &str, object: &str) -> bool {
|
||||
self.event.as_str() == Some("object_namespace_lock")
|
||||
&& self.op.as_str() == Some("data_movement_publication")
|
||||
&& self.bucket.as_str() == Some(bucket)
|
||||
&& self.object.as_str() == Some(object)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Probe {
|
||||
#[serde(default)]
|
||||
event: Value,
|
||||
#[serde(default)]
|
||||
op: Value,
|
||||
#[serde(default)]
|
||||
bucket: Value,
|
||||
#[serde(default)]
|
||||
object: Value,
|
||||
// A present null is not a missing fields object. The old reader uses the
|
||||
// root only when this key is absent, including for non-object JSON values.
|
||||
#[serde(default, deserialize_with = "present_fields")]
|
||||
fields: Option<Option<Selector>>,
|
||||
}
|
||||
|
||||
fn present_fields<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<Selector>>, D::Error> {
|
||||
Option::<Selector>::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn matches(&self, bucket: &str, object: &str) -> bool {
|
||||
match &self.fields {
|
||||
Some(Some(fields)) => fields.matches(bucket, object),
|
||||
Some(None) => false,
|
||||
None => {
|
||||
self.event.as_str() == Some("object_namespace_lock")
|
||||
&& self.op.as_str() == Some("data_movement_publication")
|
||||
&& self.bucket.as_str() == Some(bucket)
|
||||
&& self.object.as_str() == Some(object)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select(line: &[u8], bucket: &str, object: &str, full_decodes: &mut u64) -> io::Result<Option<Value>> {
|
||||
let text = std::str::from_utf8(line).map_err(|_| invalid("invalid UTF-8 in a completed log row"))?;
|
||||
// Skip unknown fields without allocating a Value tree for every RPC trace.
|
||||
// A shape/duplicate-field error falls back to the original Value parser;
|
||||
// it must not discard valid escaped strings or last-key-wins JSON.
|
||||
if let Ok(probe) = serde_json::from_str::<Probe>(text)
|
||||
&& !probe.matches(bucket, object)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
*full_decodes += 1;
|
||||
let Ok(row) = serde_json::from_str::<Value>(text) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let fields = row.get("fields").unwrap_or(&row);
|
||||
if fields.get("event").and_then(Value::as_str) != Some("object_namespace_lock")
|
||||
|| fields.get("op").and_then(Value::as_str) != Some("data_movement_publication")
|
||||
|| fields.get("bucket").and_then(Value::as_str) != Some(bucket)
|
||||
|| fields.get("object").and_then(Value::as_str) != Some(object)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let mut event = fields.clone();
|
||||
event["timestamp"] = row["timestamp"].clone();
|
||||
Ok(Some(event))
|
||||
}
|
||||
|
||||
struct Tail {
|
||||
path: PathBuf,
|
||||
handle: Handle,
|
||||
offset: u64,
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Tail {
|
||||
fn open(path: PathBuf) -> io::Result<Self> {
|
||||
let file = File::open(&path)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(invalid("publication log is not a regular file"));
|
||||
}
|
||||
Ok(Self {
|
||||
path,
|
||||
handle: Handle::from_file(file)?,
|
||||
offset: 0,
|
||||
pending: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn check_identity(&self) -> io::Result<()> {
|
||||
if Handle::from_path(&self.path)? != self.handle {
|
||||
return Err(invalid("publication log file was replaced"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll(&mut self, bytes_read: &mut u64, mut accept: impl FnMut(&[u8]) -> io::Result<()>) -> io::Result<()> {
|
||||
self.check_identity()?;
|
||||
let end = self.handle.as_file().metadata()?.len();
|
||||
if end < self.offset {
|
||||
return Err(invalid("publication log file was truncated"));
|
||||
}
|
||||
// A fixed high-water snapshot bounds each poll even if writers append
|
||||
// faster than the reader. Bytes after it remain for the next poll.
|
||||
let mut chunk = [0_u8; 64 * 1024];
|
||||
while self.offset < end {
|
||||
let size = usize::try_from((end - self.offset).min(64 * 1024)).expect("a log chunk fits usize");
|
||||
self.handle.as_file_mut().read_exact(&mut chunk[..size])?;
|
||||
self.offset += u64::try_from(size).expect("a bounded log read fits u64");
|
||||
*bytes_read += u64::try_from(size).expect("a bounded log read fits u64");
|
||||
let appended_at = self.pending.len();
|
||||
self.pending.extend_from_slice(&chunk[..size]);
|
||||
let mut start = 0;
|
||||
for end in self.pending[appended_at..]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, byte)| (*byte == b'\n').then_some(appended_at + i + 1))
|
||||
{
|
||||
if end - start > MAX_LINE_BYTES {
|
||||
return Err(invalid("publication log row exceeds its fixed bound"));
|
||||
}
|
||||
accept(&self.pending[start..end])?;
|
||||
start = end;
|
||||
}
|
||||
self.pending.drain(..start);
|
||||
if self.pending.len() > MAX_LINE_BYTES {
|
||||
return Err(invalid("unfinished publication log row exceeds its fixed bound"));
|
||||
}
|
||||
}
|
||||
self.check_identity()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PublicationEvents {
|
||||
tails: Vec<Tail>,
|
||||
bucket: String,
|
||||
object: String,
|
||||
events: Vec<Value>,
|
||||
per_file: Vec<Vec<Value>>,
|
||||
event_count: usize,
|
||||
event_bytes: usize,
|
||||
bytes_read: u64,
|
||||
full_decodes: u64,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl PublicationEvents {
|
||||
pub(super) fn new(paths: Vec<PathBuf>, bucket: &str, object: &str) -> io::Result<Self> {
|
||||
if paths.is_empty() || paths.len() > 4 {
|
||||
return Err(invalid("publication capture requires one to four log files"));
|
||||
}
|
||||
let tails: Vec<Tail> = paths.into_iter().map(Tail::open).collect::<io::Result<_>>()?;
|
||||
let mut reader = Self {
|
||||
per_file: vec![Vec::new(); tails.len()],
|
||||
tails,
|
||||
bucket: bucket.to_owned(),
|
||||
object: object.to_owned(),
|
||||
events: Vec::new(),
|
||||
event_bytes: 0,
|
||||
event_count: 0,
|
||||
bytes_read: 0,
|
||||
full_decodes: 0,
|
||||
failed: false,
|
||||
};
|
||||
// Prime before holding remote DELETE or starting a movement attempt.
|
||||
// Retain earlier matching events too: the caller still applies its
|
||||
// original timestamp boundary, and evidence keeps the complete history.
|
||||
reader.poll()?;
|
||||
Ok(reader)
|
||||
}
|
||||
|
||||
pub(super) fn poll(&mut self) -> io::Result<&[Value]> {
|
||||
if self.failed {
|
||||
return Err(invalid("publication log reader was already invalidated"));
|
||||
}
|
||||
let result = (|| {
|
||||
for (tail, history) in self.tails.iter_mut().zip(&mut self.per_file) {
|
||||
tail.poll(&mut self.bytes_read, |line| {
|
||||
if let Some(event) = select(line, &self.bucket, &self.object, &mut self.full_decodes)? {
|
||||
let bytes = serde_json::to_vec(&event).map_err(io::Error::other)?.len();
|
||||
if self.event_count >= MAX_EVENTS || bytes > MAX_EVENT_BYTES - self.event_bytes {
|
||||
return Err(invalid("publication event history exceeds its fixed bound"));
|
||||
}
|
||||
self.event_bytes += bytes;
|
||||
self.event_count += 1;
|
||||
history.push(event);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
// Preserve the old file-then-row order, even when a later poll appends
|
||||
// to an earlier file. Only bounded selected history is copied here.
|
||||
self.events = self.per_file.iter().flatten().cloned().collect();
|
||||
if let Err(error) = result {
|
||||
self.failed = true;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(&self.events)
|
||||
}
|
||||
|
||||
/// Diagnostics only: after a read error this history can be incomplete.
|
||||
pub(super) fn cached(&self) -> &[Value] {
|
||||
&self.events
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn event(id: &str) -> Value {
|
||||
json!({"timestamp":"2026-10-26T00:00:00Z","event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object","lock_attempt_id":id,"state":"acquiring","extra":{"kept":[1,2]}})
|
||||
}
|
||||
fn append(path: &std::path::Path, raw: &[u8]) {
|
||||
std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.expect("open append log")
|
||||
.write_all(raw)
|
||||
.expect("append log bytes");
|
||||
}
|
||||
fn legacy(raw: &[u8]) -> Vec<Value> {
|
||||
std::str::from_utf8(raw)
|
||||
.expect("fixture UTF-8")
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let row: Value = serde_json::from_str(line).ok()?;
|
||||
let fields = row.get("fields").unwrap_or(&row);
|
||||
if fields.get("event").and_then(Value::as_str) == Some("object_namespace_lock")
|
||||
&& fields.get("op").and_then(Value::as_str) == Some("data_movement_publication")
|
||||
&& fields.get("bucket").and_then(Value::as_str) == Some("bucket")
|
||||
&& fields.get("object").and_then(Value::as_str) == Some("object")
|
||||
{
|
||||
let mut result = fields.clone();
|
||||
result["timestamp"] = row["timestamp"].clone();
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_events_keep_full_fields_without_history_rescans() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut raw = (json!({"message":"unrelated trace","payload":"x".repeat(4096)}).to_string() + "\n")
|
||||
.repeat(64)
|
||||
.into_bytes();
|
||||
raw.extend_from_slice(
|
||||
format!("{}\n", json!({"event":"object_namespace_lock","op":"get_object","bucket":"bucket","object":"object","spans":"x".repeat(2401832)})).as_bytes(),
|
||||
);
|
||||
raw.extend_from_slice(format!("{}\n", event("first")).as_bytes());
|
||||
std::fs::write(file.path(), &raw).expect("seed log");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
|
||||
assert_eq!(reader.cached(), legacy(&raw));
|
||||
assert_eq!(reader.full_decodes, 1);
|
||||
let bytes = reader.bytes_read;
|
||||
assert_eq!(reader.poll().expect("unchanged poll"), legacy(&raw));
|
||||
assert_eq!(reader.bytes_read, bytes);
|
||||
let next = format!("{}\n", event("second"));
|
||||
append(file.path(), next.as_bytes());
|
||||
raw.extend_from_slice(next.as_bytes());
|
||||
assert_eq!(reader.poll().expect("new rows"), legacy(&raw));
|
||||
assert_eq!(reader.bytes_read, u64::try_from(raw.len()).expect("fixture length"));
|
||||
assert_eq!(reader.full_decodes, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_appends_preserve_the_old_file_then_row_order() {
|
||||
let first = tempfile::NamedTempFile::new().expect("first log");
|
||||
let second = tempfile::NamedTempFile::new().expect("second log");
|
||||
std::fs::write(first.path(), format!("{}\n", event("a1"))).expect("seed first");
|
||||
std::fs::write(second.path(), format!("{}\n", event("b1"))).expect("seed second");
|
||||
let mut reader = PublicationEvents::new(vec![first.path().to_owned(), second.path().to_owned()], "bucket", "object")
|
||||
.expect("prime both");
|
||||
append(first.path(), format!("{}\n", event("a2")).as_bytes());
|
||||
let mut expected = legacy(&std::fs::read(first.path()).expect("first bytes"));
|
||||
expected.extend(legacy(&std::fs::read(second.path()).expect("second bytes")));
|
||||
assert_eq!(reader.poll().expect("append first after second was read"), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_nested_escaped_and_duplicate_fields_match_the_old_reader() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let root = event("root");
|
||||
let mut nested = json!({"timestamp":root["timestamp"],"fields":root});
|
||||
nested["fields"]["extra"] = json!({"unicode":"é"});
|
||||
let null = json!({"fields":null,"event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object"});
|
||||
let escaped = root
|
||||
.to_string()
|
||||
.replace("object_namespace_lock", "\\u006fbject_namespace_lock");
|
||||
let duplicate = root.to_string().replacen("{", "{\"event\":\"earlier-value\",", 1);
|
||||
let raw = format!("{root}\n{nested}\n{null}\n{escaped}\n{duplicate}\nnot json\n{{\"fields\":[]}}\n");
|
||||
std::fs::write(file.path(), &raw).expect("mixed log");
|
||||
let reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("probe and fallback");
|
||||
assert_eq!(reader.cached(), legacy(raw.as_bytes()));
|
||||
assert_eq!(reader.cached().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_rows_and_split_utf8_wait_for_the_newline() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut row = event("partial");
|
||||
row["extra"] = json!("é");
|
||||
let raw = format!("{row}\n").into_bytes();
|
||||
let cut = raw.iter().position(|b| *b == 0xc3).expect("UTF-8 prefix") + 1;
|
||||
std::fs::write(file.path(), &raw[..cut]).expect("partial code point");
|
||||
let mut reader =
|
||||
PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("incomplete row is pending");
|
||||
assert!(reader.cached().is_empty());
|
||||
append(file.path(), &raw[cut..raw.len() - 1]);
|
||||
assert!(reader.poll().expect("complete JSON without delimiter").is_empty());
|
||||
append(file.path(), b"\n");
|
||||
assert_eq!(reader.poll().expect("published row"), legacy(&raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_is_sticky_even_after_the_file_grows_again() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
std::fs::write(file.path(), format!("{}\n", event("first"))).expect("seed log");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
|
||||
file.as_file().set_len(0).expect("truncate");
|
||||
assert!(reader.poll().is_err());
|
||||
append(file.path(), format!("{}\n", event("longer replacement")).as_bytes());
|
||||
assert!(reader.poll().is_err());
|
||||
assert_eq!(reader.cached().len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_replaced_path_does_not_switch_to_the_new_log() {
|
||||
let dir = tempfile::tempdir().expect("log dir");
|
||||
let path = dir.path().join("node.log");
|
||||
std::fs::write(&path, b"").expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![path.clone()], "bucket", "object").expect("open log");
|
||||
std::fs::rename(&path, dir.path().join("retired.log")).expect("replace log");
|
||||
std::fs::write(&path, format!("{}\n", event("other-generation"))).expect("replacement");
|
||||
assert!(reader.poll().is_err());
|
||||
assert!(reader.cached().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_and_oversized_or_excessive_records_fail_closed() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||
append(file.path(), b"\xff\n");
|
||||
assert!(reader.poll().is_err());
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||
append(file.path(), &vec![b'x'; MAX_LINE_BYTES + 1]);
|
||||
assert!(reader.poll().is_err());
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||
for _ in 0..=MAX_EVENTS {
|
||||
append(file.path(), format!("{}\n", event("attempt")).as_bytes());
|
||||
}
|
||||
assert!(reader.poll().is_err());
|
||||
assert_eq!(reader.cached().len(), MAX_EVENTS);
|
||||
assert!(reader.poll().is_err());
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||
let mut large = event("large");
|
||||
large["extra"] = json!("x".repeat(600 * 1024));
|
||||
for _ in 0..2 {
|
||||
append(file.path(), format!("{large}\n").as_bytes());
|
||||
}
|
||||
assert!(reader.poll().is_err());
|
||||
assert_eq!(reader.cached().len(), 1);
|
||||
assert!(reader.poll().is_err());
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,10 @@ pub(crate) struct TopologyRun {
|
||||
|
||||
impl TopologyRun {
|
||||
pub(crate) async fn start(label: &str) -> TestResult<Self> {
|
||||
Self::start_with_env(label, &[]).await
|
||||
}
|
||||
|
||||
pub(crate) async fn start_with_env(label: &str, extra_env: &[(&str, &str)]) -> TestResult<Self> {
|
||||
let run_name = format!("scanner-heal-topology-{label}-{}", Uuid::new_v4().simple());
|
||||
// CI uploads RUSTFS_E2E_LOG_DIR; keep node logs, barrier observations
|
||||
// and evidence there so a failed lane can be diagnosed.
|
||||
@@ -236,6 +240,7 @@ impl TopologyRun {
|
||||
let mut env: Vec<(&str, &str)> = TOPOLOGY_ENV.to_vec();
|
||||
env.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV);
|
||||
env.push((MOVEMENT_BARRIER_DIR_ENV, movement_dir_value.as_str()));
|
||||
env.extend_from_slice(extra_env);
|
||||
let mut dist = DistCluster::new_stopped_with_env(DistLayout::SingleNodeFourDrive, &env).await?;
|
||||
dist.capture_logs_under(&log_dir)?;
|
||||
dist.cluster.start().await?;
|
||||
@@ -329,6 +334,11 @@ impl FileBarrier {
|
||||
&self.nonce
|
||||
}
|
||||
|
||||
/// Keep the previous phase's release handle while arming a fresh nonce.
|
||||
pub(crate) fn next_phase(&self) -> Self {
|
||||
Self::new(self.dir.clone())
|
||||
}
|
||||
|
||||
fn write_atomic(&self, name: &str, body: &[u8]) -> TestResult {
|
||||
let tmp = self.dir.join(format!(".{name}.{}.tmp", Uuid::new_v4().simple()));
|
||||
std::fs::write(&tmp, body)?;
|
||||
@@ -360,6 +370,10 @@ impl FileBarrier {
|
||||
self.write_atomic(&format!("release.{}", self.nonce), b"io_error")
|
||||
}
|
||||
|
||||
pub(crate) fn release_movement(&self) -> TestResult {
|
||||
self.write_atomic(&format!("release.{}", self.nonce), b"continue")
|
||||
}
|
||||
|
||||
pub(crate) fn reached_observations(&self) -> TestResult<Vec<Value>> {
|
||||
let prefix = format!("reached.{}.", self.nonce);
|
||||
let mut observations = Vec::new();
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
// 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.
|
||||
|
||||
//! G-TIER: real mover processes contend with a held remote DELETE while a
|
||||
//! free version owns the old remote tuple beside a live transitioned version.
|
||||
//! The remote is the HTTP fake S3 target, not a provider interoperability test.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DRIVES_PER_NODE, TestResult, cluster_admin_ok, decommission_status_json, enable_versioning,
|
||||
rebalance_status_json, sha256_hex, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||
wait_for_rebalance_complete,
|
||||
};
|
||||
use super::publication_log::PublicationEvents;
|
||||
use super::scanner_heal_topology_fixture::{
|
||||
MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL, STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP,
|
||||
TopologyRun, assert_bucket_durability_strict, assert_process_durability_strict, require_test_hooks,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation, RequestRecord};
|
||||
use crate::storage_api::OBJECT_LOCK_TRACE_FILTER;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, Transition, TransitionStorageClass,
|
||||
};
|
||||
use http::Method;
|
||||
use rustfs_filemeta::{FileMeta, TransitionVersionState};
|
||||
use rustfs_utils::http::{SUFFIX_TRANSITION_TIER_DESTINATION_ID, get_consistent_str};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::time::{Instant, sleep, timeout, timeout_at};
|
||||
|
||||
const TIER: &str = "TOPOLOGYCOLD";
|
||||
const KEY: &str = "000-tier-owner.bin";
|
||||
const TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
// Free-version cleanup holds its locks for at most 30 s during remote DELETE.
|
||||
// Fail before that deadline instead of confusing a retry with the held attempt.
|
||||
const DELETE_OVERLAP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
struct RemoteTuple {
|
||||
bucket: String,
|
||||
key: String,
|
||||
version: String,
|
||||
tier: String,
|
||||
destination: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
struct Owner {
|
||||
pool: usize,
|
||||
drive: usize,
|
||||
version: String,
|
||||
free: bool,
|
||||
remote: RemoteTuple,
|
||||
}
|
||||
|
||||
fn owners(run: &TopologyRun, bucket: &str, remote_bucket: &str) -> TestResult<Vec<Owner>> {
|
||||
let mut owners = Vec::new();
|
||||
for node in &run.dist.cluster.nodes {
|
||||
for (drive, root) in node.data_dirs.iter().enumerate() {
|
||||
let bytes = match std::fs::read(Path::new(root).join(bucket).join(KEY).join("xl.meta")) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
for version in FileMeta::load(&bytes)?.get_file_info_versions(bucket, KEY, true)?.versions {
|
||||
if version.transition_status != "complete" && !version.tier_free_version() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(version.transition_version_state, TransitionVersionState::Exact);
|
||||
owners.push(Owner {
|
||||
pool: node.pool_idx,
|
||||
drive,
|
||||
version: version.version_id.ok_or("tier owner omitted its local version")?.to_string(),
|
||||
free: version.tier_free_version(),
|
||||
remote: RemoteTuple {
|
||||
bucket: remote_bucket.to_string(),
|
||||
key: version.transitioned_objname.clone(),
|
||||
version: version
|
||||
.transition_version
|
||||
.clone()
|
||||
.ok_or("tier owner omitted its remote version")?,
|
||||
tier: version.transition_tier.clone(),
|
||||
destination: get_consistent_str(&version.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID)
|
||||
.ok_or("tier owner omitted its destination identity")?
|
||||
.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(owners)
|
||||
}
|
||||
|
||||
fn assert_remote(target: &FakeS3Target, remote: &RemoteTuple, expected: &[u8]) -> TestResult {
|
||||
assert_eq!(target.stored_versions(&remote.bucket, &remote.key), vec![(remote.version.clone(), false)]);
|
||||
let (body, metadata) = target
|
||||
.stored_object(&remote.bucket, &remote.key)
|
||||
.ok_or("remote bytes are missing")?;
|
||||
assert_eq!(body.as_ref(), expected, "remote bytes changed for {remote:?}");
|
||||
assert_eq!(
|
||||
get_consistent_str(&metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID),
|
||||
Some(remote.destination.as_str())
|
||||
);
|
||||
assert_eq!(remote.tier, TIER);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn transition_version(
|
||||
run: &TopologyRun,
|
||||
target: &FakeS3Target,
|
||||
bucket: &str,
|
||||
remote_bucket: &str,
|
||||
body: &[u8],
|
||||
) -> TestResult<(String, RemoteTuple)> {
|
||||
let client = run.dist.client(0)?;
|
||||
let put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(KEY)
|
||||
.body(ByteStream::from(body.to_vec()))
|
||||
.send()
|
||||
.await?;
|
||||
let version = put.version_id().ok_or("versioned PUT omitted its version")?.to_string();
|
||||
cluster_admin_ok(
|
||||
&run.dist.cluster,
|
||||
Method::POST,
|
||||
&format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={KEY}&tier={TIER}&dryRun=false"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let deadline = Instant::now() + TRANSITION_TIMEOUT;
|
||||
loop {
|
||||
let current = owners(run, bucket, remote_bucket)?;
|
||||
let shards: Vec<_> = current
|
||||
.iter()
|
||||
.filter(|owner| owner.version == version && !owner.free)
|
||||
.collect();
|
||||
if let Some(first) = shards.first()
|
||||
&& shards.len() == DRIVES_PER_NODE
|
||||
&& shards
|
||||
.iter()
|
||||
.all(|owner| owner.pool == SEED_POOL && owner.remote == first.remote)
|
||||
{
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(KEY)
|
||||
.version_id(&version)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(head.storage_class().map(|class| class.as_str()), Some(TIER));
|
||||
assert_remote(target, &first.remote, body)?;
|
||||
return Ok((version, first.remote.clone()));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("transition of {bucket}/{KEY}@{version} did not reach every seed drive: {current:?}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn event_time(event: &Value) -> TestResult<OffsetDateTime> {
|
||||
Ok(OffsetDateTime::parse(
|
||||
event["timestamp"].as_str().ok_or("lock event omitted its timestamp")?,
|
||||
&Rfc3339,
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn pending_publication(events: &mut PublicationEvents, started: OffsetDateTime) -> TestResult<Value> {
|
||||
loop {
|
||||
let observed = events.poll()?;
|
||||
for event in observed {
|
||||
if event_time(event)? < started {
|
||||
continue;
|
||||
}
|
||||
assert_ne!(
|
||||
event["state"], "acquired",
|
||||
"mover acquired its publication lock while remote DELETE was held"
|
||||
);
|
||||
if event["state"] == "acquiring"
|
||||
&& !observed
|
||||
.iter()
|
||||
.any(|later| later["lock_attempt_id"] == event["lock_attempt_id"] && later["state"] != "acquiring")
|
||||
{
|
||||
assert!(event["lock_attempt_id"].as_str().is_some_and(|id| !id.is_empty()));
|
||||
return Ok(event.clone());
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn acquired_publication(events: &mut PublicationEvents, pending: &Value) -> TestResult<Value> {
|
||||
loop {
|
||||
for event in events.poll()? {
|
||||
if event["lock_attempt_id"] != pending["lock_attempt_id"] {
|
||||
continue;
|
||||
}
|
||||
match event["state"].as_str() {
|
||||
Some("acquired") => return Ok(event.clone()),
|
||||
Some("failed" | "cancelled") => return Err(format!("held publication attempt did not acquire: {event}").into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn deletes(target: &FakeS3Target, remote: &RemoteTuple) -> Vec<RequestRecord> {
|
||||
target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|request| {
|
||||
request.operation == Operation::DeleteObject
|
||||
&& request.bucket == remote.bucket
|
||||
&& request.key.as_deref() == Some(remote.key.as_str())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn verify_live(run: &TopologyRun, bucket: &str, live_version: &str, old_version: &str, body: &[u8]) -> TestResult {
|
||||
for client in run.dist.clients()? {
|
||||
let got = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(KEY)
|
||||
.version_id(live_version)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(got.body.collect().await?.into_bytes().as_ref(), body);
|
||||
let latest = client.head_object().bucket(bucket).key(KEY).send().await?;
|
||||
assert_eq!(latest.version_id(), Some(live_version));
|
||||
assert_eq!(latest.storage_class().map(|class| class.as_str()), Some(TIER));
|
||||
let deleted = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(KEY)
|
||||
.version_id(old_version)
|
||||
.send()
|
||||
.await;
|
||||
let error = deleted.expect_err("deleted source version must not be visible");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
let listed = client.list_object_versions().bucket(bucket).prefix(KEY).send().await?;
|
||||
assert_eq!(listed.versions().len(), 1, "free owners must not appear as object versions");
|
||||
assert_eq!(listed.versions()[0].version_id(), Some(live_version));
|
||||
assert!(
|
||||
listed.delete_markers().is_empty(),
|
||||
"moving the free owner must not create a delete marker"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn tier_delete_during_movement(decommission: bool) -> TestResult {
|
||||
init_logging();
|
||||
require_test_hooks();
|
||||
let operation = if decommission { "decommission" } else { "rebalance" };
|
||||
let target = FakeS3Target::start().await?;
|
||||
let remote_bucket = unique_bucket("sht-cold");
|
||||
target.create_bucket(&remote_bucket);
|
||||
let mut run = TopologyRun::start_with_env(
|
||||
&format!("tier-{operation}"),
|
||||
&[
|
||||
("RUSTFS_TIER_RUSTFS_ALLOW_LOOPBACK_ENDPOINT", "true"),
|
||||
("RUSTFS_OBJECT_LOCK_DIAG_ENABLE", "true"),
|
||||
("RUST_LOG", OBJECT_LOCK_TRACE_FILTER),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let tier = json!({
|
||||
"type": "rustfs",
|
||||
"rustfs": {
|
||||
"name": TIER, "endpoint": target.endpoint(), "accessKey": FAKE_ACCESS_KEY,
|
||||
"secretKey": FAKE_SECRET_KEY, "bucket": remote_bucket, "prefix": "",
|
||||
"region": "us-east-1", "storageClass": ""
|
||||
}
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
match cluster_admin_ok(&run.dist.cluster, Method::PUT, "/rustfs/admin/v3/tier", Some(tier.to_string())).await {
|
||||
Ok(_) => break,
|
||||
Err(_) if Instant::now() < deadline => sleep(Duration::from_millis(250)).await,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let bucket = unique_bucket("sht-tier");
|
||||
run.dist.create_bucket(&bucket).await?;
|
||||
let client = run.dist.client(0)?;
|
||||
enable_versioning(&client, &bucket).await?;
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("tier-owner")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix(KEY).build())
|
||||
.transitions(
|
||||
Transition::builder()
|
||||
.days(0)
|
||||
.storage_class(TransitionStorageClass::from(TIER))
|
||||
.build(),
|
||||
)
|
||||
.build()?;
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(&bucket)
|
||||
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(rule).build()?)
|
||||
.send()
|
||||
.await?;
|
||||
let old_body = vec![0x31; 4096];
|
||||
let live_body = vec![0x92; 8192];
|
||||
let (old_version, old_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &old_body).await?;
|
||||
let (live_version, live_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &live_body).await?;
|
||||
assert_ne!(
|
||||
old_remote.key, live_remote.key,
|
||||
"the obsolete and live versions need distinct remote tuples"
|
||||
);
|
||||
// Keep the seed pool above the rebalance goal until the first key is visited.
|
||||
// These local objects are outside the transition rule's prefix.
|
||||
for index in 0..16 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(format!("zz-load/{index:02}.bin"))
|
||||
.body(ByteStream::from(vec![index as u8; 1024 * 1024]))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||
run.dist.expand_to_four_pools().await?;
|
||||
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||
let seeded = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert_eq!(seeded.len(), 2 * DRIVES_PER_NODE);
|
||||
assert!(seeded.iter().all(|owner| owner.pool == SEED_POOL && !owner.free));
|
||||
|
||||
let mut decommission_start = Box::pin(start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID));
|
||||
let mut startup = Value::Null;
|
||||
if decommission {
|
||||
run.movement_barrier.arm_movement(
|
||||
"decommission_start",
|
||||
".rustfs.sys",
|
||||
"pool.bin",
|
||||
STAGE_AFTER_RESPONSIBILITY_PERSISTED,
|
||||
)?;
|
||||
let requested_at = OffsetDateTime::now_utc();
|
||||
let preparation_started = Instant::now();
|
||||
let reached = tokio::select! {
|
||||
reached = run.movement_barrier.wait_reached(MOVEMENT_COMPLETE_TIMEOUT) => {
|
||||
reached.map_err(|error| format!(
|
||||
"G-TIER decommission start preparation failed before DELETE: {error}"
|
||||
))?
|
||||
}
|
||||
result = &mut decommission_start => {
|
||||
return Err(format!(
|
||||
"G-TIER decommission start preparation returned before durable-start barrier {}: {result:?}",
|
||||
run.movement_barrier.nonce()
|
||||
).into());
|
||||
}
|
||||
};
|
||||
startup = json!({
|
||||
"requested_at": requested_at.format(&Rfc3339)?,
|
||||
"parked_at": OffsetDateTime::now_utc().format(&Rfc3339)?,
|
||||
"preparation_elapsed_ms": preparation_started.elapsed().as_millis(),
|
||||
"barrier": reached,
|
||||
});
|
||||
}
|
||||
// The durable-start barrier has released the movement writer but has not
|
||||
// started a worker. Install a separate release nonce before holding DELETE.
|
||||
let cleanup_barrier = run.movement_barrier.next_phase();
|
||||
cleanup_barrier.arm_movement(operation, &bucket, KEY, STAGE_BEFORE_SOURCE_CLEANUP)?;
|
||||
let mut publication_events = PublicationEvents::new(run.node_log_paths(), &bucket, KEY)?;
|
||||
let mut delete_gate = target.hold_delete_object(&remote_bucket, &old_remote.key);
|
||||
// The public DELETE persists a free owner and enqueues its remote cleanup.
|
||||
let client = run.dist.client(0)?;
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(&bucket)
|
||||
.key(KEY)
|
||||
.version_id(&old_version)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("G-TIER {operation} public DELETE before remote gate failed: {error}; startup={startup}"))?;
|
||||
timeout(TRANSITION_TIMEOUT, delete_gate.wait_until_entered())
|
||||
.await
|
||||
.map_err(|error| format!("G-TIER {operation} DELETE gate entry timed out: {error}; startup={startup}"))?
|
||||
.map_err(|error| format!("G-TIER {operation} DELETE gate entry failed: {error}; startup={startup}"))?;
|
||||
let delete_gate_entered_at = OffsetDateTime::now_utc();
|
||||
let overlap_started = Instant::now();
|
||||
let overlap_deadline = overlap_started + DELETE_OVERLAP_TIMEOUT;
|
||||
let held = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert_eq!(held.len(), 2 * DRIVES_PER_NODE);
|
||||
let old_owners: Vec<_> = held.iter().filter(|owner| owner.remote == old_remote).collect();
|
||||
assert_eq!(old_owners.len(), DRIVES_PER_NODE);
|
||||
assert!(
|
||||
old_owners
|
||||
.iter()
|
||||
.all(|owner| owner.pool == SEED_POOL && owner.free && owner.version != old_version)
|
||||
);
|
||||
assert_eq!(old_owners.iter().map(|owner| &owner.version).collect::<BTreeSet<_>>().len(), 1);
|
||||
assert_eq!(
|
||||
held.iter()
|
||||
.filter(|owner| {
|
||||
owner.pool == SEED_POOL && !owner.free && owner.version == live_version && owner.remote == live_remote
|
||||
})
|
||||
.count(),
|
||||
DRIVES_PER_NODE
|
||||
);
|
||||
assert_remote(&target, &old_remote, &old_body)?;
|
||||
assert_remote(&target, &live_remote, &live_body)?;
|
||||
let held_deletes = deletes(&target, &old_remote);
|
||||
assert!(!held_deletes.is_empty());
|
||||
assert!(held_deletes.iter().all(|request| {
|
||||
request.prepared_response.is_none() && request.version_id.as_deref() == Some(old_remote.version.as_str())
|
||||
}));
|
||||
assert!(deletes(&target, &live_remote).is_empty());
|
||||
|
||||
let movement_started = OffsetDateTime::now_utc();
|
||||
if decommission {
|
||||
startup["released_at"] = json!(movement_started.format(&Rfc3339)?);
|
||||
run.movement_barrier.release_movement()?;
|
||||
}
|
||||
let rebalance_id = timeout_at(overlap_deadline, async {
|
||||
if decommission {
|
||||
decommission_start.as_mut().await?;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(None)
|
||||
} else {
|
||||
Ok(Some(start_rebalance(&run.dist.cluster).await?))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!("G-TIER {operation} start acknowledgment timed out within DELETE overlap: {error}; startup={startup}")
|
||||
})?
|
||||
.map_err(|error| {
|
||||
format!("G-TIER {operation} start acknowledgment failed within DELETE overlap: {error}; startup={startup}")
|
||||
})?;
|
||||
drop(decommission_start);
|
||||
let movement_acknowledged_at = OffsetDateTime::now_utc();
|
||||
let pending = timeout_at(overlap_deadline, pending_publication(&mut publication_events, movement_started))
|
||||
.await
|
||||
.map_err(|error| format!(
|
||||
"G-TIER {operation} publication pending timed out within DELETE overlap: {error}; startup={startup}; lock_events={:?}; cleanup_barrier={:?}",
|
||||
publication_events.cached(), cleanup_barrier.reached_observations()
|
||||
))?
|
||||
.map_err(|error| format!("G-TIER {operation} publication pending failed within DELETE overlap: {error}"))?;
|
||||
// Positive gate entry, exact physical ownership and the pending lock attempt
|
||||
// establish the overlap; an absent log alone is not an ordering assertion.
|
||||
assert_eq!(owners(&run, &bucket, &remote_bucket)?, held);
|
||||
assert_remote(&target, &old_remote, &old_body)?;
|
||||
let released_at = OffsetDateTime::now_utc();
|
||||
let delete_overlap_elapsed_ms = overlap_started.elapsed().as_millis();
|
||||
drop(delete_gate);
|
||||
let acquired = timeout(DELETE_OVERLAP_TIMEOUT, acquired_publication(&mut publication_events, &pending))
|
||||
.await
|
||||
.map_err(|error| format!(
|
||||
"G-TIER {operation} publication acquired after DELETE release timed out: {error}; pending={pending}; lock_events={:?}; cleanup_barrier={:?}",
|
||||
publication_events.cached(), cleanup_barrier.reached_observations()
|
||||
))?
|
||||
.map_err(|error| format!("G-TIER {operation} publication acquired after DELETE release failed: {error}"))?;
|
||||
assert!(event_time(&pending)? >= movement_started && event_time(&pending)? < released_at);
|
||||
assert!(
|
||||
event_time(&acquired)? >= released_at,
|
||||
"the same publication attempt must acquire after DELETE is released"
|
||||
);
|
||||
drop(publication_events);
|
||||
let reached = cleanup_barrier
|
||||
.wait_reached_during(
|
||||
&run.dist.cluster,
|
||||
match rebalance_id.as_deref() {
|
||||
Some(id) => MovementOperation::Rebalance(id),
|
||||
None => MovementOperation::Decommission(DECOMMISSION_POOL_ID),
|
||||
},
|
||||
MOVEMENT_COMPLETE_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("G-TIER {operation} before-source-cleanup barrier failed: {error}"))?;
|
||||
let published = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
|
||||
);
|
||||
let publication_pools: BTreeSet<_> = published.iter().map(|owner| owner.pool).collect();
|
||||
assert!(publication_pools.contains(&SEED_POOL) && publication_pools.iter().any(|pool| *pool != SEED_POOL));
|
||||
assert_eq!(published.len(), publication_pools.len() * DRIVES_PER_NODE);
|
||||
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
|
||||
assert_remote(&target, &live_remote, &live_body)?;
|
||||
cleanup_barrier.release_movement()?;
|
||||
cleanup_barrier.disarm()?;
|
||||
let completion = match rebalance_id.as_deref() {
|
||||
Some(id) => {
|
||||
wait_for_rebalance_complete(&run.dist.cluster, id, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||
rebalance_status_json(&run.dist.cluster).await?
|
||||
}
|
||||
None => {
|
||||
wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||
decommission_status_json(&run.dist.cluster).await?
|
||||
}
|
||||
};
|
||||
let settled = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert_eq!(settled.len(), DRIVES_PER_NODE);
|
||||
assert_eq!(settled.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
|
||||
assert!(
|
||||
settled
|
||||
.iter()
|
||||
.all(|owner| owner.pool != SEED_POOL && !owner.free && owner.remote == live_remote)
|
||||
);
|
||||
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
|
||||
if decommission {
|
||||
run.dist.remove_retired_pools_and_restart(&[SEED_POOL]).await?;
|
||||
} else {
|
||||
run.dist.restart_current_binary_gracefully().await?;
|
||||
}
|
||||
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
|
||||
let restarted = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert_eq!(restarted.len(), DRIVES_PER_NODE);
|
||||
assert_eq!(restarted.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
|
||||
assert!(
|
||||
restarted
|
||||
.iter()
|
||||
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
|
||||
);
|
||||
assert_remote(&target, &live_remote, &live_body)?;
|
||||
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
|
||||
assert!(
|
||||
deletes(&target, &live_remote).is_empty(),
|
||||
"the live transitioned tuple must never receive DELETE"
|
||||
);
|
||||
let remote_deletes = deletes(&target, &old_remote);
|
||||
assert!(!remote_deletes.is_empty());
|
||||
assert!(
|
||||
remote_deletes
|
||||
.iter()
|
||||
.all(|request| request.version_id.as_deref() == Some(old_remote.version.as_str()))
|
||||
);
|
||||
let successful = remote_deletes
|
||||
.iter()
|
||||
.filter_map(|request| request.prepared_response.as_ref())
|
||||
.find(|response| response.status == 204)
|
||||
.ok_or("old remote tuple never completed DELETE")?;
|
||||
let deleted_at_ms = i128::from(
|
||||
successful
|
||||
.prepared_at_unix_millis
|
||||
.ok_or("DELETE response omitted its timestamp")?,
|
||||
);
|
||||
assert!(deleted_at_ms >= released_at.unix_timestamp_nanos() / 1_000_000);
|
||||
assert!(event_time(&acquired)?.unix_timestamp_nanos() / 1_000_000 >= deleted_at_ms);
|
||||
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
|
||||
run.write_evidence(
|
||||
&format!("g-tier-{operation}"),
|
||||
&json!({
|
||||
"gate": "G-TIER", "operation": operation, "rebalance_id": rebalance_id,
|
||||
"startup": startup,
|
||||
"delete_gate_entered_at": delete_gate_entered_at.format(&Rfc3339)?,
|
||||
"movement_overlap_started_at": movement_started.format(&Rfc3339)?,
|
||||
"movement_acknowledged_at": movement_acknowledged_at.format(&Rfc3339)?,
|
||||
"delete_overlap_timeout_ms": DELETE_OVERLAP_TIMEOUT.as_millis(),
|
||||
"delete_overlap_elapsed_ms": delete_overlap_elapsed_ms,
|
||||
"remote_backend": "HTTP fake_s3_target with versioning enabled",
|
||||
"ordering": "remote DELETE held, mover publication waiting, DELETE released, same publication acquired",
|
||||
"bucket": bucket, "key": KEY, "bucket_durability": bucket_durability,
|
||||
"process_durability_strict_nodes": strict_nodes,
|
||||
"old_version": old_version, "old_remote": old_remote, "old_sha256": sha256_hex(&old_body),
|
||||
"live_version": live_version, "live_remote": live_remote, "live_sha256": sha256_hex(&live_body),
|
||||
"owners_while_delete_held": held, "publication_waiting": pending, "publication_acquired": acquired,
|
||||
"held_delete_sequences": held_deletes.iter().map(|request| request.sequence).collect::<Vec<_>>(),
|
||||
"delete_released_at": released_at.format(&Rfc3339)?, "remote_delete_completed_at_unix_millis": deleted_at_ms,
|
||||
"barrier": reached, "owners_before_source_cleanup": published, "owners_after_movement": settled,
|
||||
"owners_after_restart": restarted, "completion": completion,
|
||||
"old_remote_delete_attempts": remote_deletes.len(), "live_remote_delete_attempts": 0,
|
||||
"source_readback_nodes": run.dist.cluster.nodes.len(),
|
||||
}),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_rebalance() -> TestResult {
|
||||
tier_delete_during_movement(false).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_decommission() -> TestResult {
|
||||
tier_delete_during_movement(true).await
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -47,6 +47,9 @@ struct UpgradeSeed {
|
||||
history_bucket: String,
|
||||
history_key: &'static str,
|
||||
history_body: Vec<u8>,
|
||||
multipart_upload: String,
|
||||
multipart_etag: String,
|
||||
multipart_body: Vec<u8>,
|
||||
versioned_bucket: String,
|
||||
versioned_key: &'static str,
|
||||
version1: String,
|
||||
@@ -60,7 +63,7 @@ struct UpgradeSeed {
|
||||
iam_secret: &'static str,
|
||||
}
|
||||
|
||||
fn source_binary() -> TestResult<PathBuf> {
|
||||
pub(super) fn source_binary() -> TestResult<PathBuf> {
|
||||
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
|
||||
format!(
|
||||
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
|
||||
@@ -241,6 +244,25 @@ async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
|
||||
let history_body = b"written by the previous 4-node release".to_vec();
|
||||
put_object(&root, &history_bucket, history_key, history_body.clone()).await?;
|
||||
|
||||
let multipart_body = vec![0x5a; 4096];
|
||||
let multipart = root
|
||||
.create_multipart_upload()
|
||||
.bucket(&history_bucket)
|
||||
.key("legacy-multipart.bin")
|
||||
.send()
|
||||
.await?;
|
||||
let multipart_upload = multipart.upload_id().ok_or("legacy multipart upload ID missing")?.to_owned();
|
||||
let part = root
|
||||
.upload_part()
|
||||
.bucket(&history_bucket)
|
||||
.key("legacy-multipart.bin")
|
||||
.upload_id(&multipart_upload)
|
||||
.part_number(1)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(multipart_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let multipart_etag = part.e_tag().ok_or("legacy multipart part ETag missing")?.to_owned();
|
||||
|
||||
let versioned_key = "versioned-history.txt";
|
||||
let version1_body = b"version-one-before-upgrade".to_vec();
|
||||
let version1 = root
|
||||
@@ -278,6 +300,9 @@ async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
|
||||
history_bucket,
|
||||
history_key,
|
||||
history_body,
|
||||
multipart_upload,
|
||||
multipart_etag,
|
||||
multipart_body,
|
||||
versioned_bucket,
|
||||
versioned_key,
|
||||
version1,
|
||||
@@ -304,6 +329,21 @@ async fn assert_history_and_iam(dist: &DistCluster, seed: &UpgradeSeed, context:
|
||||
)
|
||||
.await?;
|
||||
assert_object_bytes(&root_a, &seed.history_bucket, seed.history_key, &seed.history_body).await?;
|
||||
// During mixed-version operation this is read-only; mutation resumes only
|
||||
// after the whole fleet is upgraded.
|
||||
for client in [&root_a, &root_b] {
|
||||
let listed = client
|
||||
.list_parts()
|
||||
.bucket(&seed.history_bucket)
|
||||
.key("legacy-multipart.bin")
|
||||
.upload_id(&seed.multipart_upload)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(listed.parts().len(), 1, "{context}: legacy part listing");
|
||||
assert_eq!(listed.parts()[0].part_number(), Some(1));
|
||||
assert_eq!(listed.parts()[0].size(), Some(4096));
|
||||
assert_eq!(listed.parts()[0].e_tag(), Some(seed.multipart_etag.as_str()));
|
||||
}
|
||||
|
||||
let v1 = root_b
|
||||
.get_object()
|
||||
@@ -387,17 +427,146 @@ async fn assert_history_and_iam(dist: &DistCluster, seed: &UpgradeSeed, context:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn compare_degraded_legacy_list_parts(dist: &mut DistCluster, seed: &UpgradeSeed, previous: &Path) -> TestResult {
|
||||
let decoded = String::from_utf8(base64_simd::URL_SAFE_NO_PAD.decode_to_vec(seed.multipart_upload.as_bytes())?)?;
|
||||
let (_, upload_dir) = decoded.split_once('.').ok_or("legacy upload ID lacks deployment delimiter")?;
|
||||
let hash = sha256_hex(format!("{}/legacy-multipart.bin", seed.history_bucket).as_bytes());
|
||||
let root = format!("{hash}/{upload_dir}");
|
||||
let mut held = Vec::new();
|
||||
let mut original_files = Vec::new();
|
||||
for (index, node) in dist.cluster.nodes.iter().enumerate() {
|
||||
let path = Path::new(&node.data_dirs[0])
|
||||
.join(crate::storage_api::multipart_fence_acceptance::RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&root);
|
||||
let entries = std::fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
|
||||
let dirs = entries
|
||||
.into_iter()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|entry| entry.join("part.1").is_file() && entry.join("part.1.meta").is_file())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(dirs.len(), 1, "each configured disk must hold the original part before injection");
|
||||
let source_root = dirs[0].parent().ok_or("part directory has no upload root")?;
|
||||
for path in [
|
||||
source_root.join("xl.meta"),
|
||||
dirs[0].join("part.1"),
|
||||
dirs[0].join("part.1.meta"),
|
||||
] {
|
||||
original_files.push((path.clone(), std::fs::read(path)?));
|
||||
}
|
||||
if index < 3 {
|
||||
held.push((dirs[0].clone(), dirs[0].with_extension("held-for-mixed-test")));
|
||||
}
|
||||
}
|
||||
dist.cluster.stop_node(3)?;
|
||||
for (original, temporary) in &held {
|
||||
std::fs::rename(original, temporary)?;
|
||||
}
|
||||
|
||||
let clients = dist.clients()?;
|
||||
let current = tokio::time::timeout(
|
||||
CREDENTIAL_TIMEOUT,
|
||||
clients[0]
|
||||
.list_parts()
|
||||
.bucket(&seed.history_bucket)
|
||||
.key("legacy-multipart.bin")
|
||||
.upload_id(&seed.multipart_upload)
|
||||
.send(),
|
||||
)
|
||||
.await?;
|
||||
let legacy = tokio::time::timeout(
|
||||
CREDENTIAL_TIMEOUT,
|
||||
clients[1]
|
||||
.list_parts()
|
||||
.bucket(&seed.history_bucket)
|
||||
.key("legacy-multipart.bin")
|
||||
.upload_id(&seed.multipart_upload)
|
||||
.send(),
|
||||
)
|
||||
.await?;
|
||||
for (original, temporary) in &held {
|
||||
std::fs::rename(temporary, original)?;
|
||||
}
|
||||
// Three online disks retain metadata quorum but have no part directory;
|
||||
// the fourth disk is offline. A legacy empty response is not a proof that
|
||||
// the unavailable participant has no part.
|
||||
let legacy = legacy.expect("previous coordinator should expose its legacy empty-list behavior");
|
||||
assert!(legacy.parts().is_empty());
|
||||
let current = current.expect_err("current coordinator must not infer empty from missing directories and offline disks");
|
||||
assert_eq!(current.raw_response().expect("current S3 response").status().as_u16(), 503);
|
||||
assert_eq!(current.as_service_error().and_then(|error| error.meta().code()), Some("SlowDownRead"));
|
||||
dist.cluster.start_node_from_binary(3, previous).await?;
|
||||
wait_for_upgrade_write_readiness(&clients, &seed.history_bucket, "mixed-list-restored", CREDENTIAL_TIMEOUT).await?;
|
||||
for (path, before) in original_files {
|
||||
assert_eq!(
|
||||
std::fs::read(&path)?,
|
||||
before,
|
||||
"the fault must preserve every original source file: {path:?}"
|
||||
);
|
||||
}
|
||||
for client in [&clients[0], &clients[1]] {
|
||||
let listed = client
|
||||
.list_parts()
|
||||
.bucket(&seed.history_bucket)
|
||||
.key("legacy-multipart.bin")
|
||||
.upload_id(&seed.multipart_upload)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(listed.parts().len(), 1);
|
||||
assert_eq!(listed.parts()[0].e_tag(), Some(seed.multipart_etag.as_str()));
|
||||
}
|
||||
eprintln!(
|
||||
"MPU mixed degraded: one offline disk, three missing part directories; previous returned empty, current returned 503 SlowDownRead; original part restored on both"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn complete_legacy_multipart(dist: &DistCluster, seed: &UpgradeSeed) -> TestResult {
|
||||
// Native legacy-source upgrade needs every original participant, not just
|
||||
// the ordinary three-of-four write quorum proven by the earlier probes.
|
||||
let clients = dist.clients()?;
|
||||
crate::upgrade_compatibility_test::wait_for_drive_readmission(
|
||||
&clients,
|
||||
&seed.history_bucket,
|
||||
"legacy-multipart-all-drives",
|
||||
CREDENTIAL_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let root = &clients[0];
|
||||
root.complete_multipart_upload()
|
||||
.bucket(&seed.history_bucket)
|
||||
.key("legacy-multipart.bin")
|
||||
.upload_id(&seed.multipart_upload)
|
||||
.multipart_upload(
|
||||
aws_sdk_s3::types::CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
aws_sdk_s3::types::CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(&seed.multipart_etag)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
for client in &clients {
|
||||
assert_object_bytes(client, &seed.history_bucket, "legacy-multipart.bin", &seed.multipart_body).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_direct_upgrade_preserves_history_and_iam_credentials() -> TestResult {
|
||||
init_logging();
|
||||
let previous = source_binary()?;
|
||||
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
|
||||
dist.cluster.set_env("RUSTFS_STORAGE_CLASS_RRS", "EC:0");
|
||||
capture_upgrade_logs(&mut dist, "direct-upgrade")?;
|
||||
dist.start_from_binary(&previous).await?;
|
||||
|
||||
let seed = seed_history_and_iam(&dist).await?;
|
||||
dist.restart_with_current_binary().await?;
|
||||
assert_history_and_iam(&dist, &seed, "direct").await?;
|
||||
complete_legacy_multipart(&dist, &seed).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -419,6 +588,7 @@ async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> Te
|
||||
init_logging();
|
||||
let previous = source_binary()?;
|
||||
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
|
||||
dist.cluster.set_env("RUSTFS_STORAGE_CLASS_RRS", "EC:0");
|
||||
capture_upgrade_logs(&mut dist, "rolling-upgrade")?;
|
||||
dist.start_from_binary(&previous).await?;
|
||||
|
||||
@@ -426,6 +596,7 @@ async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> Te
|
||||
|
||||
replace_node_and_wait_for_write_readiness(&mut dist, &seed, 0).await?;
|
||||
assert_history_and_iam(&dist, &seed, "one-current-node").await?;
|
||||
compare_degraded_legacy_list_parts(&mut dist, &seed, &previous).await?;
|
||||
|
||||
for node_idx in [1, 2] {
|
||||
replace_node_and_wait_for_write_readiness(&mut dist, &seed, node_idx).await?;
|
||||
@@ -434,5 +605,6 @@ async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> Te
|
||||
|
||||
replace_node_and_wait_for_write_readiness(&mut dist, &seed, 3).await?;
|
||||
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
|
||||
complete_legacy_multipart(&dist, &seed).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ use crate::common::init_logging;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_explicit_delete_marker_batch_is_immediately_visible() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("explicit-marker-batch");
|
||||
crate::delete_objects_versioning_test::assert_explicit_marker_batch_cleanup(&dist.clients()?, &bucket).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -487,36 +487,43 @@ impl JournaledHeaders {
|
||||
struct ControlState {
|
||||
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
||||
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
||||
held_get: Option<HeldGetObject>,
|
||||
held_get: Option<HeldObject>,
|
||||
held_delete: Option<HeldObject>,
|
||||
requests: VecDeque<RequestRecord>,
|
||||
next_sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HeldGetObject {
|
||||
struct HeldObject {
|
||||
bucket: String,
|
||||
key: String,
|
||||
entered: watch::Sender<usize>,
|
||||
released: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
/// Holds every GET of one object, including retries, until this guard is dropped.
|
||||
#[must_use = "dropping the guard releases the held GET requests"]
|
||||
pub struct GetObjectGate {
|
||||
/// Holds every matching operation, including retries, until this guard is dropped.
|
||||
#[must_use = "dropping the guard releases the held requests"]
|
||||
pub struct ObjectGate {
|
||||
control: Arc<Mutex<ControlState>>,
|
||||
operation: Operation,
|
||||
entered: watch::Receiver<usize>,
|
||||
released: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl GetObjectGate {
|
||||
impl ObjectGate {
|
||||
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
|
||||
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GetObjectGate {
|
||||
impl Drop for ObjectGate {
|
||||
fn drop(&mut self) {
|
||||
lock(&self.control).held_get = None;
|
||||
let mut state = lock(&self.control);
|
||||
match self.operation {
|
||||
Operation::GetObject => state.held_get = None,
|
||||
Operation::DeleteObject => state.held_delete = None,
|
||||
_ => unreachable!("only GET and DELETE have object gates"),
|
||||
}
|
||||
self.released.send_replace(true);
|
||||
}
|
||||
}
|
||||
@@ -1108,24 +1115,40 @@ impl FakeS3Target {
|
||||
}
|
||||
|
||||
/// Hold one exact bucket/key before any GET response can reach the client.
|
||||
/// The fixture supports one live gate; request and connection deadlines still apply.
|
||||
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
|
||||
/// The fixture supports one live GET gate; request and connection deadlines still apply.
|
||||
pub fn hold_get_object(&self, bucket: &str, key: &str) -> ObjectGate {
|
||||
self.hold_object_operation(Operation::GetObject, bucket, key)
|
||||
}
|
||||
|
||||
/// Hold one exact bucket/key before DELETE changes any stored version.
|
||||
/// The fixture supports one live DELETE gate; request deadlines still apply.
|
||||
pub fn hold_delete_object(&self, bucket: &str, key: &str) -> ObjectGate {
|
||||
self.hold_object_operation(Operation::DeleteObject, bucket, key)
|
||||
}
|
||||
|
||||
fn hold_object_operation(&self, operation: Operation, bucket: &str, key: &str) -> ObjectGate {
|
||||
assert!(
|
||||
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
|
||||
"held GET identifiers exceed the fixture limit"
|
||||
"held object identifiers exceed the fixture limit"
|
||||
);
|
||||
let mut state = lock(&self.control);
|
||||
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
|
||||
let slot = match operation {
|
||||
Operation::GetObject => &mut state.held_get,
|
||||
Operation::DeleteObject => &mut state.held_delete,
|
||||
_ => unreachable!("only GET and DELETE have object gates"),
|
||||
};
|
||||
assert!(slot.is_none(), "fake target already holds a {operation:?} gate");
|
||||
let (entered, entered_rx) = watch::channel(0);
|
||||
let (released, released_rx) = watch::channel(false);
|
||||
state.held_get = Some(HeldGetObject {
|
||||
*slot = Some(HeldObject {
|
||||
bucket: bucket.to_string(),
|
||||
key: key.to_string(),
|
||||
entered,
|
||||
released: released_rx,
|
||||
});
|
||||
GetObjectGate {
|
||||
ObjectGate {
|
||||
control: Arc::clone(&self.control),
|
||||
operation,
|
||||
entered: entered_rx,
|
||||
released,
|
||||
}
|
||||
@@ -2795,6 +2818,15 @@ impl S3 for FakeBackend {
|
||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||
let headers = req.headers;
|
||||
let input = req.input;
|
||||
let held_delete = lock(&self.control)
|
||||
.held_delete
|
||||
.as_ref()
|
||||
.filter(|held| held.bucket == input.bucket && held.key == input.key)
|
||||
.cloned();
|
||||
if let Some(mut held) = held_delete {
|
||||
held.entered.send_modify(|count| *count += 1);
|
||||
let _ = held.released.wait_for(|released| *released).await;
|
||||
}
|
||||
let mut state = lock(&self.store);
|
||||
let Some(versioned) = state.buckets.get(&input.bucket).map(|bucket| bucket.versioned) else {
|
||||
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
|
||||
@@ -3436,6 +3468,68 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_object_gate_retains_exact_version_until_release() -> Result<(), BoxError> {
|
||||
let target = FakeS3Target::start().await?;
|
||||
let bucket = "delete-gated-target";
|
||||
target.create_bucket(bucket);
|
||||
for key in ["held", "unrelated"] {
|
||||
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
|
||||
}
|
||||
let versions = target.stored_versions(bucket, "held");
|
||||
let mut gate = target.hold_delete_object(bucket, "held");
|
||||
let client = client(&target);
|
||||
let held_client = client.clone();
|
||||
let version = versions[0].0.clone();
|
||||
let mut requests = tokio::task::JoinSet::new();
|
||||
requests.spawn(async move {
|
||||
held_client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("held")
|
||||
.version_id(version)
|
||||
.send()
|
||||
.await
|
||||
});
|
||||
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
|
||||
assert_eq!(
|
||||
target.stored_versions(bucket, "held"),
|
||||
versions,
|
||||
"the gate must precede the storage mutation"
|
||||
);
|
||||
assert!(requests.try_join_next().is_none());
|
||||
let held = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.operation == Operation::DeleteObject && record.key.as_deref() == Some("held"))
|
||||
.expect("the held DELETE must be journaled");
|
||||
assert_eq!(held.version_id.as_deref(), Some(versions[0].0.as_str()));
|
||||
assert_eq!(held.prepared_response, None);
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("unrelated")
|
||||
.version_id(&target.stored_versions(bucket, "unrelated")[0].0)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(target.stored_versions(bucket, "unrelated").is_empty());
|
||||
assert_eq!(target.stored_versions(bucket, "held"), versions);
|
||||
drop(gate);
|
||||
timeout(Duration::from_secs(2), requests.join_next())
|
||||
.await?
|
||||
.expect("held DELETE task")??;
|
||||
assert!(target.stored_versions(bucket, "held").is_empty());
|
||||
let released = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.sequence == held.sequence)
|
||||
.and_then(|record| record.prepared_response)
|
||||
.expect("released DELETE response");
|
||||
assert_eq!(released.status, 204);
|
||||
assert_eq!(released.version_id.as_deref(), Some(versions[0].0.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
|
||||
@@ -21,7 +21,7 @@ mod tests {
|
||||
};
|
||||
use crate::common::{
|
||||
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
|
||||
init_logging, rustfs_binary_path,
|
||||
init_logging, local_http_client, rustfs_binary_path,
|
||||
};
|
||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||
use aws_sdk_s3::{
|
||||
@@ -53,6 +53,196 @@ mod tests {
|
||||
expected_pool_metadata: Option<VersionShardCensus>,
|
||||
}
|
||||
|
||||
fn pool_metadata_matches_republished_manifest(actual: &VersionShardCensus, expected: &VersionShardCensus) -> bool {
|
||||
for data_dir in [&actual.data_dir, &expected.data_dir] {
|
||||
if data_dir
|
||||
.as_deref()
|
||||
.and_then(|value| uuid::Uuid::parse_str(value).ok())
|
||||
.is_none_or(|value| value.is_nil())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// The owning CAS republishes the same canonical payload with a fresh
|
||||
// physical identity. Every shard and layout field must still match.
|
||||
let mut republished = actual.clone();
|
||||
republished.data_dir = expected.data_dir.clone();
|
||||
republished.matches_manifest(expected)
|
||||
}
|
||||
|
||||
fn pool_metadata_node_health_is_ready(status: u16, payload: &serde_json::Value) -> bool {
|
||||
(200..300).contains(&status)
|
||||
&& payload["ready"] == true
|
||||
&& payload["details"]["poolMetadata"]["ready"] == true
|
||||
&& payload["details"]["storage"]["readQuorum"] == true
|
||||
&& payload["details"]["storage"]["writeQuorum"] == true
|
||||
}
|
||||
|
||||
async fn all_nodes_pool_metadata_readiness(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
deadline: Instant,
|
||||
) -> (bool, serde_json::Value) {
|
||||
let http = local_http_client();
|
||||
let mut ready = !cluster.nodes.is_empty();
|
||||
let mut observations = Vec::with_capacity(cluster.nodes.len());
|
||||
for (node, environment) in cluster.nodes.iter().enumerate() {
|
||||
let response = tokio::time::timeout_at(deadline, async {
|
||||
let response = http
|
||||
.get(format!("{}/health/ready", environment.url))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status().as_u16();
|
||||
let payload = response.json::<serde_json::Value>().await?;
|
||||
Ok::<_, reqwest::Error>((status, payload))
|
||||
})
|
||||
.await;
|
||||
let observation = match response {
|
||||
Ok(Ok((status, payload))) => {
|
||||
let node_ready = pool_metadata_node_health_is_ready(status, &payload);
|
||||
ready &= node_ready;
|
||||
serde_json::json!({"node": node, "status": status, "ready": node_ready, "payload": payload})
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
ready = false;
|
||||
serde_json::json!({"node": node, "error": error.to_string()})
|
||||
}
|
||||
Err(_) => {
|
||||
ready = false;
|
||||
serde_json::json!({"node": node, "error": "pool metadata health deadline elapsed"})
|
||||
}
|
||||
};
|
||||
observations.push(observation);
|
||||
}
|
||||
let deadline_elapsed = Instant::now() >= deadline;
|
||||
(
|
||||
ready && !deadline_elapsed,
|
||||
serde_json::json!({"deadline_elapsed": deadline_elapsed, "nodes": observations}),
|
||||
)
|
||||
}
|
||||
|
||||
fn pool_metadata_manifest_fixture() -> VersionShardCensus {
|
||||
VersionShardCensus {
|
||||
version_id: None,
|
||||
has_xl_meta: true,
|
||||
data_dir: Some(uuid::Uuid::from_u128(1).to_string()),
|
||||
erasure_index: Some(1),
|
||||
erasure_distribution: Some(vec![1, 2, 3, 4]),
|
||||
data_blocks: Some(2),
|
||||
parity_blocks: Some(2),
|
||||
expected_part_numbers: Default::default(),
|
||||
present_part_fingerprints: Default::default(),
|
||||
inline_data_fingerprint: Some(crate::chaos::PartShardFingerprint {
|
||||
size: 7,
|
||||
sha256: sha256_hex(b"payload"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_republication_allows_only_valid_data_dir_change() {
|
||||
let expected = pool_metadata_manifest_fixture();
|
||||
let mut actual = expected.clone();
|
||||
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
|
||||
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
|
||||
assert!(
|
||||
!actual.matches_manifest(&expected),
|
||||
"ordinary object manifests must retain their original identity"
|
||||
);
|
||||
for invalid in [
|
||||
None,
|
||||
Some(String::new()),
|
||||
Some("invalid".to_string()),
|
||||
Some(uuid::Uuid::nil().to_string()),
|
||||
] {
|
||||
let mut changed = actual.clone();
|
||||
changed.data_dir = invalid.clone();
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed_expected = expected.clone();
|
||||
changed_expected.data_dir = invalid;
|
||||
assert!(!pool_metadata_matches_republished_manifest(&actual, &changed_expected));
|
||||
}
|
||||
let mut changed = actual.clone();
|
||||
changed
|
||||
.inline_data_fingerprint
|
||||
.as_mut()
|
||||
.expect("fixture has inline payload")
|
||||
.sha256 = sha256_hex(b"corrupt");
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.erasure_index = Some(2);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.erasure_distribution = Some(vec![2, 1, 3, 4]);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.data_blocks = Some(1);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.parity_blocks = Some(3);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.version_id = Some(uuid::Uuid::from_u128(3).to_string());
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual;
|
||||
changed.has_xl_meta = false;
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_republication_requires_exact_complete_part_fingerprints() {
|
||||
let mut expected = pool_metadata_manifest_fixture();
|
||||
expected.inline_data_fingerprint = None;
|
||||
expected.expected_part_numbers.insert(1);
|
||||
expected.present_part_fingerprints.insert(
|
||||
1,
|
||||
crate::chaos::PartShardFingerprint {
|
||||
size: 7,
|
||||
sha256: sha256_hex(b"payload"),
|
||||
},
|
||||
);
|
||||
let mut actual = expected.clone();
|
||||
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
|
||||
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.present_part_fingerprints.remove(&1);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual;
|
||||
changed
|
||||
.present_part_fingerprints
|
||||
.get_mut(&1)
|
||||
.expect("fixture has part 1")
|
||||
.sha256 = sha256_hex(b"corrupt");
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_node_health_requires_explicit_metadata_and_both_quorums() {
|
||||
let expected = serde_json::json!({"ready": true, "details": {
|
||||
"poolMetadata": {"ready": true},
|
||||
"storage": {"readQuorum": true, "writeQuorum": true}
|
||||
}});
|
||||
assert!(pool_metadata_node_health_is_ready(200, &expected));
|
||||
assert!(!pool_metadata_node_health_is_ready(503, &expected));
|
||||
assert!(!pool_metadata_node_health_is_ready(200, &serde_json::json!({"ready": true})));
|
||||
for path in [
|
||||
"/ready",
|
||||
"/details/poolMetadata/ready",
|
||||
"/details/storage/readQuorum",
|
||||
"/details/storage/writeQuorum",
|
||||
] {
|
||||
for value in [
|
||||
serde_json::Value::Bool(false),
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!("true"),
|
||||
] {
|
||||
let mut changed = expected.clone();
|
||||
*changed.pointer_mut(path).expect("fixture contains required health field") = value;
|
||||
assert!(!pool_metadata_node_health_is_ready(200, &changed), "required health field {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EvidenceBuild {
|
||||
sha256: String,
|
||||
@@ -1206,7 +1396,9 @@ mod tests {
|
||||
let interval = interval
|
||||
.filter(|interval| *interval != 0)
|
||||
.ok_or("scanner schedule has no positive interval")?;
|
||||
let next_cycle = cycle_end
|
||||
// A new leader may already be sleeping before completing its first cycle.
|
||||
let schedule_start = if cycle_end == 0 { now } else { cycle_end };
|
||||
let next_cycle = schedule_start
|
||||
.checked_add(interval)
|
||||
.ok_or("scanner schedule timestamp overflows")?;
|
||||
let budget = next_cycle
|
||||
@@ -1223,6 +1415,11 @@ mod tests {
|
||||
Ok(Duration::from_secs(259)),
|
||||
);
|
||||
assert_eq!(scanner_cycle_completion_budget(100, Some(1), 110, false), Ok(Duration::from_secs(60)));
|
||||
// A newly elected leader can advertise a sleep before its first completed cycle.
|
||||
assert_eq!(
|
||||
scanner_cycle_completion_budget(0, Some(289), 1_792_913_211, false),
|
||||
Ok(Duration::from_secs(349)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1243,7 +1440,7 @@ mod tests {
|
||||
previous_cycle_end: u64,
|
||||
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||
let mut deadline = Instant::now() + Duration::from_secs(60);
|
||||
let mut schedule_accounted = false;
|
||||
let mut accounted_schedules = HashSet::new();
|
||||
loop {
|
||||
let mut latest_cycle_end = 0;
|
||||
let mut versions_observed = false;
|
||||
@@ -1290,15 +1487,27 @@ mod tests {
|
||||
return Err("scanner cycle timestamp is in the future".into());
|
||||
}
|
||||
let schedule = &status["cycle_schedule"];
|
||||
if !schedule_accounted && schedule["execution_role"].as_str() == Some("leader") {
|
||||
if schedule["execution_role"].as_str() == Some("leader") {
|
||||
let active = metrics["current_cycle_active"]
|
||||
.as_bool()
|
||||
.ok_or("scanner status is missing its active-cycle flag")?;
|
||||
if active || (cycle_end != 0 && schedule["effective_interval_available"].as_bool() == Some(true)) {
|
||||
let interval = schedule["effective_interval_seconds"].as_u64();
|
||||
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
|
||||
deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
|
||||
schedule_accounted = true;
|
||||
if active || schedule["effective_interval_available"].as_bool() == Some(true) {
|
||||
let interval = if active {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
schedule["effective_interval_seconds"]
|
||||
.as_u64()
|
||||
.ok_or("scanner schedule is missing its effective interval")?,
|
||||
)
|
||||
};
|
||||
// Reconsider a changed leader or retry plan, but never renew
|
||||
// the deadline on repeated observations of the same sleep.
|
||||
if accounted_schedules.insert((node_index, cycle_end, interval)) {
|
||||
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
|
||||
let scheduled_deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
|
||||
deadline = deadline.max(scheduled_deadline);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Superseded walks complete all sets while withholding quota authority.
|
||||
@@ -1507,6 +1716,15 @@ mod tests {
|
||||
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", background_enabled.to_string());
|
||||
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", background_enabled.to_string());
|
||||
cluster.set_env("RUSTFS_SCANNER_ENABLED", background_enabled.to_string());
|
||||
let bucket = "heal-restart-during-rebuild";
|
||||
let outage_key_prefix = "cluster/written-while-node-down-";
|
||||
if !background_enabled {
|
||||
// With MRF disabled, an outage PUT still hands its missing shard to a
|
||||
// best-effort repair on the PUT coordinator. That repair retries until
|
||||
// the target rejoins, so suppress it to keep the explicit root heal
|
||||
// the only owner of the outage object.
|
||||
cluster.set_env("RUSTFS_E2E_SUPPRESS_RENAME_TAIL_HEAL_PREFIX", format!("{bucket}/{outage_key_prefix}"));
|
||||
}
|
||||
if background_enabled {
|
||||
// Only the scanner cadence is accelerated. Keep normal Heal
|
||||
// concurrency and every automatic recovery owner enabled.
|
||||
@@ -1539,7 +1757,6 @@ mod tests {
|
||||
cluster.start_with_binary(&server_binary).await?;
|
||||
let clients = cluster.create_all_clients()?;
|
||||
|
||||
let bucket = "heal-restart-during-rebuild";
|
||||
clients[0].create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
let ReplacementDriveSelection {
|
||||
@@ -1667,7 +1884,7 @@ mod tests {
|
||||
let mut replacement_outage_erasure_index = None;
|
||||
let mut rejected_outage_keys = Vec::new();
|
||||
for attempt in 0..max_outage_write_attempts {
|
||||
let candidate_key = format!("cluster/written-while-node-down-{attempt:04}.bin");
|
||||
let candidate_key = format!("{outage_key_prefix}{attempt:04}.bin");
|
||||
let put_result = timeout(
|
||||
Duration::from_secs(30),
|
||||
clients[2]
|
||||
@@ -1777,25 +1994,6 @@ mod tests {
|
||||
}
|
||||
|
||||
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
if !background_enabled {
|
||||
// The PUT path may have admitted a direct Internal object repair while
|
||||
// node 1 was offline. Cancel the isolated bucket path before the target
|
||||
// returns; otherwise it could rebuild the outage object and invalidate
|
||||
// the explicit-root ownership assertion below.
|
||||
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
|
||||
let (cancel_status, cancel_body) = admin_request(
|
||||
&cluster.nodes[0].url,
|
||||
Method::POST,
|
||||
&cancel_outage_heal_path,
|
||||
Some(heal_body.to_string()),
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
if !cancel_status.is_success() {
|
||||
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the partial-repair checkpoint stable across readiness and admin
|
||||
// requests. Endpoint-blackhole tests must prove their own network stall.
|
||||
@@ -2213,6 +2411,7 @@ mod tests {
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(180);
|
||||
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
|
||||
let mut last_pool_metadata_readiness = serde_json::Value::Null;
|
||||
loop {
|
||||
if restart_recovery_admin_after_failure && !recovery_admin_task_restarted {
|
||||
let task_state = timeout(
|
||||
@@ -2254,14 +2453,23 @@ mod tests {
|
||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
|
||||
let pool_metadata_matches = match &expected_pool_metadata {
|
||||
Some(expected) => {
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
|
||||
.matches_manifest(expected)
|
||||
let actual =
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||
pool_metadata_matches_republished_manifest(&actual, expected)
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
let pool_metadata_ready = if expected_pool_metadata.is_some() && pool_metadata_matches {
|
||||
let (ready, observation) = all_nodes_pool_metadata_readiness(&cluster, heal_deadline).await;
|
||||
last_pool_metadata_readiness = observation;
|
||||
ready
|
||||
} else {
|
||||
expected_pool_metadata.is_none()
|
||||
};
|
||||
if matching == expected_manifests.len()
|
||||
&& (!outage_target_manifest_required || outage_census.is_complete())
|
||||
&& pool_metadata_matches
|
||||
&& pool_metadata_ready
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -2271,6 +2479,15 @@ mod tests {
|
||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
|
||||
let pool_metadata =
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||
// This post-deadline observation is diagnostic only. It cannot
|
||||
// make an expired recovery attempt satisfy the original budget.
|
||||
let pool_metadata_readiness = if expected_pool_metadata.is_some() {
|
||||
let (_, observation) =
|
||||
all_nodes_pool_metadata_readiness(&cluster, Instant::now() + Duration::from_secs(3)).await;
|
||||
serde_json::json!({"last_eligible_check": last_pool_metadata_readiness, "timeout_check": observation})
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
};
|
||||
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
|
||||
.await
|
||||
.unwrap_or_else(|err| format!("status request failed: {err}"));
|
||||
@@ -2294,7 +2511,7 @@ mod tests {
|
||||
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
|
||||
};
|
||||
return Err(format!(
|
||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata_expected={expected_pool_metadata:?}, pool_metadata={pool_metadata:?}, pool_metadata_readiness={pool_metadata_readiness}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
||||
expected_manifests.len()
|
||||
)
|
||||
.into());
|
||||
|
||||
@@ -45,6 +45,9 @@ pub mod on_demand_migration;
|
||||
#[cfg(test)]
|
||||
pub mod fault_proxy;
|
||||
|
||||
#[cfg(test)]
|
||||
mod rpc_fault_proxy;
|
||||
|
||||
// Reliability tests built on the fault-injection harness
|
||||
#[cfg(test)]
|
||||
mod reliability_disk_fault_test;
|
||||
|
||||
@@ -38,6 +38,8 @@ mod tests {
|
||||
use tracing::info;
|
||||
|
||||
const PAGINATION_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
// Bound queued durable commits on the single fixture disk under CI I/O pressure.
|
||||
const FIXTURE_PUT_CONCURRENCY: usize = 4;
|
||||
|
||||
// Fixture population can require thousands of durable PUTs. Bound the
|
||||
// listing traversal itself with one deadline shared by every page.
|
||||
@@ -564,7 +566,7 @@ mod tests {
|
||||
let object_count = 1002;
|
||||
// Finish every durable PUT before listing, with the same bounded fanout as the delimiter fixtures.
|
||||
stream::iter(0..object_count)
|
||||
.for_each_concurrent(16, |i| {
|
||||
.for_each_concurrent(FIXTURE_PUT_CONCURRENCY, |i| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let key = format!("object{i:04}.txt");
|
||||
@@ -575,7 +577,7 @@ mod tests {
|
||||
.body(ByteStream::from_static(b"test content"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err:?}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -822,7 +824,7 @@ mod tests {
|
||||
.flat_map(|dir| (0..10).map(move |file| format!("dir-{dir:03}/file{file:02}.txt")))
|
||||
.collect();
|
||||
stream::iter(&all_keys)
|
||||
.for_each_concurrent(16, |key| {
|
||||
.for_each_concurrent(FIXTURE_PUT_CONCURRENCY, |key| {
|
||||
let client = &client;
|
||||
async move {
|
||||
client
|
||||
@@ -832,7 +834,7 @@ mod tests {
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err:?}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -1055,7 +1057,7 @@ mod tests {
|
||||
let files_per_dir = 100;
|
||||
let fixture_started = std::time::Instant::now();
|
||||
stream::iter((0..dir_count).flat_map(|d| (0..files_per_dir).map(move |f| (d, f))))
|
||||
.for_each_concurrent(16, |(d, f)| {
|
||||
.for_each_concurrent(FIXTURE_PUT_CONCURRENCY, |(d, f)| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let key = format!("dir-{d:02}/file{f:03}.txt");
|
||||
@@ -1066,7 +1068,7 @@ mod tests {
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err:?}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -1129,12 +1131,14 @@ mod tests {
|
||||
/// and the marker plus the cursor tag.
|
||||
///
|
||||
/// Bug Reference: backlog#1047
|
||||
/// The V2 continuation token appends a `[rustfs_cache:...]` cursor tag to the
|
||||
/// last returned key. The orchestration layer passed that raw string to
|
||||
/// `forward_past`, so any key whose byte after the shared stem sorts below
|
||||
/// `[` (0x5B) — `.`, `/`, `-`, digits, uppercase — was silently dropped on
|
||||
/// the next page: with keys `a`, `a.txt`, `zz` and max_keys=1, page 2
|
||||
/// returned `zz` and `a.txt` was never listed.
|
||||
/// Earlier releases appended a `[rustfs_cache:...]` cursor tag to the last
|
||||
/// returned key inside the V2 continuation token. The orchestration layer
|
||||
/// passed that raw string to `forward_past`, so any key whose byte after the
|
||||
/// shared stem sorts below `[` (0x5B) — `.`, `/`, `-`, digits, uppercase —
|
||||
/// was silently dropped on the next page: with keys `a`, `a.txt`, `zz` and
|
||||
/// max_keys=1, page 2 returned `zz` and `a.txt` was never listed. The token
|
||||
/// is now base64 of the plain key, and this test keeps guarding the
|
||||
/// continuation boundary.
|
||||
#[tokio::test]
|
||||
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
|
||||
init_logging();
|
||||
|
||||
@@ -230,6 +230,302 @@ fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Sign the data and terminating chunks independently of the server decoder.
|
||||
fn encode_signed_aws_chunked(signer: &SigV4, headers: &SignedHeaders, decoded: &[u8], trailer: bool) -> Vec<u8> {
|
||||
let mut previous = headers
|
||||
.authorization
|
||||
.rsplit_once("Signature=")
|
||||
.expect("seed signature")
|
||||
.1
|
||||
.to_string();
|
||||
let mut encoded = Vec::new();
|
||||
for chunk in [decoded, b"".as_slice()] {
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256-PAYLOAD\n{}\n{}\n{previous}\n{}\n{}",
|
||||
headers.amz_date,
|
||||
get_scope(REGION, signer.time, "s3"),
|
||||
sha256_hex(b""),
|
||||
sha256_hex(chunk),
|
||||
);
|
||||
previous = get_signature(get_signing_key(&signer.secret_key, REGION, signer.time, "s3"), &string_to_sign);
|
||||
encoded.extend_from_slice(format!("{:x};chunk-signature={previous}\r\n", chunk.len()).as_bytes());
|
||||
encoded.extend_from_slice(chunk);
|
||||
encoded.extend_from_slice(b"\r\n");
|
||||
}
|
||||
if trailer {
|
||||
let checksum = sha256_base64(decoded);
|
||||
let canonical = format!("x-amz-checksum-sha256:{checksum}\n");
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256-TRAILER\n{}\n{}\n{previous}\n{}",
|
||||
headers.amz_date,
|
||||
get_scope(REGION, signer.time, "s3"),
|
||||
sha256_hex(canonical.as_bytes()),
|
||||
);
|
||||
let signature = get_signature(get_signing_key(&signer.secret_key, REGION, signer.time, "s3"), &string_to_sign);
|
||||
encoded.extend_from_slice(
|
||||
format!("x-amz-checksum-sha256:{checksum}\r\nx-amz-trailer-signature:{signature}\r\n\r\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Exercise decoder failures through both storage publication boundaries.
|
||||
#[tokio::test]
|
||||
async fn aws_chunked_named_errors_reject_put_and_upload_part() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
let client = env.create_s3_client();
|
||||
let decoded = b"private-chunked-fixture-body";
|
||||
let mut failures = Vec::new();
|
||||
for multipart in [false, true] {
|
||||
for (case, expected_status, expected_code) in [
|
||||
("signature", 403, "SignatureDoesNotMatch"),
|
||||
("final-signature", 403, "SignatureDoesNotMatch"),
|
||||
("format", 400, "IncompleteBody"),
|
||||
("metadata-at-limit", 400, "IncompleteBody"),
|
||||
("metadata-over-limit", 400, "EntityTooLarge"),
|
||||
("chunk-over-limit", 400, "EntityTooLarge"),
|
||||
("trailers-over-limit", 400, "IncompleteBody"),
|
||||
("too-many-trailers", 400, "IncompleteBody"),
|
||||
] {
|
||||
let key = format!("chunked-{case}-{multipart}.bin");
|
||||
let upload_id = if multipart {
|
||||
Some(
|
||||
client
|
||||
.create_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await?
|
||||
.upload_id()
|
||||
.ok_or("missing upload ID")?
|
||||
.to_owned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let query = upload_id
|
||||
.as_ref()
|
||||
.map(|id| format!("partNumber=1&uploadId={}", urlencoding::encode(id)))
|
||||
.unwrap_or_default();
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let target = if query.is_empty() {
|
||||
path.clone()
|
||||
} else {
|
||||
format!("{path}?{query}")
|
||||
};
|
||||
let signer = SigV4::new(&env);
|
||||
let unsigned = matches!(case, "trailers-over-limit" | "too-many-trailers");
|
||||
let mode = if unsigned {
|
||||
UNSIGNED_PAYLOAD_TRAILER
|
||||
} else {
|
||||
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
};
|
||||
let length = decoded.len().to_string();
|
||||
let mut extra = vec![
|
||||
("content-encoding", "aws-chunked"),
|
||||
("x-amz-decoded-content-length", length.as_str()),
|
||||
];
|
||||
if unsigned {
|
||||
extra.push(("x-amz-trailer", "x-amz-checksum-sha256"));
|
||||
}
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, &query, mode, &extra);
|
||||
let encoded = match case {
|
||||
"signature" | "final-signature" => {
|
||||
let mut body = encode_signed_aws_chunked(&signer, &headers, decoded, false);
|
||||
let offset = if case == "signature" {
|
||||
body.windows(2).position(|w| w == b"\r\n").expect("chunk header") + 2
|
||||
} else {
|
||||
body.len() - 5
|
||||
};
|
||||
body[offset] = if body[offset] == b'a' { b'b' } else { b'a' };
|
||||
body
|
||||
}
|
||||
"format" => b"invalid-chunk-size\r\n".to_vec(),
|
||||
"metadata-at-limit" | "metadata-over-limit" => {
|
||||
let size = if case == "metadata-at-limit" { 1024 } else { 1025 };
|
||||
let mut body = vec![b'z'; size - 2];
|
||||
body.extend_from_slice(b"\r\n");
|
||||
body
|
||||
}
|
||||
"chunk-over-limit" => format!("10000001;chunk-signature={}\r\n", "0".repeat(64)).into_bytes(),
|
||||
"trailers-over-limit" | "too-many-trailers" => {
|
||||
let mut body = format!("{:x}\r\n", decoded.len()).into_bytes();
|
||||
body.extend_from_slice(decoded);
|
||||
body.extend_from_slice(b"\r\n0\r\n");
|
||||
if case == "trailers-over-limit" {
|
||||
body.extend_from_slice(format!("x-private:{}\r\n\r\n", "v".repeat(16385 - 14)).as_bytes());
|
||||
} else {
|
||||
body.extend_from_slice("x-private:v\r\n".repeat(101).as_bytes());
|
||||
body.extend_from_slice(b"\r\n");
|
||||
}
|
||||
body
|
||||
}
|
||||
_ => unreachable!("all fixtures are enumerated"),
|
||||
};
|
||||
let mut request = local_http_client()
|
||||
.put(format!("{}{target}", env.url))
|
||||
.header("authorization", &headers.authorization)
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("x-amz-date", &headers.amz_date);
|
||||
for (name, value) in extra {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = request
|
||||
.body(encoded)
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await?;
|
||||
eprintln!("aws-chunked {case} multipart={multipart}: HTTP {status}, {body}");
|
||||
if status != expected_status || !body.contains(&format!("<Code>{expected_code}</Code>")) {
|
||||
failures.push(format!(
|
||||
"{case} multipart={multipart}: expected {expected_status} {expected_code}, got {status} {body}"
|
||||
));
|
||||
}
|
||||
for private in [
|
||||
"private-chunked-fixture-body",
|
||||
"chunk-signature=",
|
||||
"AwsChunkedStreamError",
|
||||
"x-private",
|
||||
] {
|
||||
if body.contains(private) {
|
||||
failures.push(format!("{case}: response exposed {private}"));
|
||||
}
|
||||
}
|
||||
if let Some(id) = upload_id {
|
||||
let parts = client.list_parts().bucket(BUCKET).key(&key).upload_id(&id).send().await?;
|
||||
assert!(parts.parts().is_empty(), "{case}: rejected UploadPart published a part");
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.upload_id(id)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let absent = client
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a rejected body must not publish an object");
|
||||
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aws_chunked_valid_signed_and_trailer_boundaries_succeed() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
let client = env.create_s3_client();
|
||||
let decoded = b"valid-chunked-control";
|
||||
for (case, multipart) in ["signed", "signed-trailer", "trailer-size-limit", "trailer-count-limit"]
|
||||
.into_iter()
|
||||
.flat_map(|case| [false, true].map(|multipart| (case, multipart)))
|
||||
{
|
||||
let key = format!("{case}-{multipart}");
|
||||
let upload_id = if multipart {
|
||||
Some(
|
||||
client
|
||||
.create_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await?
|
||||
.upload_id()
|
||||
.ok_or("missing upload ID")?
|
||||
.to_owned(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let query = upload_id
|
||||
.as_ref()
|
||||
.map(|id| format!("partNumber=1&uploadId={}", urlencoding::encode(id)))
|
||||
.unwrap_or_default();
|
||||
let signer = SigV4::new(&env);
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let target = if query.is_empty() {
|
||||
path.clone()
|
||||
} else {
|
||||
format!("{path}?{query}")
|
||||
};
|
||||
let length = decoded.len().to_string();
|
||||
let mode = match case {
|
||||
"signed" => "STREAMING-AWS4-HMAC-SHA256-PAYLOAD",
|
||||
"signed-trailer" => "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER",
|
||||
_ => UNSIGNED_PAYLOAD_TRAILER,
|
||||
};
|
||||
let mut extra = vec![
|
||||
("content-encoding", "aws-chunked"),
|
||||
("x-amz-decoded-content-length", length.as_str()),
|
||||
];
|
||||
if case != "signed" {
|
||||
extra.push(("x-amz-trailer", "x-amz-checksum-sha256"));
|
||||
}
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, &query, mode, &extra);
|
||||
let mut encoded = if case.starts_with("signed") {
|
||||
encode_signed_aws_chunked(&signer, &headers, decoded, case == "signed-trailer")
|
||||
} else {
|
||||
encode_unsigned_aws_chunked_with_sha256_trailer(decoded)
|
||||
};
|
||||
if !case.starts_with("signed") {
|
||||
encoded.truncate(encoded.len() - 2);
|
||||
if case == "trailer-size-limit" {
|
||||
let checksum_line_size = format!("x-amz-checksum-sha256:{}\r\n", sha256_base64(decoded)).len();
|
||||
encoded.extend_from_slice(format!("x-padding:{}\r\n", "v".repeat(16384 - checksum_line_size - 14)).as_bytes());
|
||||
} else {
|
||||
encoded.extend_from_slice("x-padding:v\r\n".repeat(99).as_bytes());
|
||||
}
|
||||
encoded.extend_from_slice(b"\r\n");
|
||||
}
|
||||
let mut request = local_http_client()
|
||||
.put(format!("{}{target}", env.url))
|
||||
.header("authorization", &headers.authorization)
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("x-amz-date", &headers.amz_date);
|
||||
for (name, value) in extra {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = request
|
||||
.body(encoded)
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let etag = response
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let body = response.text().await?;
|
||||
assert_eq!(status.as_u16(), 200, "{case}: {body}");
|
||||
if let Some(id) = upload_id {
|
||||
let part = aws_sdk_s3::types::CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(etag.ok_or("successful UploadPart omitted ETag")?)
|
||||
.build();
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.upload_id(id)
|
||||
.multipart_upload(aws_sdk_s3::types::CompletedMultipartUpload::builder().parts(part).build())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let object = client.get_object().bucket(BUCKET).key(&key).send().await?;
|
||||
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), decoded, "{case}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Positive control: a correctly hand-signed request must succeed. Without
|
||||
/// this, every negative assertion below could pass for the wrong reason (a
|
||||
/// broken signer that never produces a valid signature).
|
||||
|
||||
@@ -19,6 +19,19 @@ use http::{Method, StatusCode};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// A successful reporting response can still have unknown scanner usage.
|
||||
fn reported_quota_usage(response: &serde_json::Value) -> std::io::Result<Option<u64>> {
|
||||
let value = response
|
||||
.get("current_usage")
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "quota response is missing current_usage"))?;
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
value.as_u64().map(Some).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "quota current_usage is not an unsigned integer or null")
|
||||
})
|
||||
}
|
||||
|
||||
/// Test environment setup for quota tests
|
||||
pub struct QuotaTestEnv {
|
||||
pub env: RustFSTestEnvironment,
|
||||
@@ -87,35 +100,69 @@ impl QuotaTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bucket_quota_stats(&self) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, self.bucket_name);
|
||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to get quota stats: {}", response).into())
|
||||
} else {
|
||||
Ok(serde_json::from_str(&response)?)
|
||||
async fn wait_for_usage_response(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<String>,
|
||||
expected_usage: u64,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let convergence = async {
|
||||
loop {
|
||||
let (status, response) = admin_request(
|
||||
&self.env.url,
|
||||
method.clone(),
|
||||
path,
|
||||
body.clone(),
|
||||
&self.env.access_key,
|
||||
&self.env.secret_key,
|
||||
)
|
||||
.await?;
|
||||
if status.is_success() {
|
||||
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||
if let Some(usage) = reported_quota_usage(&response)?
|
||||
&& usage == expected_usage
|
||||
{
|
||||
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(response);
|
||||
}
|
||||
} else if status != StatusCode::SERVICE_UNAVAILABLE {
|
||||
return Err(format!("quota usage request failed for {path}: {status} {response}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(30), convergence).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("quota usage did not converge to {expected_usage} for {path} within 30 seconds").into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_bucket_quota(
|
||||
async fn wait_for_bucket_quota_stats(
|
||||
&self,
|
||||
bucket: &str,
|
||||
expected_usage: u64,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.wait_for_usage_response(Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None, expected_usage)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_bucket_quota_check(
|
||||
&self,
|
||||
operation_type: &str,
|
||||
operation_size: u64,
|
||||
expected_usage: u64,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota-check/{}", self.env.url, self.bucket_name);
|
||||
let check_request = serde_json::json!({
|
||||
"operation_type": operation_type,
|
||||
"operation_size": operation_size
|
||||
});
|
||||
|
||||
let response = awscurl_post(&url, &check_request.to_string(), &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to check quota: {}", response).into())
|
||||
} else {
|
||||
Ok(serde_json::from_str(&response)?)
|
||||
}
|
||||
self.wait_for_usage_response(
|
||||
Method::POST,
|
||||
&format!("/rustfs/admin/v3/quota-check/{}", self.bucket_name),
|
||||
Some(check_request.to_string()),
|
||||
expected_usage,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upload_object(&self, key: &str, size_bytes: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -145,25 +192,9 @@ impl QuotaTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bucket_usage(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let stats = self.get_bucket_quota_stats().await?;
|
||||
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let convergence = async {
|
||||
loop {
|
||||
let usage = self.get_bucket_usage().await?;
|
||||
if usage == expected {
|
||||
return Ok::<u64, Box<dyn std::error::Error + Send + Sync>>(usage);
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(30), convergence).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("bucket usage did not converge to {expected} bytes within 30 seconds").into()),
|
||||
}
|
||||
let stats = self.wait_for_bucket_quota_stats(&self.bucket_name, expected).await?;
|
||||
reported_quota_usage(&stats)?.ok_or_else(|| "converged quota usage is missing".into())
|
||||
}
|
||||
|
||||
pub async fn set_bucket_quota_for(
|
||||
@@ -171,8 +202,6 @@ impl QuotaTestEnv {
|
||||
bucket: &str,
|
||||
quota_bytes: u64,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.wait_for_quota_usage_for(bucket).await?;
|
||||
|
||||
let quota_path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||
let quota_config = serde_json::json!({
|
||||
"quota": quota_bytes,
|
||||
@@ -206,46 +235,6 @@ impl QuotaTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_quota_usage_for(&self, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let stats_path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
|
||||
let readiness = async {
|
||||
loop {
|
||||
let (status, response) =
|
||||
admin_request(&self.env.url, Method::GET, &stats_path, None, &self.env.access_key, &self.env.secret_key)
|
||||
.await?;
|
||||
if status.is_success() {
|
||||
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
|
||||
}
|
||||
if status != StatusCode::SERVICE_UNAVAILABLE {
|
||||
return Err(format!("quota usage readiness failed for {bucket}: {status} {response}").into());
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(30), readiness).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("quota usage did not become authoritative for {bucket} within 30 seconds").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get bucket quota statistics for specific bucket
|
||||
pub async fn get_bucket_quota_stats_for(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!("Getting quota stats for bucket: {}", bucket);
|
||||
|
||||
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, bucket);
|
||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to get quota stats: {}", response).into())
|
||||
} else {
|
||||
let stats: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload an object to specific bucket
|
||||
pub async fn upload_object_to_bucket(
|
||||
&self,
|
||||
@@ -270,6 +259,132 @@ impl QuotaTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod usage_response_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[test]
|
||||
fn unknown_usage_is_not_zero() {
|
||||
assert_eq!(
|
||||
reported_quota_usage(&serde_json::json!({"current_usage": null})).expect("valid unknown usage"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
reported_quota_usage(&serde_json::json!({"current_usage": 0})).expect("known zero usage"),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_invalid_usage_is_not_readiness() {
|
||||
for response in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"current_usage": -1}),
|
||||
serde_json::json!({"current_usage": "0"}),
|
||||
serde_json::json!({"current_usage": 1.5}),
|
||||
serde_json::json!({"current_usage": false}),
|
||||
] {
|
||||
let error = reported_quota_usage(&response).expect_err("malformed usage must fail");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
}
|
||||
|
||||
async fn mock_environment(
|
||||
responses: Vec<(StatusCode, &'static str)>,
|
||||
) -> Result<(QuotaTestEnv, JoinSet<()>), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let address = listener.local_addr()?;
|
||||
let mut tasks = JoinSet::new();
|
||||
tasks.spawn(async move {
|
||||
for (status, body) in responses {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept quota reporting request");
|
||||
let mut request = Vec::new();
|
||||
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let read = stream.read(&mut buffer).await.expect("read reporting request");
|
||||
assert!(read > 0 && request.len() + read <= 8192, "bounded HTTP request headers");
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
let response = format!(
|
||||
"HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
status.as_u16(),
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("write quota reporting response");
|
||||
stream.shutdown().await.expect("close reporting response");
|
||||
}
|
||||
});
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.url = format!("http://{address}");
|
||||
let client = env.create_s3_client();
|
||||
Ok((
|
||||
QuotaTestEnv {
|
||||
env,
|
||||
client,
|
||||
bucket_name: "quota-reporting-fixture".to_owned(),
|
||||
},
|
||||
tasks,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reporting_waits_for_known_matching_usage_without_selecting_allowed()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (env, mut tasks) = mock_environment(vec![
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "unavailable"),
|
||||
(StatusCode::OK, r#"{"current_usage":null}"#),
|
||||
(StatusCode::OK, r#"{"current_usage":1}"#),
|
||||
(StatusCode::OK, r#"{"current_usage":0,"allowed":false}"#),
|
||||
])
|
||||
.await?;
|
||||
let response = env.wait_for_usage_response(Method::GET, "/reporting", None, 0).await?;
|
||||
assert_eq!(response["current_usage"], 0);
|
||||
assert_eq!(response["allowed"], false, "readiness must not select a desired admission outcome");
|
||||
timeout(Duration::from_secs(1), tasks.join_next())
|
||||
.await?
|
||||
.expect("reporting mock task")
|
||||
.expect("all responses consumed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reporting_rejects_malformed_success_instead_of_retrying() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (env, mut tasks) = mock_environment(vec![(StatusCode::OK, r#"{"current_usage":"unknown"}"#)]).await?;
|
||||
let error = env
|
||||
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
|
||||
.await
|
||||
.expect_err("malformed usage must fail");
|
||||
assert!(error.to_string().contains("not an unsigned integer or null"));
|
||||
timeout(Duration::from_secs(1), tasks.join_next())
|
||||
.await?
|
||||
.expect("reporting mock task")
|
||||
.expect("response consumed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reporting_does_not_retry_authorization_failure() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (env, mut tasks) = mock_environment(vec![(StatusCode::FORBIDDEN, "denied")]).await?;
|
||||
let error = env
|
||||
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
|
||||
.await
|
||||
.expect_err("authorization error must fail");
|
||||
assert!(error.to_string().contains("403"));
|
||||
timeout(Duration::from_secs(1), tasks.join_next())
|
||||
.await?
|
||||
.expect("reporting mock task")
|
||||
.expect("response consumed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
@@ -482,7 +597,7 @@ mod integration_tests {
|
||||
env.upload_object("file2.txt", 256 * 1024).await?;
|
||||
|
||||
// Check usage
|
||||
let usage = env.get_bucket_usage().await?;
|
||||
let usage = env.wait_for_bucket_usage((512 + 256) * 1024).await?;
|
||||
assert_eq!(usage, (512 + 256) * 1024);
|
||||
|
||||
// Delete a file
|
||||
@@ -517,7 +632,7 @@ mod integration_tests {
|
||||
env.upload_object("file2.txt", 512 * 1024).await?;
|
||||
|
||||
// Get detailed quota statistics
|
||||
let stats = env.get_bucket_quota_stats().await?;
|
||||
let stats = env.wait_for_bucket_quota_stats(&env.bucket_name, (1024 + 512) * 1024).await?;
|
||||
|
||||
assert_eq!(stats.get("bucket").unwrap().as_str().unwrap(), env.bucket_name);
|
||||
assert_eq!(stats.get("quota_limit").unwrap().as_u64().unwrap(), 2 * 1024 * 1024);
|
||||
@@ -546,22 +661,22 @@ mod integration_tests {
|
||||
env.upload_object("existing_file.txt", 512 * 1024).await?;
|
||||
|
||||
// Check if we can upload another 512KB (should succeed, exactly fill quota)
|
||||
let check_result = env.check_bucket_quota("PUT", 512 * 1024).await?;
|
||||
let check_result = env.wait_for_bucket_quota_check("PUT", 512 * 1024, 512 * 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 0);
|
||||
|
||||
// Note: we haven't actually uploaded the second file yet, so current_usage is still 512KB
|
||||
// Check if we can upload 1KB (should succeed - we haven't used the full quota yet)
|
||||
let check_result = env.check_bucket_quota("PUT", 1024).await?;
|
||||
let check_result = env.wait_for_bucket_quota_check("PUT", 1024, 512 * 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 523264); // 511 * 1024
|
||||
|
||||
// Check if we can upload 600KB (should fail - would exceed quota)
|
||||
let check_result = env.check_bucket_quota("PUT", 600 * 1024).await?;
|
||||
let check_result = env.wait_for_bucket_quota_check("PUT", 600 * 1024, 512 * 1024).await?;
|
||||
assert!(!check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
|
||||
// Check delete operation (should always be allowed)
|
||||
let check_result = env.check_bucket_quota("DELETE", 512 * 1024).await?;
|
||||
let check_result = env.wait_for_bucket_quota_check("DELETE", 512 * 1024, 512 * 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
@@ -593,8 +708,8 @@ mod integration_tests {
|
||||
env.upload_object_to_bucket(&bucket2, "another_file.txt", 512 * 1024).await?;
|
||||
|
||||
// Verify statistics are independent
|
||||
let stats1 = env.get_bucket_quota_stats_for(&bucket1).await?;
|
||||
let stats2 = env.get_bucket_quota_stats_for(&bucket2).await?;
|
||||
let stats1 = env.wait_for_bucket_quota_stats(&bucket1, 1024 * 1024).await?;
|
||||
let stats2 = env.wait_for_bucket_quota_stats(&bucket2, (1024 + 512) * 1024).await?;
|
||||
|
||||
assert_eq!(stats1.get("current_usage").unwrap().as_u64().unwrap(), 1024 * 1024);
|
||||
assert_eq!(stats2.get("current_usage").unwrap().as_u64().unwrap(), (1024 + 512) * 1024);
|
||||
@@ -655,7 +770,6 @@ mod integration_tests {
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
env.wait_for_quota_usage_for(&env.bucket_name).await?;
|
||||
|
||||
// Test 1: GET quota for bucket without quota config
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
||||
|
||||
@@ -548,6 +548,20 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_multipart_data(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn recover_multipart_source(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_data_at_incarnation(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||
@@ -562,6 +576,25 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_data_with_pool_meta_cas(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
async fn undo_pool_meta_write(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
async fn pool_meta_native_recovery(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn make_volumes(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -261,6 +261,61 @@ mod tests {
|
||||
archive
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_rejects_veeam_virtual_members() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_VEEAM_SOSAPI_ENABLE", "true")])
|
||||
.await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-veeam-reserved";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
let prefix = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c";
|
||||
for (member, destination, use_prefix) in [
|
||||
(format!("{prefix}/system.xml"), format!("{prefix}/system.xml"), false),
|
||||
("capacity.xml".to_string(), format!("{prefix}/capacity.xml"), true),
|
||||
] {
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
append_typed_entry(&mut builder, &member, tokio_tar::EntryType::Regular, b"blocked").await?;
|
||||
let archive = builder.into_inner().await?.into_inner();
|
||||
let mut request = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(archive));
|
||||
if use_prefix {
|
||||
request = request
|
||||
.metadata("Minio-Snowball-Prefix", prefix)
|
||||
.metadata("Minio-Snowball-Ignore-Errors", "true");
|
||||
}
|
||||
let error = request.send().await.expect_err("virtual archive members must be rejected");
|
||||
assert_eq!(error.code(), Some("MethodNotAllowed"));
|
||||
let listed = client.list_objects_v2().bucket(bucket).prefix(&destination).send().await?;
|
||||
assert!(listed.contents().is_empty(), "rejected virtual member must not be stored");
|
||||
}
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
append_typed_entry(&mut builder, "ordinary.xml", tokio_tar::EntryType::Regular, b"ordinary").await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.metadata("Minio-Snowball-Prefix", prefix)
|
||||
.body(ByteStream::from(builder.into_inner().await?.into_inner()))
|
||||
.send()
|
||||
.await?;
|
||||
let ordinary = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("{prefix}/ordinary.xml"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(ordinary.body.collect().await?.into_bytes().as_ref(), b"ordinary");
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_supports_minio_prefix_and_directory_markers() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -13,9 +13,13 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
||||
use crate::storage_api::multipart_fence_acceptance::RUSTFS_META_MULTIPART_BUCKET;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::time::{Duration, Instant, sleep};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -84,6 +88,126 @@ async fn wait_for_cleanup_on_all_nodes(
|
||||
Err("stale multipart upload was not cleaned up on all nodes within timeout".into())
|
||||
}
|
||||
|
||||
type PhysicalTree = BTreeMap<PathBuf, Option<Vec<u8>>>;
|
||||
|
||||
fn physical_tree(root: &Path) -> Result<PhysicalTree, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut result = BTreeMap::new();
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(directory) = pending.pop() {
|
||||
for entry in std::fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let relative = path.strip_prefix(root)?.to_path_buf();
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_dir() {
|
||||
result.insert(relative, None);
|
||||
pending.push(path);
|
||||
} else if kind.is_file() {
|
||||
result.insert(relative, Some(std::fs::read(path)?));
|
||||
} else {
|
||||
return Err(format!("unexpected staging entry type: {relative:?}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn assert_low_quorum_residuals_are_diagnosed_and_preserved(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
clients: &[aws_sdk_s3::Client],
|
||||
log_paths: &[PathBuf],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut residuals = Vec::new();
|
||||
for copies in [1_usize, 2] {
|
||||
let key = format!("multipart/partial-{copies}-{}", Uuid::new_v4().simple());
|
||||
let upload = clients[0]
|
||||
.create_multipart_upload()
|
||||
.bucket(CLEANUP_BUCKET)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = upload.upload_id().ok_or("create response missing upload ID")?;
|
||||
clients[0]
|
||||
.upload_part()
|
||||
.bucket(CLEANUP_BUCKET)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(vec![0x41; 4096]))
|
||||
.send()
|
||||
.await?;
|
||||
let decoded = String::from_utf8(base64_simd::URL_SAFE_NO_PAD.decode_to_vec(upload_id.as_bytes())?)?;
|
||||
let (_, upload_dir) = decoded.split_once('.').ok_or("upload ID lacks deployment delimiter")?;
|
||||
let digest = Sha256::digest(format!("{CLEANUP_BUCKET}/{key}").as_bytes());
|
||||
let hash = hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower);
|
||||
let relative = format!("{hash}/{upload_dir}");
|
||||
let roots = cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
Path::new(&node.data_dirs[0])
|
||||
.join(RUSTFS_META_MULTIPART_BUCKET)
|
||||
.join(&relative)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(roots.len(), 4);
|
||||
let mut preserved = Vec::new();
|
||||
for root in &roots {
|
||||
assert!(
|
||||
root.join("xl.meta").is_file(),
|
||||
"all four original metadata files must exist before injection"
|
||||
);
|
||||
preserved.push(physical_tree(root)?);
|
||||
}
|
||||
for root in roots.iter().skip(copies) {
|
||||
std::fs::remove_dir_all(root)?;
|
||||
}
|
||||
residuals.push((relative, roots, preserved, copies));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
let logs = log_paths
|
||||
.iter()
|
||||
.filter_map(|path| std::fs::read_to_string(path).ok())
|
||||
.collect::<Vec<_>>();
|
||||
let diagnosed = residuals.iter().all(|(path, _, _, _)| {
|
||||
logs.iter().any(|log| {
|
||||
log.lines()
|
||||
.any(|line| line.contains(path) && line.contains("multipart_cleanup_lock_or_recheck_failed"))
|
||||
})
|
||||
});
|
||||
if diagnosed {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("stale low-quorum residuals were not diagnosed before the deadline".into());
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
for (path, roots, preserved, copies) in &residuals {
|
||||
for slot in 0..*copies {
|
||||
assert_eq!(
|
||||
physical_tree(&roots[slot])?,
|
||||
preserved[slot],
|
||||
"retained source evidence changed: {path}, slot {slot}"
|
||||
);
|
||||
}
|
||||
for root in roots.iter().skip(*copies) {
|
||||
assert!(!root.exists(), "cleanup must not recreate missing source roots: {path}");
|
||||
}
|
||||
}
|
||||
for path in log_paths {
|
||||
let log = std::fs::read_to_string(path)?;
|
||||
assert!(
|
||||
!log.contains("op=\"upload_part\"") && !log.contains("\"op\":\"upload_part\""),
|
||||
"background cleanup must not be logged as a client UploadPart"
|
||||
);
|
||||
}
|
||||
eprintln!("Stale MPU low-quorum: EC:2 one/two-copy roots diagnosed; full surviving file inventories preserved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stale_multipart_cleanup_removes_incomplete_upload_across_cluster()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -92,6 +216,22 @@ async fn test_stale_multipart_cleanup_removes_incomplete_upload_across_cluster()
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
cluster.set_env("RUSTFS_API_STALE_UPLOADS_EXPIRY", "5s");
|
||||
cluster.set_env("RUSTFS_API_STALE_UPLOADS_CLEANUP_INTERVAL", "1s");
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||
cluster.set_env(
|
||||
"RUST_LOG",
|
||||
concat!("warn,rustfs_ecstore", "::bucket::lifecycle::bucket_lifecycle_ops=debug"),
|
||||
);
|
||||
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR")
|
||||
.map(PathBuf::from)
|
||||
.map(|root| root.join(format!("stale-multipart-{}", Uuid::new_v4().simple())))
|
||||
.unwrap_or_else(|| PathBuf::from(&cluster.temp_dir));
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
let log_paths = (0..cluster.nodes.len())
|
||||
.map(|index| log_dir.join(format!("stale-node-{index}.log")))
|
||||
.collect::<Vec<_>>();
|
||||
for (index, path) in log_paths.iter().enumerate() {
|
||||
cluster.set_node_capture_log_path(index, path.to_string_lossy().into_owned())?;
|
||||
}
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(CLEANUP_BUCKET).await?;
|
||||
|
||||
@@ -137,6 +277,7 @@ async fn test_stale_multipart_cleanup_removes_incomplete_upload_across_cluster()
|
||||
);
|
||||
|
||||
wait_for_cleanup_on_all_nodes(&clients, CLEANUP_BUCKET, &key, &upload_id).await?;
|
||||
assert_low_quorum_residuals_are_diagnosed_and_preserved(&cluster, &clients, &log_paths).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
|
||||
pub(crate) const OBJECT_LOCK_TRACE_FILTER: &str = "info,rustfs_ecstore::object_lock_diag=trace";
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{
|
||||
DiskOption, Endpoint, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
@@ -52,3 +56,27 @@ pub(crate) mod internode_rpc_signature {
|
||||
pub(crate) mod replication_extension {
|
||||
pub(crate) use super::BucketTargetSys;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod upgrade_compatibility {
|
||||
pub(crate) use super::{DiskOption, Endpoint, RUSTFS_META_MULTIPART_BUCKET, new_disk};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod multipart_rpc_delay {
|
||||
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod multipart_fence_acceptance {
|
||||
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod multipart_source_rpc {
|
||||
pub(crate) use rustfs_ecstore::api::disk::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
|
||||
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceSnapshot,
|
||||
PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -600,6 +600,154 @@ mod tests {
|
||||
info!("✅ PASSED: Veeam backup workflow simulation completed successfully");
|
||||
}
|
||||
|
||||
/// Veeam B&R discovers SOSAPI by GET/HEAD of two well-known keys.
|
||||
#[tokio::test]
|
||||
async fn test_veeam_sosapi_system_and_capacity() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use crate::common::local_http_client;
|
||||
|
||||
const SYSTEM_KEY: &str = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml";
|
||||
const CAPACITY_KEY: &str = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml";
|
||||
|
||||
init_logging();
|
||||
info!("🧪 TEST: Veeam SOSAPI system.xml and capacity.xml");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], crate::common::FAST_DATA_USAGE_SCANNER_ENV)
|
||||
.await?;
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "veeam-sosapi";
|
||||
create_bucket(&client, bucket).await?;
|
||||
|
||||
let system = client.get_object().bucket(bucket).key(SYSTEM_KEY).send().await?;
|
||||
let system_etag = system.e_tag().map(str::to_string);
|
||||
let system_len = system.content_length();
|
||||
assert_eq!(system.content_type(), Some("application/xml"));
|
||||
let system_body = system.body.collect().await?.into_bytes();
|
||||
let system_xml = std::str::from_utf8(&system_body)?;
|
||||
assert!(system_xml.contains("<ProtocolVersion>\"1.0\"</ProtocolVersion>"), "{system_xml}");
|
||||
assert!(
|
||||
system_xml.contains("<ModelName>\"RustFS ") && system_xml.contains("\"</ModelName>"),
|
||||
"{system_xml}"
|
||||
);
|
||||
assert!(system_xml.contains("<CapacityInfo>true</CapacityInfo>"));
|
||||
assert!(system_xml.contains("<UploadSessions>false</UploadSessions>"));
|
||||
assert!(system_xml.contains("<IAMSTS>false</IAMSTS>"));
|
||||
assert!(!system_xml.contains("SystemRecommendations"));
|
||||
assert!(!system_xml.contains("APIEndpoints"));
|
||||
assert!(!system_xml.contains("""));
|
||||
|
||||
let head = client.head_object().bucket(bucket).key(SYSTEM_KEY).send().await?;
|
||||
assert_eq!(head.content_type(), Some("application/xml"));
|
||||
assert_eq!(head.content_length(), system_len);
|
||||
assert_eq!(head.e_tag().map(str::to_string), system_etag);
|
||||
assert!(head.last_modified().is_some());
|
||||
|
||||
let ranged = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(SYSTEM_KEY)
|
||||
.range("bytes=0-3")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(ranged.content_length(), Some(4));
|
||||
let ranged_header = ranged.content_range().unwrap_or("");
|
||||
assert!(ranged_header.starts_with("bytes 0-3/"), "{ranged_header}");
|
||||
let ranged_body = ranged.body.collect().await?.into_bytes();
|
||||
assert_eq!(&ranged_body[..], &system_body[..4]);
|
||||
|
||||
let capacity = tokio::time::timeout(std::time::Duration::from_secs(180), async {
|
||||
loop {
|
||||
match client.get_object().bucket(bucket).key(CAPACITY_KEY).send().await {
|
||||
Ok(capacity) => break Ok(capacity),
|
||||
Err(err) if err.code() == Some("ServiceUnavailable") => {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert_eq!(capacity.content_type(), Some("application/xml"));
|
||||
let capacity_body = capacity.body.collect().await?.into_bytes();
|
||||
let capacity_xml = std::str::from_utf8(&capacity_body)?;
|
||||
let capacity_value = xml_u64(capacity_xml, "Capacity")?;
|
||||
let available_value = xml_u64(capacity_xml, "Available")?;
|
||||
let used_value = xml_u64(capacity_xml, "Used")?;
|
||||
assert!(used_value <= capacity_value, "{capacity_xml}");
|
||||
assert!(available_value <= capacity_value.saturating_sub(used_value), "{capacity_xml}");
|
||||
|
||||
let put_err = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(SYSTEM_KEY)
|
||||
.body(ByteStream::from_static(b"overwrite"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("SOSAPI put must fail");
|
||||
assert_eq!(put_err.code(), Some("MethodNotAllowed"));
|
||||
let delete_err = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(CAPACITY_KEY)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("SOSAPI delete must fail");
|
||||
assert_eq!(delete_err.code(), Some("MethodNotAllowed"));
|
||||
|
||||
let normal_key = "Veeam/Backup/job.dat";
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(normal_key)
|
||||
.body(ByteStream::from_static(b"backup-bytes"))
|
||||
.send()
|
||||
.await?;
|
||||
let normal = client.get_object().bucket(bucket).key(normal_key).send().await?;
|
||||
let normal_body = normal.body.collect().await?.into_bytes();
|
||||
assert_eq!(&normal_body[..], b"backup-bytes");
|
||||
|
||||
let anon = local_http_client()
|
||||
.get(format!("{}/{bucket}/{SYSTEM_KEY}", env.url))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(anon.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
let anon_body = anon.text().await?;
|
||||
assert!(!anon_body.contains("ProtocolVersion"), "{anon_body}");
|
||||
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let scope_date = format!("{:04}{:02}{:02}", now.year(), u8::from(now.month()), now.day());
|
||||
let amz_date = format!("{scope_date}T{:02}{:02}{:02}Z", now.hour(), now.minute(), now.second());
|
||||
let bad = local_http_client()
|
||||
.get(format!("{}/{bucket}/{SYSTEM_KEY}", env.url))
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
|
||||
.header(
|
||||
"authorization",
|
||||
format!(
|
||||
"AWS4-HMAC-SHA256 Credential=bad/{scope_date}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=0000000000000000000000000000000000000000000000000000000000000000"
|
||||
),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(bad.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
let bad_body = bad.text().await?;
|
||||
assert!(!bad_body.contains("ProtocolVersion"), "{bad_body}");
|
||||
|
||||
info!("✅ PASSED: Veeam SOSAPI system.xml and capacity.xml");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml_u64(xml: &str, element: &str) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let start = format!("<{element}>");
|
||||
let end = format!("</{element}>");
|
||||
let start_idx = xml.find(&start).ok_or_else(|| format!("missing {element} in {xml}"))? + start.len();
|
||||
let end_idx = xml[start_idx..]
|
||||
.find(&end)
|
||||
.ok_or_else(|| format!("unclosed {element} in {xml}"))?
|
||||
+ start_idx;
|
||||
Ok(xml[start_idx..end_idx].parse()?)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_terraform_put_after_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -12,6 +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 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
|
||||
|
||||
@@ -32,12 +32,9 @@ workspace = true
|
||||
[features]
|
||||
default = []
|
||||
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
|
||||
# Sampled wall/CPU diagnostics only; does not change the read backend.
|
||||
diagnostic-local-batch = ["rustix/time"]
|
||||
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
||||
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
||||
# production binary (it is always available to tests). Off by default so the
|
||||
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
||||
# state in a stock build (backlog#1832).
|
||||
list-chaos = []
|
||||
rio-v2 = ["dep:rustfs-rio-v2"]
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
|
||||
//! Explicit ECStore public facades for outer crate compatibility boundaries.
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[cfg(any(feature = "diagnostic-read-version-caller", feature = "diagnostic-local-batch"))]
|
||||
pub mod diagnostic {
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub use crate::cluster::rpc::runtime_sources::{
|
||||
current_read_version_entry_source, observe_read_version_context_boundary, with_captured_read_version_context,
|
||||
with_read_version_entry, with_read_version_spawn_context,
|
||||
};
|
||||
#[cfg(feature = "diagnostic-local-batch")]
|
||||
pub use crate::diagnostics::batch_read_diagnostics::{current_request_id, in_request_context, request_context};
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub use rustfs_io_metrics::internode_metrics::{ReadVersionContextBoundary, ReadVersionEntrySource};
|
||||
}
|
||||
|
||||
@@ -141,6 +145,12 @@ pub mod bucket {
|
||||
pub use crate::bucket::lifecycle::rule::{Filter, NoncurrentVersionTransitionOps, TransitionOps};
|
||||
}
|
||||
|
||||
pub mod tier_free_version_recovery {
|
||||
pub use crate::bucket::lifecycle::tier_free_version_recovery::{
|
||||
TierCleanupRetryReport, TierCleanupRetryStatus, retry_tier_free_version_cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod tier_delete_journal {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::lifecycle::tier_delete_journal::recover_tier_delete_journal_entries;
|
||||
@@ -252,7 +262,7 @@ pub mod bucket {
|
||||
pub mod quota {
|
||||
pub use crate::bucket::quota::{
|
||||
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V1, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError,
|
||||
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration,
|
||||
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration, QuotaType,
|
||||
};
|
||||
|
||||
pub mod checker {
|
||||
@@ -341,9 +351,9 @@ pub mod cache {
|
||||
|
||||
pub mod capacity {
|
||||
pub use crate::core::pools::{
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
||||
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, path2_bucket_object,
|
||||
path2_bucket_object_with_base_path,
|
||||
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolAvailability, PoolCapacityStatus, PoolDecommissionInfo,
|
||||
PoolDiagnosticStatus, PoolMeta, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
|
||||
};
|
||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||
}
|
||||
@@ -371,15 +381,24 @@ pub mod compression {
|
||||
|
||||
pub mod config {
|
||||
pub mod com {
|
||||
/// Preserve empty existing objects so callers can distinguish corruption from absence.
|
||||
pub async fn read_config_preserve_empty_with_metadata_opts(
|
||||
api: std::sync::Arc<crate::store::ECStore>,
|
||||
file: &str,
|
||||
opts: &crate::object_api::ObjectOptions,
|
||||
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
||||
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
|
||||
}
|
||||
|
||||
pub use crate::config::com::{
|
||||
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
||||
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config, delete_config_no_lock,
|
||||
is_server_config_corrupt_error, lookup_configs, read_config, read_config_no_lock, read_config_with_metadata,
|
||||
read_config_without_migrate, read_config_without_migrate_no_lock, read_existing_server_config_no_lock,
|
||||
read_server_config_snapshot, save_config, save_config_no_lock, save_config_with_opts, save_config_with_opts_quiet,
|
||||
save_server_config, save_server_config_no_lock, save_server_config_snapshot,
|
||||
save_server_config_snapshot_with_generation, server_config_path, try_migrate_server_config,
|
||||
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
|
||||
read_server_config_snapshot, read_server_config_with_generation, save_config, save_config_no_lock,
|
||||
save_config_with_opts, save_config_with_opts_quiet, save_server_config, save_server_config_no_lock,
|
||||
save_server_config_snapshot, save_server_config_snapshot_with_generation, server_config_path,
|
||||
try_migrate_server_config, with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
|
||||
with_server_config_write_lock,
|
||||
};
|
||||
}
|
||||
@@ -416,9 +435,10 @@ pub mod data_usage {
|
||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
||||
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
||||
invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached,
|
||||
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached, quota_object_size,
|
||||
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
|
||||
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
|
||||
load_bucket_usage_from_backend_cached, load_compression_total_from_memory, load_data_usage_from_backend,
|
||||
load_data_usage_from_backend_cached, quota_object_size, record_bucket_delete_marker_memory,
|
||||
record_bucket_object_delete_memory, record_bucket_object_version_write_memory, record_bucket_object_write_memory,
|
||||
record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
|
||||
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
|
||||
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
|
||||
store_data_usage_in_backend,
|
||||
@@ -429,6 +449,16 @@ pub mod data_usage {
|
||||
pub mod disk {
|
||||
pub use crate::disk::disk_store::get_object_disk_read_timeout;
|
||||
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::disk::local::{local_durability_modes, set_local_rename_data_fail_after_metadata_commit};
|
||||
pub use crate::disk::multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
|
||||
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse,
|
||||
MultipartSourceSnapshot, PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage, capture_source_condition,
|
||||
require_source_condition, strip_native_controls,
|
||||
};
|
||||
#[cfg(all(feature = "test-util", unix))]
|
||||
pub use crate::disk::os::set_local_dir_fsync_failure;
|
||||
#[cfg(all(feature = "test-util", not(windows)))]
|
||||
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
||||
pub use crate::disk::{
|
||||
@@ -438,9 +468,12 @@ pub mod disk {
|
||||
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
|
||||
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
|
||||
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
|
||||
PartTransactionAction, QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp,
|
||||
ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
is_quota_mutation_fence_path, new_disk, validate_batch_read_version_item_count,
|
||||
POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup, PoolMetaNativeInspection,
|
||||
PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
|
||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
|
||||
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken,
|
||||
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path, new_disk,
|
||||
validate_batch_read_version_item_count,
|
||||
};
|
||||
pub use bytes::Bytes;
|
||||
pub use endpoint::Endpoint;
|
||||
@@ -491,6 +524,8 @@ pub mod global {
|
||||
}
|
||||
|
||||
pub mod runtime {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::runtime::global::set_object_store_resolver;
|
||||
pub use crate::runtime::instance::{InstanceContext, bootstrap_ctx};
|
||||
pub use crate::runtime::sources::{
|
||||
boot_time, bucket_monitor, deployment_id, endpoint_pools, expiry_state_handle, first_cluster_node_is_local,
|
||||
@@ -511,13 +546,16 @@ pub mod metrics {
|
||||
pub mod notification {
|
||||
pub use crate::services::notification_sys::{
|
||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
|
||||
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
|
||||
acquire_legacy_transition_state_reconcile_fleet_proof, configured_fleet_topology_fingerprint,
|
||||
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, ilm_recovery_export_fleet_proof_matches,
|
||||
ilm_recovery_export_local_process_epoch, ilm_recovery_export_member_epochs_sha256,
|
||||
ilm_recovery_export_topology_generation, legacy_transition_state_reconcile_fleet_proof_matches,
|
||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
LegacyTransitionStateReconcileFleetProofToken, MrfLifecycleWriterFleetProofToken, NotificationPeerErr, NotificationSys,
|
||||
ScannerPublicationLeaseGrant, acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
|
||||
acquire_legacy_transition_state_reconcile_fleet_proof, acquire_mrf_lifecycle_writer_fleet_proof,
|
||||
configured_fleet_topology_fingerprint, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
|
||||
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
|
||||
legacy_transition_state_reconcile_fleet_proof_matches, mrf_lifecycle_reader_local_process_epoch,
|
||||
mrf_lifecycle_writer_fleet_proof_current, mrf_lifecycle_writer_fleet_proof_matches, new_global_notification_sys,
|
||||
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
with_mrf_lifecycle_writer_fleet_proof,
|
||||
};
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::{
|
||||
@@ -587,12 +625,14 @@ pub mod rpc {
|
||||
decode_heal_bucket_rpc_options, encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers,
|
||||
gen_tonic_signature_headers, gen_tonic_signature_interceptor, node_service_time_out_client,
|
||||
node_service_time_out_client_no_auth, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
|
||||
sign_ns_scanner_capability, sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability,
|
||||
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
|
||||
tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation,
|
||||
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
|
||||
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
|
||||
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
|
||||
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
|
||||
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
|
||||
verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
|
||||
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability,
|
||||
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
||||
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3472,23 +3472,47 @@ mod tests {
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
fn spawn_http_status_server(status: u16) -> (u16, std::thread::JoinHandle<()>) {
|
||||
use std::io::{Read, Write};
|
||||
fn spawn_http_status_server(status: u16) -> (u16, std::sync::mpsc::Sender<()>, std::thread::JoinHandle<usize>) {
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("test HTTP listener should bind");
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.expect("test HTTP listener should be nonblocking");
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.expect("test HTTP listener should have an address")
|
||||
.port();
|
||||
let (stop, stopped) = std::sync::mpsc::channel();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("test HTTP client should connect");
|
||||
let mut request = [0_u8; 8192];
|
||||
let bytes_read = stream.read(&mut request).expect("test HTTP request should be read");
|
||||
assert!(bytes_read > 0, "test HTTP request should not be empty");
|
||||
write!(stream, "HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||
.expect("test HTTP response should be written");
|
||||
let mut requests = 0;
|
||||
loop {
|
||||
match stopped.try_recv() {
|
||||
Ok(()) | Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
let (mut stream, _) = match listener.accept() {
|
||||
Ok(connection) => connection,
|
||||
Err(err) if err.kind() == ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
continue;
|
||||
}
|
||||
Err(err) => panic!("test HTTP listener failed: {err}"),
|
||||
};
|
||||
stream.set_nonblocking(false).expect("test HTTP stream should be blocking");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.expect("test HTTP read timeout should configure");
|
||||
let mut request = [0_u8; 8192];
|
||||
let bytes_read = stream.read(&mut request).expect("test HTTP request should be read");
|
||||
assert!(bytes_read > 0, "test HTTP request should not be empty");
|
||||
requests += 1;
|
||||
write!(stream, "HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||
.expect("test HTTP response should be written");
|
||||
}
|
||||
requests
|
||||
});
|
||||
(port, handle)
|
||||
(port, stop, handle)
|
||||
}
|
||||
|
||||
fn spawn_delayed_http_server() -> (
|
||||
@@ -4492,16 +4516,26 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn target_health_check_treats_client_errors_as_online_and_server_errors_as_offline() {
|
||||
for (status, expected_online) in [(403, true), (500, false)] {
|
||||
let (port, server) = spawn_http_status_server(status);
|
||||
let (port, stop, server) = spawn_http_status_server(status);
|
||||
let endpoint = format!("http://127.0.0.1:{port}");
|
||||
let target = target_client_for_test(
|
||||
&format!("arn:http-{status}"),
|
||||
endpoint.clone(),
|
||||
s3_client_for_endpoint_test(endpoint, None),
|
||||
);
|
||||
let client = s3_client_for_endpoint_test(endpoint.clone(), None);
|
||||
let config = client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(
|
||||
aws_sdk_s3::config::retry::RetryConfig::standard()
|
||||
.with_max_attempts(2)
|
||||
.with_initial_backoff(Duration::from_millis(1))
|
||||
.with_max_backoff(Duration::from_millis(1)),
|
||||
)
|
||||
.build();
|
||||
let target = target_client_for_test(&format!("arn:http-{status}"), endpoint, S3Client::from_conf(config));
|
||||
|
||||
assert_eq!(BucketTargetSys::check_endpoint_health(&target).await, expected_online);
|
||||
server.join().expect("test HTTP server should stop");
|
||||
let online = BucketTargetSys::check_endpoint_health(&target).await;
|
||||
stop.send(()).expect("test HTTP server should be running");
|
||||
let requests = server.join().expect("test HTTP server should stop");
|
||||
assert_eq!(requests, if status == 500 { 2 } else { 1 }, "unexpected probe count for HTTP {status}");
|
||||
assert_eq!(online, expected_online, "unexpected health for HTTP {status}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -174,5 +174,36 @@ mod tests {
|
||||
.unwrap()
|
||||
);
|
||||
assert!(get_expiry_configs(&store_b, bucket).await.unwrap().lifecycle.is_none());
|
||||
|
||||
let snapshot = store_a.object_lock_config_snapshot(bucket).await.unwrap();
|
||||
assert_eq!(
|
||||
snapshot.validated_bucket_incarnation_for(store_a.id, bucket, Some(configs.bucket_incarnation_id)),
|
||||
Some(configs.bucket_incarnation_id)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.validated_bucket_incarnation_for(store_a.id, bucket, Some(uuid::Uuid::new_v4())),
|
||||
None
|
||||
);
|
||||
assert_eq!(snapshot.validated_bucket_incarnation_for(store_b.id, bucket, None), None);
|
||||
let opts = crate::object_api::ObjectOptions {
|
||||
object_lock_config_snapshot: Some(snapshot.clone()),
|
||||
expected_bucket_incarnation_id: Some(configs.bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
get_expiry_configs_for_options(&store_a, bucket, &opts)
|
||||
.await
|
||||
.unwrap()
|
||||
.bucket_incarnation_id,
|
||||
configs.bucket_incarnation_id
|
||||
);
|
||||
let unguarded = crate::object_api::ObjectLockConfigSnapshot::for_store_bucket(
|
||||
store_a.id,
|
||||
bucket,
|
||||
configs.bucket_incarnation_id,
|
||||
time::OffsetDateTime::UNIX_EPOCH,
|
||||
metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
|
||||
);
|
||||
assert_eq!(unguarded.validated_bucket_incarnation_for(store_a.id, bucket, None), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::{
|
||||
};
|
||||
|
||||
use futures::StreamExt;
|
||||
use rand::RngExt as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -70,6 +71,7 @@ pub(crate) const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_del
|
||||
// successful pages while its wall-clock budget remains.
|
||||
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 8;
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET: Duration = Duration::from_secs(240);
|
||||
const TIER_DELETE_REMOTE_DEADLINE: Duration = Duration::from_secs(30);
|
||||
@@ -6104,57 +6106,114 @@ pub(crate) async fn recover_test_tier_delete_journal_pass_with_budget(
|
||||
recover_tier_delete_journal_pass(api, cancel_token, manifest_marker, journal_marker, budget).await
|
||||
}
|
||||
|
||||
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
|
||||
fn tier_delete_journal_recovery_pass_is_idle(stats: &TierDeleteJournalRecoveryPassStats) -> bool {
|
||||
stats.manifest_pages > 0
|
||||
&& stats.journal_pages > 0
|
||||
&& stats.manifest_errors == 0
|
||||
&& stats.journal_errors == 0
|
||||
&& !stats.deadline_exhausted
|
||||
&& !stats.canceled
|
||||
&& stats.manifests == TierDeleteDispatchManifestRecoveryStats::default()
|
||||
&& stats.journals == TierDeleteJournalRecoveryStats::default()
|
||||
}
|
||||
|
||||
fn bounded_tier_delete_journal_recovery_delay(delay: Duration, percent: u32) -> Duration {
|
||||
(delay.saturating_mul(percent) / 100)
|
||||
.clamp(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL)
|
||||
}
|
||||
|
||||
fn jitter_tier_delete_journal_recovery_delay(delay: Duration) -> Duration {
|
||||
bounded_tier_delete_journal_recovery_delay(delay, rand::rng().random_range(90..=110))
|
||||
}
|
||||
|
||||
async fn drive_tier_delete_journal_recovery(
|
||||
cancel_token: &CancellationToken,
|
||||
wait_for_wakeup: impl AsyncFn(),
|
||||
mut recover: impl AsyncFnMut() -> TierDeleteJournalRecoveryPassStats,
|
||||
jitter_delay: fn(Duration) -> Duration,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut marker: Option<String> = None;
|
||||
let mut manifest_marker: Option<String> = None;
|
||||
let mut idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => return,
|
||||
_ = wait_for_wakeup() => {
|
||||
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||
interval.reset();
|
||||
},
|
||||
_ = interval.tick() => {},
|
||||
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
|
||||
}
|
||||
|
||||
let stats = recover_tier_delete_journal_pass(
|
||||
api.clone(),
|
||||
&cancel_token,
|
||||
&mut manifest_marker,
|
||||
&mut marker,
|
||||
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
|
||||
)
|
||||
.await;
|
||||
let stats = recover().await;
|
||||
if stats.canceled {
|
||||
return;
|
||||
}
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
manifest_pages = stats.manifest_pages,
|
||||
manifest_scanned = stats.manifests.scanned,
|
||||
manifest_advanced = stats.manifests.advanced,
|
||||
manifest_deleted = stats.manifests.deleted,
|
||||
manifest_retained = stats.manifests.retained,
|
||||
manifest_failed = stats.manifests.failed,
|
||||
manifest_errors = stats.manifest_errors,
|
||||
manifest_truncated = stats.manifests.truncated,
|
||||
manifest_next_marker = ?manifest_marker,
|
||||
journal_pages = stats.journal_pages,
|
||||
journal_scanned = stats.journals.scanned,
|
||||
journal_deleted = stats.journals.deleted,
|
||||
journal_failed = stats.journals.failed,
|
||||
journal_errors = stats.journal_errors,
|
||||
journal_truncated = stats.journals.truncated,
|
||||
journal_next_marker = ?marker,
|
||||
deadline_exhausted = stats.deadline_exhausted,
|
||||
"Recovered tier delete journal pass"
|
||||
);
|
||||
if tier_delete_journal_recovery_pass_is_idle(&stats) {
|
||||
// Only a completed empty sweep may defer discovery. Retained,
|
||||
// failed, or paginated work keeps the original retry tick budget.
|
||||
interval.reset_after(jitter_delay(idle_interval));
|
||||
idle_interval = idle_interval
|
||||
.saturating_mul(2)
|
||||
.min(TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
|
||||
} else {
|
||||
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
|
||||
let mut marker: Option<String> = None;
|
||||
let mut manifest_marker: Option<String> = None;
|
||||
let wake_context = api.ctx.clone();
|
||||
let recovery_cancel_token = cancel_token.clone();
|
||||
drive_tier_delete_journal_recovery(
|
||||
&cancel_token,
|
||||
async move || wake_context.wait_for_tier_delete_journal_recovery().await,
|
||||
async move || {
|
||||
let stats = recover_tier_delete_journal_pass(
|
||||
api.clone(),
|
||||
&recovery_cancel_token,
|
||||
&mut manifest_marker,
|
||||
&mut marker,
|
||||
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
|
||||
)
|
||||
.await;
|
||||
if stats.canceled {
|
||||
return stats;
|
||||
}
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
manifest_pages = stats.manifest_pages,
|
||||
manifest_scanned = stats.manifests.scanned,
|
||||
manifest_advanced = stats.manifests.advanced,
|
||||
manifest_deleted = stats.manifests.deleted,
|
||||
manifest_retained = stats.manifests.retained,
|
||||
manifest_failed = stats.manifests.failed,
|
||||
manifest_errors = stats.manifest_errors,
|
||||
manifest_truncated = stats.manifests.truncated,
|
||||
manifest_next_marker = ?manifest_marker,
|
||||
journal_pages = stats.journal_pages,
|
||||
journal_scanned = stats.journals.scanned,
|
||||
journal_deleted = stats.journals.deleted,
|
||||
journal_failed = stats.journals.failed,
|
||||
journal_errors = stats.journal_errors,
|
||||
journal_truncated = stats.journals.truncated,
|
||||
journal_next_marker = ?marker,
|
||||
deadline_exhausted = stats.deadline_exhausted,
|
||||
"Recovered tier delete journal pass"
|
||||
);
|
||||
stats
|
||||
},
|
||||
jitter_tier_delete_journal_recovery_delay,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn await_tier_delete_journal_recovery<T, F>(
|
||||
cancel_token: &CancellationToken,
|
||||
timeout: Duration,
|
||||
@@ -6180,16 +6239,20 @@ mod tests {
|
||||
use super::{
|
||||
PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
|
||||
TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
|
||||
TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL,
|
||||
TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
|
||||
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
|
||||
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState,
|
||||
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
|
||||
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
|
||||
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest,
|
||||
TierDeleteDispatchManifestRecoveryStats, TierDeleteDispatchManifestState, TierDeleteDispatchParent,
|
||||
TierDeleteDispatchParentState, TierDeleteDispatchRecord, TierDeleteJournalRecoveryPassStats,
|
||||
await_tier_delete_journal_recovery, bounded_tier_delete_journal_recovery_delay, decode_tier_delete_dispatch_record,
|
||||
decode_tier_delete_journal_entry, drive_tier_delete_journal_recovery, encode_tier_delete_dispatch_manifest,
|
||||
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
|
||||
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
|
||||
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
|
||||
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
|
||||
tier_delete_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
|
||||
tier_delete_journal_recovery_pass_is_idle, tier_delete_source_matches_dispatch_scope,
|
||||
validate_legacy_tier_delete_recovery_source,
|
||||
};
|
||||
use crate::bucket::lifecycle::tier_sweeper::{
|
||||
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||
@@ -6833,6 +6896,213 @@ mod tests {
|
||||
assert!(err.to_string().contains("decode tier delete journal failed"));
|
||||
}
|
||||
|
||||
fn empty_recovery_pass() -> TierDeleteJournalRecoveryPassStats {
|
||||
TierDeleteJournalRecoveryPassStats {
|
||||
manifest_pages: 1,
|
||||
journal_pages: 1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_recovery_requires_both_completed_empty_queues_for_idle() {
|
||||
assert!(tier_delete_journal_recovery_pass_is_idle(&empty_recovery_pass()));
|
||||
let mut cases = Vec::new();
|
||||
cases.push(TierDeleteJournalRecoveryPassStats::default());
|
||||
for change in [
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_pages = 0,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_pages = 0,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_errors = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_errors = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.deadline_exhausted = true,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.canceled = true,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.scanned = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.retained = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.failed = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.truncated = true,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.next_marker = Some("next".to_owned()),
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.scanned = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.failed = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.truncated = true,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.next_marker = Some("next".to_owned()),
|
||||
] {
|
||||
let mut stats = empty_recovery_pass();
|
||||
change(&mut stats);
|
||||
cases.push(stats);
|
||||
}
|
||||
for stats in cases {
|
||||
assert!(
|
||||
!tier_delete_journal_recovery_pass_is_idle(&stats),
|
||||
"unfinished recovery was considered idle: {stats:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_recovery_jitter_preserves_discovery_bounds() {
|
||||
for delay in [
|
||||
Duration::ZERO,
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(120),
|
||||
Duration::from_secs(300),
|
||||
Duration::MAX,
|
||||
] {
|
||||
for percent in [90, 100, 110] {
|
||||
let jittered = bounded_tier_delete_journal_recovery_delay(delay, percent);
|
||||
assert!(jittered >= TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
||||
assert!(jittered <= TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
|
||||
if delay == Duration::from_secs(120) {
|
||||
assert!((Duration::from_secs(108)..=Duration::from_secs(132)).contains(&jittered));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_delete_journal_recovery_loop_starts_immediately_backs_off_and_wakes() {
|
||||
let cancel = CancellationToken::new();
|
||||
let wake = tokio::sync::Notify::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let started = tokio::time::Instant::now();
|
||||
let recovery = drive_tier_delete_journal_recovery(
|
||||
&cancel,
|
||||
async || wake.notified().await,
|
||||
async || {
|
||||
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||
empty_recovery_pass()
|
||||
},
|
||||
|delay| bounded_tier_delete_journal_recovery_delay(delay, 110),
|
||||
);
|
||||
let exercise = async {
|
||||
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
for delay in [66, 132, 264, 300, 300] {
|
||||
tokio::time::advance(Duration::from_secs(delay - 1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(rx.try_recv().is_err(), "idle pass ran before its backoff");
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
elapsed += Duration::from_secs(delay);
|
||||
assert_eq!(rx.recv().await.expect("bounded idle recovery must run"), started + elapsed);
|
||||
}
|
||||
wake.notify_one();
|
||||
assert_eq!(rx.recv().await.expect("notification must bypass idle delay"), started + elapsed);
|
||||
let first_idle_delay = Duration::from_secs(66);
|
||||
tokio::time::advance(first_idle_delay).await;
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("wakeup must reset idle backoff"),
|
||||
started + elapsed + first_idle_delay
|
||||
);
|
||||
cancel.cancel();
|
||||
};
|
||||
tokio::join!(recovery, exercise);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_delete_journal_recovery_loop_preserves_known_work_tick_deadline() {
|
||||
let cancel = CancellationToken::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut pass = 0;
|
||||
let recovery = drive_tier_delete_journal_recovery(
|
||||
&cancel,
|
||||
async || std::future::pending::<()>().await,
|
||||
async || {
|
||||
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||
pass += 1;
|
||||
if pass <= 3 {
|
||||
return empty_recovery_pass();
|
||||
}
|
||||
if pass == 4 {
|
||||
tokio::time::sleep(Duration::from_secs(40)).await;
|
||||
}
|
||||
let mut stats = empty_recovery_pass();
|
||||
match pass {
|
||||
4 => {
|
||||
stats.manifests = TierDeleteDispatchManifestRecoveryStats {
|
||||
retained: 1,
|
||||
scanned: 1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
5 => stats.manifest_errors = 1,
|
||||
6 => stats.journal_errors = 1,
|
||||
7 => stats.manifests.truncated = true,
|
||||
8 => stats.journals.failed = 1,
|
||||
9 => stats.deadline_exhausted = true,
|
||||
_ => stats.journals.next_marker = Some("next-page".to_owned()),
|
||||
}
|
||||
stats
|
||||
},
|
||||
std::convert::identity,
|
||||
);
|
||||
let exercise = async {
|
||||
for expected in [0, 60, 180, 420] {
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("idle and work passes must run"),
|
||||
started + Duration::from_secs(expected)
|
||||
);
|
||||
}
|
||||
tokio::time::advance(Duration::from_secs(59)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(rx.try_recv().is_err(), "known work ran before the next tick");
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
for expected in [480, 540, 600, 660, 720, 780, 840] {
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("unfinished work must retain the base tick deadline"),
|
||||
started + Duration::from_secs(expected)
|
||||
);
|
||||
}
|
||||
cancel.cancel();
|
||||
};
|
||||
tokio::join!(recovery, exercise);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_delete_journal_recovery_loop_keeps_wakeup_received_during_a_pass() {
|
||||
let cancel = CancellationToken::new();
|
||||
let wake = tokio::sync::Notify::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut first = true;
|
||||
let recovery = drive_tier_delete_journal_recovery(
|
||||
&cancel,
|
||||
async || wake.notified().await,
|
||||
async || {
|
||||
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||
if first {
|
||||
first = false;
|
||||
tokio::time::sleep(Duration::from_secs(40)).await;
|
||||
}
|
||||
empty_recovery_pass()
|
||||
},
|
||||
std::convert::identity,
|
||||
);
|
||||
let exercise = async {
|
||||
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
|
||||
wake.notify_one();
|
||||
tokio::time::advance(Duration::from_secs(40)).await;
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("a notification during recovery must remain pending"),
|
||||
started + Duration::from_secs(40)
|
||||
);
|
||||
cancel.cancel();
|
||||
};
|
||||
tokio::join!(recovery, exercise);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_delete_journal_recovery_loop_cancellation_wins_over_ready_wakeup() {
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
drive_tier_delete_journal_recovery(
|
||||
&cancel,
|
||||
async || {},
|
||||
async || panic!("shutdown must prevent admission even when a wakeup and startup tick are ready"),
|
||||
std::convert::identity,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
|
||||
let result = await_tier_delete_journal_recovery(
|
||||
|
||||
@@ -24,12 +24,16 @@ use crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_versi
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::Result;
|
||||
use crate::object_api::ObjectInfo;
|
||||
use crate::services::tier::tier::{ERR_TIER_INVALID_CONFIG, TierConfigMgr};
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketOperations, BucketOptions},
|
||||
list::{ListOperations as _, StorageObjectInfoOrErr, StorageWalkOptions},
|
||||
};
|
||||
use crate::store::ECStore;
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_lock::error::LockError;
|
||||
use rustfs_s3_client::admin_handler_utils::AdminError;
|
||||
|
||||
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
|
||||
// These are page-wide repair budgets. Applying them per bucket would still let
|
||||
@@ -130,6 +134,153 @@ pub(super) fn set_recovery_bucket_list_wait_hook(started: Arc<tokio::sync::Notif
|
||||
RecoveryBucketListWaitHookGuard
|
||||
}
|
||||
|
||||
/// A bounded retry queues existing cleanup ownership; it never deletes a tier.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TierCleanupRetryReport {
|
||||
pub tier_name: String,
|
||||
pub status: TierCleanupRetryStatus,
|
||||
pub scanned: usize,
|
||||
pub enqueued: usize,
|
||||
pub failed: usize,
|
||||
pub scan_truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TierCleanupRetryStatus {
|
||||
Accepted,
|
||||
Busy,
|
||||
}
|
||||
|
||||
impl TierCleanupRetryReport {
|
||||
fn new(tier_name: &str, status: TierCleanupRetryStatus) -> Self {
|
||||
Self {
|
||||
tier_name: tier_name.to_string(),
|
||||
status,
|
||||
scanned: 0,
|
||||
enqueued: 0,
|
||||
failed: 0,
|
||||
scan_truncated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_cleanup_retry_error() -> AdminError {
|
||||
let mut error = ERR_TIER_INVALID_CONFIG.clone();
|
||||
error.code = "XRustFSAdminTierCleanupRetryFailed".to_string();
|
||||
error.status_code = http::StatusCode::SERVICE_UNAVAILABLE;
|
||||
error.message = "Tier cleanup retry could not finish; existing cleanup records are retained".to_string();
|
||||
error
|
||||
}
|
||||
|
||||
/// Reuses the recovery reader and expiry queue on whichever node receives the
|
||||
/// request. This does not depend on a node-local wakeup reaching the recovery
|
||||
/// leader. The identity lease and distributed retry lock are acquired in that
|
||||
/// order; no manager lock is held during namespace I/O.
|
||||
pub async fn retry_tier_free_version_cleanup(
|
||||
api: Arc<ECStore>,
|
||||
tier_name: &str,
|
||||
) -> std::result::Result<TierCleanupRetryReport, AdminError> {
|
||||
let cancel = api.ctx.background_cancel_token().unwrap_or_default().child_token();
|
||||
let _cancel_on_drop = cancel.clone().drop_guard();
|
||||
let deadline = tokio::time::sleep(Duration::from_secs(10));
|
||||
tokio::pin!(deadline);
|
||||
let retry = retry_tier_free_version_cleanup_with_cancel(api, tier_name, cancel.clone());
|
||||
tokio::pin!(retry);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
let _ = retry.await;
|
||||
Err(tier_cleanup_retry_error())
|
||||
}
|
||||
_ = &mut deadline => {
|
||||
cancel.cancel();
|
||||
// Poll cancellation through the recovery reader's walk shutdown.
|
||||
let _ = retry.await;
|
||||
Err(tier_cleanup_retry_error())
|
||||
}
|
||||
result = &mut retry => result,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn retry_tier_free_version_cleanup_with_cancel(
|
||||
api: Arc<ECStore>,
|
||||
tier_name: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> std::result::Result<TierCleanupRetryReport, AdminError> {
|
||||
let manager = api.tier_config_mgr();
|
||||
let lease = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err(tier_cleanup_retry_error()),
|
||||
result = TierConfigMgr::acquire_operation_lease(&manager, tier_name) => result?,
|
||||
};
|
||||
let lock_name = format!(
|
||||
"tier-cleanup-retry/{}",
|
||||
hex_simd::encode_to_string(lease.backend_identity(), hex_simd::AsciiCase::Lower)
|
||||
);
|
||||
let lock = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err(tier_cleanup_retry_error()),
|
||||
result = api.new_ns_lock(RUSTFS_META_BUCKET, &lock_name) => result.map_err(|_| tier_cleanup_retry_error())?,
|
||||
};
|
||||
let mut guard = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err(tier_cleanup_retry_error()),
|
||||
result = lock.get_write_lock_quiet(Duration::from_millis(100)) => match result {
|
||||
Ok(guard) => guard,
|
||||
Err(LockError::Timeout { .. }) => return Ok(TierCleanupRetryReport::new(tier_name, TierCleanupRetryStatus::Busy)),
|
||||
Err(_) => return Err(tier_cleanup_retry_error()),
|
||||
},
|
||||
};
|
||||
let recovery = retry_tier_free_version_cleanup_page(api, tier_name, cancel.clone());
|
||||
tokio::pin!(recovery);
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = guard.lock_lost_notified() => {
|
||||
cancel.cancel();
|
||||
let _ = recovery.await;
|
||||
Err(tier_cleanup_retry_error())
|
||||
}
|
||||
result = &mut recovery => result,
|
||||
};
|
||||
guard.release();
|
||||
drop(lease);
|
||||
result
|
||||
}
|
||||
|
||||
async fn retry_tier_free_version_cleanup_page(
|
||||
api: Arc<ECStore>,
|
||||
tier_name: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> std::result::Result<TierCleanupRetryReport, AdminError> {
|
||||
let page = list_tier_free_versions_for_tier(
|
||||
api.clone(),
|
||||
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
|
||||
None,
|
||||
None,
|
||||
cancel.clone(),
|
||||
recovery_work_budget(DEFAULT_FREE_VERSION_RECOVERY_LIMIT),
|
||||
Some(tier_name),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| tier_cleanup_retry_error())?;
|
||||
let mut report = TierCleanupRetryReport::new(tier_name, TierCleanupRetryStatus::Accepted);
|
||||
report.scanned = page.scanned_entries;
|
||||
report.scan_truncated = page.truncated;
|
||||
for object in page.items {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(tier_cleanup_retry_error());
|
||||
}
|
||||
if enqueue_recovered_free_version_with_cancel(&api, object, &cancel).await {
|
||||
report.enqueued += 1;
|
||||
} else {
|
||||
report.failed += 1;
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FreeVersionRecoveryStats {
|
||||
pub scanned: usize,
|
||||
@@ -302,6 +453,18 @@ pub(super) async fn list_tier_free_versions_with_budget(
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
work_budget: RecoveryWorkBudget,
|
||||
) -> Result<FreeVersionRecoveryPage> {
|
||||
list_tier_free_versions_for_tier(api, limit, bucket_marker, object_marker, cancel_token, work_budget, None).await
|
||||
}
|
||||
|
||||
async fn list_tier_free_versions_for_tier(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
work_budget: RecoveryWorkBudget,
|
||||
tier_name: Option<&str>,
|
||||
) -> Result<FreeVersionRecoveryPage> {
|
||||
let mut page = FreeVersionRecoveryPage {
|
||||
items: Vec::new(),
|
||||
@@ -539,7 +702,7 @@ pub(super) async fn list_tier_free_versions_with_budget(
|
||||
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
|
||||
continue;
|
||||
}
|
||||
if is_recoverable_tier_free_version(&oi) {
|
||||
if is_recoverable_tier_free_version_for_tier(&oi, tier_name) {
|
||||
let current_cursor = RecoveryCursor {
|
||||
bucket: bucket.name.clone(),
|
||||
object: oi.name.clone(),
|
||||
@@ -547,7 +710,15 @@ pub(super) async fn list_tier_free_versions_with_budget(
|
||||
page.items.push(oi);
|
||||
page.next_bucket_marker = Some(current_cursor.bucket.clone());
|
||||
page.next_object_marker = Some(current_cursor.object.clone());
|
||||
if page.items.len() >= limit && truncated_after.is_none() {
|
||||
if page.items.len() >= limit && tier_name.is_some() {
|
||||
// Manual passes do not expose a continuation cursor, so they can
|
||||
// stop within one key. Background recovery keeps complete key
|
||||
// groups to avoid skipping versions when it resumes by key.
|
||||
page.truncated = true;
|
||||
cancel.cancel();
|
||||
draining_after_truncation = true;
|
||||
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
|
||||
} else if page.items.len() >= limit && truncated_after.is_none() {
|
||||
truncated_after = Some(current_cursor);
|
||||
}
|
||||
}
|
||||
@@ -624,6 +795,10 @@ fn mark_scan_truncated_if_needed(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_recoverable_tier_free_version_for_tier(oi: &ObjectInfo, tier_name: Option<&str>) -> bool {
|
||||
is_recoverable_tier_free_version(oi) && tier_name.is_none_or(|tier| oi.transitioned_object.tier == tier)
|
||||
}
|
||||
|
||||
fn is_recoverable_tier_free_version(oi: &ObjectInfo) -> bool {
|
||||
// A free version transitioned to an unversioned remote tier legally carries an
|
||||
// empty `version_id` (see AGENTS.md: a tier version of `None`/`""` means the
|
||||
|
||||
@@ -564,12 +564,39 @@ pub(crate) async fn validate_bucket_reconstruction_in(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate against disk while retaining an already-held Object Lock fence.
|
||||
/// Resolve the authoritative bucket incarnation id for `opts`:
|
||||
/// 1. When namespace locking is enabled and `opts.object_lock_config_snapshot` is
|
||||
/// present, validate and return the snapshot's incarnation under its held
|
||||
/// lifecycle and metadata-transaction fences.
|
||||
/// 2. When namespace locking is enabled and `opts.bucket_lifecycle_lock_fence` is
|
||||
/// present and not lost, reuse the store's `BucketFenceRegistry` memo if a
|
||||
/// registered lifecycle read guard is currently held without lock loss.
|
||||
/// 3. Otherwise, acquire the bucket metadata transaction read lock (unless
|
||||
/// already held on `opts`) and read the incarnation from disk.
|
||||
pub(crate) async fn get_bucket_incarnation_id_for_options_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<Uuid> {
|
||||
if !ctx.lock_manager().is_disabled() {
|
||||
if let Some(snapshot) = opts.object_lock_config_snapshot.as_ref() {
|
||||
let store = object_store_in(ctx).await?;
|
||||
return snapshot
|
||||
.validated_bucket_incarnation_for(store.id, bucket, opts.expected_bucket_incarnation_id)
|
||||
.ok_or_else(|| {
|
||||
Error::other("Object Lock snapshot does not hold a valid metadata transaction fence for this bucket")
|
||||
});
|
||||
}
|
||||
if opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(|fence| !fence.is_lock_lost())
|
||||
&& let Some(store) = object_store_if_initialized_in(ctx).await
|
||||
&& let Some(incarnation) = store.bucket_fence_registry.validated_incarnation(bucket)
|
||||
{
|
||||
return Ok(incarnation);
|
||||
}
|
||||
}
|
||||
let sys = bucket_metadata_sys_of(ctx)?;
|
||||
let sys = sys.read().await.clone();
|
||||
let guard = acquire_bucket_metadata_transaction_read_lock_for_options_in(ctx, bucket, opts).await?;
|
||||
@@ -3186,9 +3213,17 @@ pub mod test_support {
|
||||
use crate::store::init_local_disks_with_instance_ctx;
|
||||
|
||||
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
let mut dirs = Vec::with_capacity(4);
|
||||
let mut endpoints = Vec::with_capacity(4);
|
||||
for disk_idx in 0..4 {
|
||||
isolated_store_over_temp_disks_with_count(4).await
|
||||
}
|
||||
|
||||
pub async fn isolated_store_over_temp_disks_with_count(disk_count: usize) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
assert!(
|
||||
matches!(disk_count, 4 | 16),
|
||||
"isolated store fixture requires a registered logical set geometry"
|
||||
);
|
||||
let mut dirs = Vec::with_capacity(disk_count);
|
||||
let mut endpoints = Vec::with_capacity(disk_count);
|
||||
for disk_idx in 0..disk_count {
|
||||
let dir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut endpoint =
|
||||
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
|
||||
@@ -3201,7 +3236,7 @@ pub mod test_support {
|
||||
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
drives_per_set: disk_count,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "metadata-sys-cache-test".to_string(),
|
||||
platform: "test".to_string(),
|
||||
|
||||
@@ -235,6 +235,8 @@ impl QuotaChecker {
|
||||
Ok(updated_at)
|
||||
}
|
||||
|
||||
/// Return quota configuration with optional scanner usage, independently
|
||||
/// of the usage requirements enforced by write admission.
|
||||
pub async fn get_quota_stats(&self, bucket: &str) -> Result<(BucketQuota, Option<u64>), QuotaError> {
|
||||
// If bucket doesn't exist, return ConfigNotFound error
|
||||
if !self.bucket_exists(bucket).await {
|
||||
@@ -244,37 +246,28 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
let quota = self.get_quota_config(bucket).await?;
|
||||
let current_usage = if quota.uses_durable_reservations() {
|
||||
self.report_only_usage(bucket).await
|
||||
} else {
|
||||
Some(self.get_real_time_usage(bucket).await?)
|
||||
};
|
||||
let current_usage = self.report_only_usage(bucket).await;
|
||||
|
||||
Ok((quota, current_usage))
|
||||
}
|
||||
|
||||
/// Scanner usage for a bucket the reservation ledger admits is reporting
|
||||
/// input only, so a failed lookup degrades to "unknown" and is counted
|
||||
/// instead of becoming an error the write path would fail on.
|
||||
/// Best-effort usage for statistics and durable admission reports.
|
||||
/// Missing scanner coverage is unknown, not an error or a zero-sized bucket.
|
||||
async fn report_only_usage(&self, bucket: &str) -> Option<u64> {
|
||||
// The plain lookup: a report must not wait for an early snapshot reload.
|
||||
let usage = get_bucket_usage_memory(bucket).await;
|
||||
match self.usage_or_degraded_baseline(bucket, usage).await {
|
||||
Ok(usage) => Some(usage),
|
||||
Err(err) => {
|
||||
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
|
||||
debug!(
|
||||
event = EVENT_QUOTA_USAGE_REPORT,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_QUOTA,
|
||||
state = "usage_unknown",
|
||||
bucket = %bucket,
|
||||
error = %err,
|
||||
"durable quota usage report degraded"
|
||||
);
|
||||
None
|
||||
}
|
||||
if usage.is_none() {
|
||||
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
|
||||
debug!(
|
||||
event = EVENT_QUOTA_USAGE_REPORT,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_QUOTA,
|
||||
state = "usage_unknown",
|
||||
bucket = %bucket,
|
||||
"quota usage report degraded"
|
||||
);
|
||||
}
|
||||
usage
|
||||
}
|
||||
|
||||
/// Re-save `bucket`'s quota under the sharded durable reservation
|
||||
@@ -544,6 +537,8 @@ mod tests {
|
||||
use serial_test::serial;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod reporting_retirement;
|
||||
|
||||
/// Regression (PR #5307 / s3-tests `test_100_continue_error_retry`): a
|
||||
/// bucket with no persisted metadata has no quota, so the admission check
|
||||
/// must pass and let the request reach its NoSuchBucket answer. With the
|
||||
@@ -744,8 +739,12 @@ mod tests {
|
||||
1,
|
||||
"fail-closed admission takes the early-reload lookup"
|
||||
);
|
||||
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 2);
|
||||
let (_, usage) = checker
|
||||
.get_quota_stats(&legacy)
|
||||
.await
|
||||
.expect("legacy quota statistics are reporting only");
|
||||
assert_eq!(usage, None);
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 1);
|
||||
assert_eq!(
|
||||
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
|
||||
0,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::disk::DiskAPI as _;
|
||||
use crate::object_api::{ObjectOptions, PutObjReader};
|
||||
use crate::storage_api_contracts::object::ObjectIO as _;
|
||||
use crate::test_metrics::CapturingRecorder;
|
||||
use rustfs_data_usage::{BucketUsageInfo, DataUsageInfo};
|
||||
use std::time::SystemTime;
|
||||
|
||||
async fn stop_reporting_fixture(store: &crate::store::ECStore) {
|
||||
if let Some(cancel) = store.ctx.background_cancel_token() {
|
||||
cancel.cancel();
|
||||
}
|
||||
for pool in &store.pools {
|
||||
for set in &pool.disk_set {
|
||||
let disks = set.disks.read().await.clone();
|
||||
for disk in disks.iter().flatten() {
|
||||
disk.close().await.expect("stop fixture disk monitoring");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_reporting_snapshot(
|
||||
store: Arc<crate::store::ECStore>,
|
||||
bucket: &str,
|
||||
source: &str,
|
||||
size: u64,
|
||||
) -> Option<(String, Vec<u8>)> {
|
||||
if source == "missing" {
|
||||
return None;
|
||||
}
|
||||
let mut info = DataUsageInfo {
|
||||
last_update: Some(SystemTime::now()),
|
||||
scanner_epoch: Some(1),
|
||||
scanner_cycle: Some(1),
|
||||
buckets_count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
info.buckets_usage.insert(
|
||||
bucket.to_owned(),
|
||||
BucketUsageInfo {
|
||||
size,
|
||||
objects_count: 1,
|
||||
versions_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
info.bucket_sizes.insert(bucket.to_owned(), size);
|
||||
info.calculate_totals();
|
||||
let object = match source {
|
||||
"legacy" => rustfs_data_usage::LEGACY_DATA_USAGE_OBJECT_NAME,
|
||||
"incomplete" => rustfs_data_usage::DATA_USAGE_OBJECT_NAME,
|
||||
"complete" => {
|
||||
info.usage_snapshot_complete = true;
|
||||
rustfs_data_usage::DATA_USAGE_OBJECT_NAME
|
||||
}
|
||||
"observed" => {
|
||||
info.usage_snapshot_complete = true;
|
||||
info.usage_snapshot_converged = Some(false);
|
||||
info.usage_snapshot_authoritative_baseline = Some(DataUsageInfo::default().snapshot_identity());
|
||||
rustfs_data_usage::DATA_USAGE_OBSERVED_OBJECT_NAME
|
||||
}
|
||||
_ => panic!("unknown scanner fixture source"),
|
||||
};
|
||||
let path = format!("{}/{object}", crate::disk::BUCKET_META_PREFIX);
|
||||
let bytes = serde_json::to_vec(&info).expect("scanner fixture encoding");
|
||||
crate::config::com::save_config(store, &path, bytes.clone())
|
||||
.await
|
||||
.expect("persist scanner fixture");
|
||||
if source == "complete" {
|
||||
crate::data_usage::replace_bucket_usage_memory_from_info(&info).await;
|
||||
}
|
||||
Some((path, bytes))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn durable_quota_reporting_retires_degraded_floors_without_changing_ledger_admission() {
|
||||
let recorder = CapturingRecorder::default();
|
||||
let _recorder = metrics::set_default_local_recorder(&recorder);
|
||||
for protocol in [1, 2] {
|
||||
for source in ["missing", "legacy", "incomplete", "observed", "complete"] {
|
||||
let (_dirs, store, sys) = isolated_durable_quota_store().await;
|
||||
let checker = QuotaChecker::new(sys);
|
||||
let bucket = format!("quota-report-{protocol}-{}", Uuid::new_v4().simple());
|
||||
let quota = if protocol == 1 {
|
||||
BucketQuota::new(Some(4096))
|
||||
} else {
|
||||
BucketQuota::new_sharded(4096)
|
||||
};
|
||||
create_bucket_with_quota_json(&store, &bucket, serde_json::to_vec("a).expect("quota fixture encoding")).await;
|
||||
let snapshot = install_reporting_snapshot(store.clone(), &bucket, source, 1_000_000).await;
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
let unavailable_before = recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]);
|
||||
let check = checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, 512)
|
||||
.await
|
||||
.expect("reporting cannot fail ledger admission");
|
||||
let (_, usage) = checker.get_quota_stats(&bucket).await.expect("best-effort quota statistics");
|
||||
let preview = checker
|
||||
.check_quota_with_usage_reporting(&bucket, QuotaOperation::PutObject, 512, true)
|
||||
.await
|
||||
.expect("admin preview uses the same optional report");
|
||||
|
||||
// Exercise the real storage entry point before testing the new
|
||||
// reporting semantics: the same ledger must admit and refuse
|
||||
// these writes even when a scanner floor claims a million bytes.
|
||||
let mut too_large = PutObjReader::from_vec(vec![0x5a; 8192]);
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.put_object(&bucket, "too-large", &mut too_large, &ObjectOptions::default())
|
||||
.await,
|
||||
Err(StorageError::QuotaExceeded { .. })
|
||||
),
|
||||
"protocol={protocol}, source={source}"
|
||||
);
|
||||
let mut fits = PutObjReader::from_vec(vec![0x5a; 512]);
|
||||
store
|
||||
.put_object(&bucket, "fits", &mut fits, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("ledger admits 512 bytes");
|
||||
let mut fills = PutObjReader::from_vec(vec![0x5a; 3584]);
|
||||
store
|
||||
.put_object(&bucket, "fills", &mut fills, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("ledger admits exactly the remaining quota");
|
||||
let mut exceeds = PutObjReader::from_vec(vec![0x5a; 1]);
|
||||
assert!(
|
||||
matches!(
|
||||
store
|
||||
.put_object(&bucket, "exceeds", &mut exceeds, &ObjectOptions::default())
|
||||
.await,
|
||||
Err(StorageError::QuotaExceeded { .. })
|
||||
),
|
||||
"ledger cannot undercount protocol={protocol}, source={source}"
|
||||
);
|
||||
|
||||
assert!(check.allowed && check.uses_durable_reservations);
|
||||
let expected_usage = (source == "complete").then_some(1_000_000);
|
||||
assert_eq!(
|
||||
check.current_usage, expected_usage,
|
||||
"protocol={protocol}, source={source}: a floor is not a current report"
|
||||
);
|
||||
assert_eq!(check.remaining, expected_usage.map(|_| 0));
|
||||
assert_eq!(usage, expected_usage, "statistics use the same best-effort reporting contract");
|
||||
assert_eq!(preview.current_usage, expected_usage);
|
||||
assert_eq!(preview.remaining, expected_usage.map(|_| 0));
|
||||
assert_eq!(preview.allowed, source != "complete", "unknown preview is advisory, not a reservation");
|
||||
assert_eq!(
|
||||
recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]) - unavailable_before,
|
||||
if source == "complete" { 0 } else { 3 },
|
||||
"each unknown check/statistics/preview report is counted"
|
||||
);
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
|
||||
assert_eq!(persisted_quota(&store, &bucket).await.reservation_protocol, Some(protocol));
|
||||
if let Some((path, original)) = snapshot {
|
||||
assert_eq!(
|
||||
crate::config::com::read_config(store.clone(), &path)
|
||||
.await
|
||||
.expect("retained scanner evidence"),
|
||||
original
|
||||
);
|
||||
}
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("fixture cache cleanup");
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
stop_reporting_fixture(&store).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn snapshot_quota_admission_retains_degraded_floors_until_migration() {
|
||||
for source in ["missing", "legacy", "incomplete", "observed"] {
|
||||
let (_dirs, store, _) = isolated_durable_quota_store().await;
|
||||
let bucket = format!("quota-compat-{}", Uuid::new_v4().simple());
|
||||
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
|
||||
// An unregistered checker models a snapshot-protocol reader without
|
||||
// starting the optional background migration during this assertion.
|
||||
let checker = QuotaChecker::new(Arc::new(RwLock::new(BucketMetadataSys::new(store.clone()))));
|
||||
install_reporting_snapshot(store.clone(), &bucket, source, 1000).await;
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
let check = checker.check_quota(&bucket, QuotaOperation::PutObject, 512).await;
|
||||
if source == "missing" {
|
||||
assert!(matches!(check, Err(QuotaError::UsageUnavailable { .. })));
|
||||
} else {
|
||||
let check = check.expect("legacy admission retains its grounded compatibility fallback");
|
||||
assert_eq!(check.current_usage, Some(1000));
|
||||
assert!(check.allowed && !check.uses_durable_reservations);
|
||||
assert!(
|
||||
!checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, 4096)
|
||||
.await
|
||||
.expect("legacy quota decision")
|
||||
.allowed
|
||||
);
|
||||
}
|
||||
assert_eq!(persisted_quota(&store, &bucket).await.reservation_protocol, None);
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("fixture cache cleanup");
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
stop_reporting_fixture(&store).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[serial]
|
||||
async fn quota_without_limit_keeps_the_zero_usage_io_fast_path() {
|
||||
let (_dirs, store, sys) = isolated_durable_quota_store().await;
|
||||
let bucket = format!("quota-unlimited-{}", Uuid::new_v4().simple());
|
||||
create_bucket_with_quota_json(
|
||||
&store,
|
||||
&bucket,
|
||||
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
|
||||
)
|
||||
.await;
|
||||
let recorder = CapturingRecorder::default();
|
||||
let _recorder = metrics::set_default_local_recorder(&recorder);
|
||||
let check = QuotaChecker::new(sys)
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, u64::MAX)
|
||||
.await
|
||||
.expect("unlimited quota fast path");
|
||||
assert!(check.allowed);
|
||||
assert_eq!(check.current_usage, None);
|
||||
assert_eq!(check.quota_limit, None);
|
||||
assert_eq!(recorder.counter_value(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL, &[]), 0);
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
|
||||
stop_reporting_fixture(&store).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_statistics_remain_available_without_a_complete_scanner_snapshot() {
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
for configuration in ["unconfigured", "unlimited", "snapshot"] {
|
||||
for source in ["missing", "legacy", "incomplete", "observed", "complete", "empty"] {
|
||||
let (_dirs, store, _) = isolated_durable_quota_store().await;
|
||||
let bucket = format!("quota-statistics-{}", Uuid::new_v4().simple());
|
||||
match configuration {
|
||||
"unconfigured" => store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket without a quota configuration"),
|
||||
"unlimited" => {
|
||||
create_bucket_with_quota_json(
|
||||
&store,
|
||||
&bucket,
|
||||
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"snapshot" => create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
// An unregistered checker keeps legacy migration out of the
|
||||
// assertion: reporting must work before a protocol upgrade.
|
||||
let metadata_sys = BucketMetadataSys::new(store.clone());
|
||||
let (metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
|
||||
metadata_sys.set(bucket.clone(), metadata).await;
|
||||
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
|
||||
let (snapshot_source, size) = if source == "empty" { ("complete", 0) } else { (source, 1000) };
|
||||
install_reporting_snapshot(store.clone(), &bucket, snapshot_source, size).await;
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
|
||||
let statistics = checker.get_quota_stats(&bucket).await;
|
||||
let early_reloads = crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket);
|
||||
let missing_bucket = format!("missing-{}", Uuid::new_v4().simple());
|
||||
let missing_statistics = checker.get_quota_stats(&missing_bucket).await;
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("fixture cache cleanup");
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
stop_reporting_fixture(&store).await;
|
||||
|
||||
let (quota, usage) = statistics.unwrap_or_else(|error| {
|
||||
panic!("configuration={configuration}, source={source}: quota statistics must remain available: {error}")
|
||||
});
|
||||
assert_eq!(quota.quota, (configuration == "snapshot").then_some(4096));
|
||||
assert_eq!(
|
||||
usage,
|
||||
(snapshot_source == "complete").then_some(size),
|
||||
"configuration={configuration}, source={source}: only known usage is numeric"
|
||||
);
|
||||
assert_eq!(early_reloads, 0, "read-only statistics must not force an early snapshot reload");
|
||||
assert!(
|
||||
matches!(missing_statistics, Err(QuotaError::ConfigNotFound { bucket }) if bucket == missing_bucket),
|
||||
"reporting must still reject a missing bucket"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_statistics_preserve_configuration_errors() {
|
||||
let (_dirs, store, _) = isolated_durable_quota_store().await;
|
||||
let bucket = format!("quota-invalid-{}", Uuid::new_v4().simple());
|
||||
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
|
||||
let metadata_sys = BucketMetadataSys::new(store.clone());
|
||||
let (mut metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
|
||||
Arc::make_mut(&mut metadata).quota_config_json = b"{not-json".to_vec();
|
||||
metadata_sys.set(bucket.clone(), metadata).await;
|
||||
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
|
||||
|
||||
let statistics = checker.get_quota_stats(&bucket).await;
|
||||
stop_reporting_fixture(&store).await;
|
||||
|
||||
assert!(
|
||||
matches!(statistics, Err(QuotaError::InvalidConfig { .. })),
|
||||
"unavailable reporting must not hide malformed quota configuration"
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user