Compare commits

..

67 Commits

Author SHA1 Message Date
cxymds 14b3cffe8f Merge branch 'main' into release
* main:
  fix(rebalance): align activation locks and preserve retryable causes (#7551)
2026-09-09 12:32:55 +08:00
houseme c62384e58d ci(test): enable local KMS for s3 compatibility tests (#7553)
Configure the s3-tests harness with a local KMS key so SSE-KMS cases run in CI without relying on an external KMS service.

Also move the anonymous POST default SSE-KMS regression onto the shared local KMS test environment.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 12:08:06 +08:00
cxymds 789f1832a4 fix(rebalance): align activation locks and preserve retryable causes (#7551) 2026-09-09 03:25:05 +00:00
houseme 3f30f6c139 test(heal): reduce W13 evidence helper arguments
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 10:26:59 +08:00
houseme 584a52ce3d test(scanner): fix segment proof fixtures
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 10:22:13 +08:00
houseme 76e025a50c test(scanner): add W13 MRF evidence runner (#7547)
Add a W13 durable MRF evidence runner that emits measured G07, G08, and P4 JSON artifacts and release descriptors through the existing scanner/heal bundle gate.

The runner now executes the ignored MRF replay evidence test with an exact full test path, validates raw artifact kinds and gate decisions, prepares Linux tmpfs-backed ENOSPC roots for G08, and documents the Linux/long-soak boundaries.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 10:08:59 +08:00
houseme b40eb193df test(scanner): roll out distributed e2e selection (#7549)
Refresh the committed e2e-distributed selection digest after the EC8+4 Scanner/Heal evidence case joined the profile, and document the registry-aware runner entry for the distributed case.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 10:08:49 +08:00
houseme 023c987397 test(scanner): run scoped fallback cases on dedicated stack (#7550)
Linux libtest defaults can abort these deep EC-store futures before the assertions run. Wrap the async scoped fallback cases in the repository's dedicated-stack test pattern so the default cargo test path exercises the oracle directly.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 10:00:06 +08:00
houseme a4b265bf77 feat(scanner): enable segment reuse activation gate (#7546)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 09:09:17 +08:00
houseme c176220a34 test(scanner): bind MRF release artifacts to evidence kinds (#7545)
Require Scanner/Heal G07/G08/P4 JSON artifacts to declare the exact MRF evidence kind expected for each release-bundle field. This keeps generic measured JSON from satisfying durable replay, ENOSPC, replica-loss, scale, replay-cost, retained-responsibility, or cleanup-GC soak gates.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 08:34:55 +08:00
houseme 82ac2dff05 feat(scanner): admit durable segment proof replay (#7544)
* feat(scanner): admit durable segment proof evidence

Validate complete set snapshot segment invalidation proof metadata against the current dirty usage generation window and scanner process epoch before clearing durable producer and restart-gap activation blockers.

Production segment reuse remains gated by the explicit activation flag.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): replay segment proof from root snapshots

Carry segment invalidation proof metadata into root snapshot set states so a complete published baseline can replay the durable producer evidence recorded by each set cache.

Keep the field additive for older readers and leave incomplete or LKG set states unproven.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 08:34:50 +08:00
Zhengchao An e24eae9eaa fix: address release acceptance regressions (#7541) 2026-09-09 08:33:59 +08:00
houseme 8617f2701b test(scanner): add W16 release evidence runner (#7543)
Add a W16 Scanner/Heal evidence runner that executes the recovery-intent crash-boundary and quota-authority lanes, emits measured G04/G12 JSON artifacts, and validates the resulting single-gate release descriptors.

Wire its shell self-test into script-tests and document the release evidence entry point.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 08:26:13 +08:00
houseme 8d5592f83e test(scanner): list full P4 release evidence fields (#7542)
Align the Scanner/Heal release requirement registry with the release bundle gate for P4 so the closure checklist advertises MRF scale, replay cost, retained responsibility, and cleanup/GC soak evidence together.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 08:24:02 +08:00
houseme b5c9229e77 chore: integrate main CI gates into release
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-09 08:02:35 +08:00
houseme a974e50b1d test(scanner): wait for EC8+4 heal control readiness (#7540) 2026-09-09 05:17:13 +08:00
houseme 081910e825 feat(scanner): persist segment invalidation proof metadata (#7539) 2026-09-09 05:17:03 +08:00
Zhengchao An a722fa80d5 fix(ci): require every selected validation lane before merge (#7529) 2026-09-09 05:16:48 +08:00
Zhengchao An 9ecb500cbf test(odm): wait for pull counters before status assertions (#7533) 2026-09-08 17:35:43 +00:00
houseme 7f7e4fe40b feat(scanner): bind producer evidence to dirty generations (#7538)
Record scanner segment producer identities with the dirty usage generation they invalidated, then expose a cycle-local producer evidence snapshot for segment reuse activation preflight.

Production activation remains fail-closed until durable producer identity and restart-gap proof are available.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 01:11:05 +08:00
houseme 684e6f313a test(scanner): require hard evidence artifact payloads (#7537)
Require Scanner/Heal release bundle JSON artifacts to carry hard-domain evidence fields for mixed-version, crash, capacity, disk-full, replica-loss, and MRF cleanup gates. This prevents a descriptor from approving a hard gate while pointing at a generic measured artifact that lacks the boundary-specific oracle fields.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 01:10:48 +08:00
houseme b3a02f305e feat(scanner): surface cold segment reuse oracle (#7536)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:48:45 +08:00
houseme 0ea9c19569 feat(scanner): carry segment activation preflight evidence (#7535)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:25:46 +08:00
houseme ae662da256 test(heal): cover MRF replay proof cleanup (#7534)
Exercise the committed MRF replay checkpoint path with a real ECStore-backed bucket incarnation, retain the replay anchor until an exact verified proof arrives, then delete the proof-discharged checkpoint and prove a subsequent restart does not resurrect the replayed work.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:25:37 +08:00
houseme b5d33a1f4e fix(scanner): keep G09 source binary path clean (#7532)
Keep checksum verification output out of the command substitution that resolves the previous-release binary for the G09 runner.

Also tolerate non-GNU sha256sum in local self-tests by falling back to shasum when GNU --check support is unavailable.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:21:39 +08:00
houseme d288496752 fix(scanner): reject invalid G09 runner test selection (#7531)
Validate the selected G09 evidence lane before process-substitution case expansion so --plan-only cannot turn an unknown --test value into an empty successful plan.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:13:56 +08:00
houseme 40baedba4f Merge branches 'release', 'release' and 'main' of github.com:rustfs/rustfs into release
* 'release' of github.com:rustfs/rustfs:
  test(scanner): emit G09 release bundle gate evidence (#7530)
  test(e2e): add G09 upgrade evidence runner (#7525)
  fix(scanner): respect cargo target dir in G09 runner (#7528)
  test(scanner): preflight G09 evidence disk space (#7527)
  fix(scanner): replay recovery intents while disabled (#7521)
  test(scanner): add G09 upgrade evidence runner (#7524)
  test(scanner): assemble release evidence bundles (#7526)
  feat(scanner): implement V2 evidence preflights (#7523)
  test(scanner): add G09 upgrade evidence runner (#7522)

* 'release' of github.com:rustfs/rustfs:
  test(scanner): emit G09 release bundle gate evidence (#7530)
  test(e2e): add G09 upgrade evidence runner (#7525)
  fix(scanner): respect cargo target dir in G09 runner (#7528)
  test(scanner): preflight G09 evidence disk space (#7527)
  fix(scanner): replay recovery intents while disabled (#7521)
  test(scanner): add G09 upgrade evidence runner (#7524)
  test(scanner): assemble release evidence bundles (#7526)
  feat(scanner): implement V2 evidence preflights (#7523)
  test(scanner): add G09 upgrade evidence runner (#7522)

* 'main' of github.com:rustfs/rustfs:
  fix(targets): reject trailing batch items (#7508)
  fix(ci): bind performance runs to selected inputs (#7512)
  fix(replication): close the GA blocker set from backlog#2366 (#7503)
2026-09-09 00:10:33 +08:00
houseme 0c3fd48c22 test(scanner): emit G09 release bundle gate evidence (#7530)
* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* fix(targets): reject trailing batch items (#7508)

* test(scanner): emit G09 release bundle gate evidence

Write a bundle-ready G09 gate descriptor from the Linux upgrade evidence runner and validate the single G09 gate with the shared release-bundle rules without approving the full Scanner/Heal release.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: cui fliter <imcusg@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:06:59 +08:00
houseme 3e8b0e7c83 test(e2e): add G09 upgrade evidence runner (#7525)
* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* test(e2e): add G09 upgrade evidence runner

Add a Linux x86_64 runner that downloads the pinned previous release, builds the current RustFS binary, runs the mixed-version and rollback upgrade compatibility lanes, and verifies the required Scanner/Heal G09 raw evidence artifacts.

Document the runner and add a shell self-test for help, dry-run, SHA validation, and non-empty artifact directory guards.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-09 00:05:36 +08:00
houseme dd2af9ff88 fix(scanner): respect cargo target dir in G09 runner (#7528)
* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* fix(targets): reject trailing batch items (#7508)

* fix(scanner): respect cargo target dir in G09 runner

Write the RustFS feature stamp under the resolved Cargo target directory so remote validation hosts with CARGO_TARGET_DIR set can reuse the built PR-head binary.

Extend the runner plan/self-test path to cover relative target-dir resolution.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: cui fliter <imcusg@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:58:20 +08:00
houseme 5db0e14d04 test(scanner): preflight G09 evidence disk space (#7527)
* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* fix(targets): reject trailing batch items (#7508)

* test(scanner): preflight G09 evidence disk space

Fail the Scanner/Heal G09 upgrade evidence runner before downloading or building when the validation host does not have enough free space for a full raw evidence pass.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: cui fliter <imcusg@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:58:08 +08:00
houseme 9429225cf4 fix(scanner): replay recovery intents while disabled (#7521)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:50:13 +08:00
houseme 857584b3c2 test(scanner): add G09 upgrade evidence runner (#7524)
* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* test(scanner): add G09 upgrade evidence runner

Add a reusable Scanner/Heal G09 runner for Linux PR-head validation. The script downloads the pinned previous release, builds the current checkout, runs the mixed-version and rollback upgrade E2E lanes, and validates the measured raw artifacts before they can be consumed by the release bundle gate.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:50:02 +08:00
houseme cfda9b451c test(scanner): assemble release evidence bundles (#7526)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:49:49 +08:00
houseme 354e49de4c feat(scanner): implement V2 evidence preflights (#7523)
* feat(scanner): wire dirty usage producer identities

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add segment activation preflight proof

Keep scanner segment reuse behind a structured activation preflight so release evidence can prove the production gate remains disabled until every producer, generation, overflow, cold-oracle, and distributed invalidation check is satisfied.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): expose distributed invalidation evidence

Record an explicit distributed segment invalidation evidence summary when remote dirty usage snapshots are bound to the current activity window and the authenticated scoped ACK capability probe succeeds.

Reject peer dirty usage snapshots that contradict the peer activity pending bit so scoped ACKs fail closed instead of clearing an unadvertised remote mutation.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:39:05 +08:00
houseme 8e987ce0a6 test(scanner): add G09 upgrade evidence runner (#7522)
* fix(replication): close the GA blocker set from backlog#2366 (#7503)

* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.

* fix(ci): bind performance runs to selected inputs (#7512)

* test(scanner): add G09 upgrade evidence runner

Add a reusable Linux x86_64 runner for the Scanner/Heal G09 mixed-version and rollback upgrade evidence lanes.

The helper reads the pinned previous-release asset metadata from the upgrade workflow, verifies the downloaded binary, builds the current head, runs both ignored E2E tests, and fails unless the expected G09 JSON artifacts exist.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: 唐小鸭 <tangtang1251@qq.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:38:54 +08:00
cui fliter 8462b3492b fix(targets): reject trailing batch items (#7508) 2026-09-08 15:32:24 +00:00
houseme 929f836e40 test(heal): persist MRF rollback mirror boundary (#7520)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:22:41 +08:00
houseme c7dec044eb test(e2e): emit scanner heal G09 upgrade evidence (#7519)
Record measured Scanner/Heal G09 evidence artifacts from the upgrade compatibility lanes when a fresh evidence directory is provided.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:18:36 +08:00
Zhengchao An ac44f8968e fix(ci): bind performance runs to selected inputs (#7512) 2026-09-08 23:16:54 +08:00
houseme 14a59f7770 test(scanner): require bounded retry window evidence (#7517)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 23:10:02 +08:00
houseme 084e9c4b82 test(scanner): require MRF cleanup delete ENOSPC evidence (#7518)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:59:32 +08:00
唐小鸭 46907c05cf fix(replication): close the GA blocker set from backlog#2366 (#7503)
* fix(replication): close GA blockers from backlog#2366

Implements the P1 set from the pre-GA replication audit:

- Replication rule tag filters now require every And.Tag to match, replacing
  the s3s OR semantics with a local AND matcher that fails closed on a
  malformed tag.
- A replicated group membership change no longer writes the group status, so
  a membership update carrying the default Enabled status cannot silently
  re-enable a disabled group on the peer.
- A successful IAM import schedules one collapsed full-IAM snapshot per remote
  peer instead of leaving the imported entities local-only.
- A pending endpoint refresh is redriven by the heavyweight reconcile tick,
  carries its own ilm-expiry override, and no longer blocks a remove that
  drops every unacknowledged peer.
- Site metrics expose local replication failure totals and rolling windows;
  node-level counters no longer report a constructed zero.
- set/remove-remote-target notify peer metadata caches before returning, so a
  follow-up put-bucket-replication on another node sees the target.
- Adds the site-replication operations runbook, a docs index, a replication
  support boundary section, and the Replication changelog section.

* fix(site-replication): resume only a locally driven endpoint refresh

The peer-side edit handler journals a pending endpoint refresh with an empty
`remote_peers` map and commits it inside the same request through
`apply_internal_peer_edit`. The reconcile tick could not tell that journal
from the coordinator's own: with no required peers it reads as complete on
sight, so the tick committed it with `edit_state` - losing the local-name
sync - and cleared it under the request that owned it, whose commit then
reported the refresh as changed and denied the coordinator the peer
acknowledgement it was waiting for.

Resume now runs only for a journal that carries the fan-out topology. A
receiver's journal stays for the coordinator to redrive with the same
refresh id, which is the path that already recovers it.

* fix(site-replication): keep an explicit disabled group status on a snapshot

Skipping the group-status write whenever an item carries members stopped a
membership change from re-enabling a disabled group, but it also silenced the
full-IAM snapshot, which always sends members together with the sender's real
status. A peer that did not have the group yet created it through
`GroupInfo::new` - enabled - so a bootstrap, a repair, or the snapshot an IAM
import now schedules handed every member of a frozen group live access there.

The madmin wire maps an unset `groupStatus` to Enabled, so only Enabled can be
a default. Disabled is always explicit and is applied again.

* fix(site-replication): schedule the import snapshot without recording a failure

`import-iam` reused the failure-recording path to queue its full-IAM
snapshot. That raises `retry_count` on every call, so three imports - the
normal shape of a bulk migration done one archive at a time - escalated a
healthy peer to `retryStats.failed` with the scheduling note shown as
`lastError`, which is exactly the signal the runbook tells operators to
repair. A full retry queue also turned a completed import into a 503.

Scheduling now only ensures the collapsed entry exists, and a failure to
schedule is logged instead of failing the request: the entities are already
imported and the reconcile pass still closes the gap.

* fix(admin): stop reporting replication failures as retries

`retries` is the minio-go counter for redeliveries, and mc prints it as such.
Filling it with the failure count claimed a redelivery that never happens: a
failed object is not retried by an event today, it waits for the scanner heal
pass. `errors` keeps the failure counters; `retries` stays zero until there is
a real redelivery to count, and the runbook now says so.

* perf(site-replication): aggregate failure windows without cloning bucket stats

`site_metrics_snapshot` went through `get_all`, which clones every bucket's
stats, and then scanned each target's sample deque twice. That deque is
bounded only by the one-hour window, so an unreachable target under load -
the case an operator polls this endpoint for - made every
`mc admin replicate status` copy the whole backlog and hold the read lock
against the failure path while doing it.

It now folds under the read lock and takes both windows in one walk. The
`max` against the serialized `last_minute` / `last_hour` snapshots is dropped:
those are stamped onto per-bucket clones elsewhere and are always zero in this
node-local cache.

* fix(site-replication): reject a conflicting ilm-expiry override on a re-run

The commit now reads the ilm-expiry override back out of the pending refresh
journal, so a second edit that asks for a different value had it dropped while
the request still reported success. Re-running without the flag keeps pinning
the recorded value - that is the documented way to redrive a stuck refresh -
but an explicit different value is now rejected instead of ignored.

* fix(admin): do not fail a remote-target write on a peer reload error

set/remove-remote-target propagated the peer metadata reload error, so a
target that was already persisted and live on this node reported a 5xx to the
client whenever one peer could not be reached. Every S3 bucket-config write
path treats that reload as best effort and only warns; these two admin
handlers now do the same, and the reason is logged with the bucket and action.

* fix(site-replication): undo every bucket a cut-short refresh rewrote

When a remove accepted on another node clears the refresh journal mid-pass,
only the bucket holding the lock at that moment had its restored target
undone. The buckets rewritten earlier in the same pass kept a target pointing
at the removed peer whenever the remove's own cleanup had already walked past
them. The undo now covers every bucket this pass rewrote, attempting all of
them so one failure does not strand the rest.

* fix(site-replication): keep replay running while an endpoint refresh is pending

A pending endpoint refresh took the whole heavyweight pass with it, so a peer
that never came back froze IAM and bucket replay to every healthy peer too -
the stall this journal's resume path was meant to end. The refresh arm now
drains the retry queue before returning; it replays per-peer deliveries
against the endpoints currently committed in state, so it is unaffected by the
edit in flight. Bucket wiring reconciliation still waits, because it rewrites
the very targets the refresh is changing, and the runbook now says so.

* test(e2e): cover the AND semantics of a two-tag replication filter

The acceptance matrix only had a single-tag rule, which matches under both AND
and OR semantics and therefore proved nothing about the filter this fix
changed. It now also carries a two-tag `And` rule - the shape
`mc replicate add --tags "k1=v1&k2=v2"` writes - and asserts that an object
with one of the two tags is not admitted while an object with both is.

No new test function, so the nightly selection digest is unchanged.

* refactor(site-replication): fold the refresh state-change error into one constructor

The endpoint-refresh work added three `s3_error!` invocation lines, which the
s3s footprint ratchet is meant to prevent. Five copies of the same
concurrent-change error now share one constructor, so the surface nets one
line smaller than main; the baseline is retightened to match.

* fix(site-replication): report a peer whose IAM snapshot waits for a repair

An escalated snapshot entry records a deletion a snapshot cannot replay, so
only a repair settles it and the marker must survive. Scheduling an import
snapshot therefore leaves that peer's entry alone - and now says so, instead
of returning success while nothing was scheduled for it.

* docs(operations): state the group-status and escalation convergence limits

Two boundaries the fixes in this branch make load-bearing: a membership change
never carries an enable, so a group disabled on one site only has to be
re-enabled there explicitly; and a peer holding an escalated IAM entry does
not receive a scheduled snapshot, including the one a bulk import schedules,
until a repair settles it.
2026-09-08 14:58:41 +00:00
houseme 921cc7ad94 fix(scanner): confirm recovery intent accept readback (#7516)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:53:21 +08:00
houseme bfa8df00e7 test(scanner): add release bundle dry-run fixture (#7515)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:53:04 +08:00
houseme 33a8469e27 test(scanner): require heal retry stats evidence (#7514)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:52:34 +08:00
houseme 128080ceb9 test(storage): cover MRF cleanup delete ENOSPC anchor (#7513)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:52:12 +08:00
唐小鸭 ae9fe62fb1 fix(sse): resolve 1.0.0 SSE/KMS blockers and P1 findings (#7511)
* fix(sse): resolve bucket default encryption per request

PUT and the POST-object/extract path resolved a bucket's default
encryption with a hard-coded "no explicit SSE-C" flag, so the default was
layered onto a request that already carried an SSE-C header triple and
then tripped that request's own mutual-exclusion check. Every bucket with
default encryption refused SSE-C single PUTs with 400 InvalidArgument,
while CreateMultipartUpload on the same bucket succeeded because it
resolves SSE elsewhere. Both call sites now derive the flag from the
request headers, as COPY already did.

The bucket default's KMS key id was also inherited independently of the
effective algorithm, so an explicit AES256 request against an aws:kms
default bucket produced a self-contradictory algorithm/key-id pair and
was rejected. The key id is now inherited only when the effective
algorithm is aws:kms, matching the storage-layer resolver.

Refs backlog#2368 B1, B2.

* fix(sse): refuse SSE-KMS without a running KMS service

A write requesting aws:kms on a node with no KMS service fell back to the
node-local SSE-S3 provider: the data key was wrapped with
RUSTFS_SSE_S3_MASTER_KEY while the object metadata still recorded
aws:kms and the requested KMS key id. The stored object claimed a KMS
protection it never had, under a key that was never consulted, and no
signal distinguished it from a genuine SSE-KMS object.

The managed-encryption path now asks the resolved DEK provider whether it
wraps with a node-local master key and refuses SSE-KMS in that case:
InvalidRequest when KMS was never configured, ServiceUnavailable when a
configured service is not running. The check sits after the per-key
authorization gate so an unauthorized caller still receives AccessDenied
whatever the KMS runtime state is, and asks the provider rather than a
parallel availability signal because the provider is what actually wraps
the key. A missing master key no longer answers an SSE-KMS request with
an SSE-S3-worded configuration error.

The SSE-S3 local fallback is unchanged.

Refs backlog#2368 B4.

* fix(ecstore): restore and archive tiers in stored coordinates

Multipart restore addressed the remote tier in plaintext coordinates
while the copy-back reads the stored representation. Each part received a
misaligned slice of the remote object whose length still satisfied the
range, the hash reader and the completion size check, so the restore
reported success and silently replaced the object's bytes. Encrypted and
compressed multipart objects were both affected. Restore now accumulates
stored part sizes, passes the stored length to the hash reader alongside
the plaintext length, and validates against the stored size.

The copy-back digests stored bytes, so its computed MD5 is not the
object's public ETag. Restore now preserves the object ETag on both the
single-part and multipart paths, and gives each restored part its own
recorded part ETag rather than the object-level value.

Transition also handed the tier the object's SSE headers and its
RustFS-wrapped data key as request headers. Any S3 target rejected an
SSE-C archive outright, an SSE-KMS archive asked the target to encrypt a
second time under a key id it does not own, and the wrapped DEK left the
cluster. The archive request now strips every SSE header and encryption
marker with the predicate the replication path already uses; the local
xl.meta keeps all of it, so read-through and restore are unaffected.

Objects restored by an affected release are not detected or repaired
retroactively and must be re-restored from the tier.

Refs backlog#2368 B3, B5; backlog#2369 P7.1.

* fix(rio): lock the v1 nonce layout within a segment

Decrypting a v1 segment tried three historical nonce layouts per frame,
independently for every frame. The last of them exists for streams
written before 1.0.0-alpha.91, which reused a segment's part nonce for
every block in it; because block zero's derived nonce equals that base
nonce, a frame encrypted at index zero authenticated at any position. An
attacker able to rewrite the underlying shards could replay it and have
the forged plaintext returned with 200 and an unchanged length. Shard
integrity uses a keyed-hash-free checksum, which such an attacker can
recompute, so it is not a barrier.

A segment now locks onto whichever layout decoded its first non-zero-index
frame and rejects any later frame needing a different one. That leaves one
residual shape: a stream built purely from repeats of frame zero has no
later frame to disagree. New RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK
(default true, so pre-alpha.91 objects keep decrypting) drops the third
layout entirely when set to false, which closes it. Turning it off refuses
pre-alpha.91 objects, so migrate them first by rewriting in place.

Refs backlog#2369 P2.

* fix(kms): reload a service that failed to start

POST /rustfs/admin/v3/kms/reload short-circuited whenever the persisted
configuration matched the in-memory one byte for byte. A node whose KMS
failed to start keeps that configuration and sits in Error, so the
documented recovery call returned "reloaded successfully" while leaving
the node down. Peers reached the same path through the reload broadcast,
so a cluster that lost Vault during a rolling restart had no working
recovery route other than the node-local start endpoint. Reload now
short-circuits only for a service that is actually running, and otherwise
reconfigures, which starts a service that is not running.

The AWS backend also advertised key-version enumeration through
kms/status, which its own documentation says it cannot do; the capability
and its golden snapshot now say false.

Refs backlog#2369 P1, P7.3.

* docs: record the SSE and KMS changes for 1.0.0

The Unreleased changelog section carried no entry for any encryption work
merged since 1.0.0-rc.5, including three items with operational impact:
the config-secret variable whose absence persists secrets in cleartext
with only a warning, the v2 frame write switch and its rolling-upgrade
constraint, and per-key authorization making a public bucket incompatible
with SSE-KMS objects. Adds those plus this batch, including the SSE-KMS
refusal as a breaking change with both routes out.

Also corrects four places where documentation contradicted the code: the
cleanup register still called encrypted range seek opt-in after its
default flipped, the Helm README claimed vault_mount_path only applies to
Transit while the template also feeds the KV2 mount, the disaster-recovery
drill listed bundle contents for backends whose export is refused with
501, and the Chinese README capability table predated most of the feature
set. Documents the SSE-S3 local master key as a first-class operational
mode with its rotation dead end, and what the v1 frame layout does and
does not authenticate.

Refs backlog#2369 P5.

* fix(kms): classify data-path KMS failures by what the caller can do

Only "key not found" and a backend outage were classified; every other
KMS failure that reached the S3 data path fell through to
500 InternalError with a generic message. A disabled or pending-deletion
key, a denied KMS grant, an encryption-context mismatch, an unsupported
algorithm, a credential or timeout failure, and a capability the
configured backend does not have all looked identical to a server fault.
SDKs therefore applied exponential backoff to configuration errors no
retry can fix, and monitoring counted every one of them against the
server's own error rate.

Unusable-key and request-side failures now answer 400, a denied grant
403, transient backend failures 503, and a missing backend capability
501. Damaged, unreadable, or unknown-format key material keeps its 500:
it is a server-side integrity fault, and existing tests pin it.

The classifier is deliberately separate from the admin lifecycle
mapping, which answers 404 for a missing key because there a key id is
the resource being addressed; on the data path it arrives inside a
request header or a bucket default. Messages either name what the caller
asked for or stay generic, with deployment-side detail left on the error
source the way the storage-IO mapping already does.

Refs backlog#2368 B6.

* fix(kms): track and renew static Vault tokens

Token authentication hard-coded "this token carries no lease", so the
renewal task never started, the remaining-TTL gauge was never published,
and nothing looked wrong. `vault token create` grants a 768-hour TTL by
default, so a cluster that had been healthy for a month turned every KMS
call into a 403 and could not recover without a restart or a
reconfigure. Production configuration validation only rejects the
literal dev-token, so an ordinary expiring token reaches a whole cluster.

The source now reads `auth/token/lookup-self` at login and adopts what
Vault reports. A token with no expiry behaves exactly as before. An
expiring renewable one is picked up by the existing renewal loop and
renewed at half TTL like every other auth method. An expiring
non-renewable one warns with its remaining lifetime and publishes the
gauge, so the fail-closed window is visible before it arrives.

The probe never fails the login: a policy that omits lookup-self, or a
Vault that is briefly unreachable, warns and falls back to exactly the
previous behaviour rather than taking down a deployment that works
today. The scripted Vault test double answers the lookup out of band so
existing scripts keep describing only the protocol under test.

Refs backlog#2369 P3.

* feat(sse): report SSE-C requests that arrive without TLS

An SSE-C request carries the customer's AES key in a request header, so
AWS S3 and MinIO both refuse one that did not arrive over TLS. RustFS
accepted them on any transport: a plaintext hop hands the key to anyone
on the path, and since the object cannot be read without that same key,
the exposure lasts as long as the object does.

Refusing outright is the correct end state but not a safe default to
adopt inside a release window, because the project's own s3-tests and
e2e lanes and most staging deployments speak plain HTTP. This release
reports instead: each such request increments
rustfs_ssec_plaintext_requests_total and logs one warning per process, so
an operator can confirm nothing would break before the default flips.
RUSTFS_SSE_C_REQUIRE_TLS=true opts into the AWS 400 now.

The verdict is per connection rather than per deployment: the layer is
built with whether this listener terminated TLS, and additionally accepts
an https protocol forwarded by a proxy the trusted-proxy configuration
already vetted. It sits beside the rate limiter, after the layer that
makes a forwarded protocol trustworthy and after the request context, so
a rejection can echo the request id.

Refs backlog#2369 P7.2.

* fix(kms): say what a node-local backend means for a cluster

The Local backend keeps key material on each node's own disk and
generates its Argon2id salt per node, so two nodes derive different keys
from the same master_key and an object encrypted on one node cannot be
decrypted on another. Behind a load balancer that surfaces as
intermittent 500s on reads that succeeded moments earlier, with nothing
tying the symptom to the cause: the only signal was a generic
"development, testing and demos only" positioning warning that says
nothing about what actually breaks.

Configuring or reconfiguring Local while the deployment is distributed
now logs a dedicated event and appends the consequence to the configure
response, so the operator who made the change sees it. The product
decision to warn rather than refuse is unchanged.

Refs backlog#2369 P7.4.

* docs: record the remaining SSE and KMS changes for 1.0.0

Adds changelog entries for the KMS data-path status classification, the
Vault static-token lease probe, the SSE-C plaintext-transport report and
its switch, and the node-local backend warning.

Documents two things the backend security guide never stated: that SSE-C
belongs on a secure transport, with the counter and switch to plan the
change around, and that the Local backend cannot be shared by a
multi-node deployment because each node derives different keys from the
same master key.

Refs backlog#2368 B6; backlog#2369 P3, P5, P7.2, P7.4.

* fix(kms): report an unreadable key store as an outage on the S3 path

A backend now distinguishes a key store it could not read from a key
that is genuinely absent, but the S3 boundary collapsed the first one
back onto 500 InternalError through the fallthrough for integrity
faults. The distinction was therefore invisible to the client: a
temporary key-directory outage looked exactly like a permanently damaged
key record, and neither the status nor the metric said the request was
worth retrying.

An unreadable key store joins the retryable class and answers 503, next
to a backend error and a credential failure. Damaged, unreadable or
unknown-format key material keeps its 500.

Refs backlog#2368 B6; builds on rustfs/rustfs#7470.
2026-09-08 22:37:53 +08:00
overtrue 929a9f27c4 chore(release): merge main into release
Merge main at 73957d0faf into release. Resolve the multipart test conflict with the current main fixture and remove its superseded ReadPlan test export.
2026-09-08 22:28:29 +08:00
houseme 15376a7fa6 test(e2e): report port allocator bind errors (#7510)
Surface the scanned port window, attempt count, and last bind error when the e2e port allocator cannot reserve a localhost port. This keeps Scanner/Heal evidence failures actionable when the environment blocks binds before business assertions run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:25:04 +08:00
houseme 5d789006dd test(heal): cover MRF disk-full commit anchors (#7509)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:21:31 +08:00
houseme f4bd53a63c test(scanner): require MRF cleanup GC soak evidence (#7507)
Require the P4 release bundle to carry measured cleanup/GC soak evidence for retained MRF replay responsibilities. The bundle now needs a two-hour cleanup window, exact cleanup case coverage, observed verified idle GC, and zero pending responsibilities or stale journals after GC.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:13:00 +08:00
houseme 21fee7f7ff test(scanner): require MRF retention GC evidence (#7506)
Require P4 retained-responsibility release bundle evidence to list the retained replay anchor and idle cleanup cases, prove a two-hour retention window, and record both idle cleanup and verified-proof discharge observations.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:12:44 +08:00
houseme 23ed00fc85 test(scanner): require release bundle domain evidence (#7502)
Reject scanner/heal release bundles that omit field-level domain evidence for scoped ACK, durable intent, mixed-version, scheduler pressure, profile cost, and two-hour pressure lanes.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 22:00:18 +08:00
houseme f49ffa2fec test(scanner): require MRF crash matrix cases (#7505)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:55:37 +08:00
houseme ee5f287324 test(scanner): require segment activation evidence (#7504)
* test(scanner): require segment activation evidence

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): cover activation proof inputs

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:49:08 +08:00
houseme 158c613d0e test(heal): cover MRF rollback mirror filtering (#7501)
Add a regression oracle that keeps scoped-only MRF responsibilities in the authoritative runtime snapshot while omitting them from the v1 legacy rollback mirror.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:48:42 +08:00
houseme a950f914ca test(scanner): bind release JSON artifact provenance (#7500)
Require scanner/heal release evidence JSON artifacts to repeat their measured source revision, run identity, measurement window, gate, and field identity inside the artifact payload. This keeps a refreshed outer bundle hash from accepting stale summary or profile JSON from another run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:48:24 +08:00
houseme 1de9b40a30 test(scanner): require MRF disk-full evidence fields (#7499)
Require the Scanner/Heal release registry and bundle checker to carry explicit G08 MRF capacity, disk-full, and replica-loss evidence fields before release approval can pass.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:20:42 +08:00
houseme 831468b2e9 fix(e2e): ignore untracked files in build identity (#7498)
Align e2e_test build provenance with Scanner/Heal evidence receipts and server binary provenance by treating only tracked source changes as dirty.

This prevents unrelated untracked worktrees or evidence directories from causing compiled test identity mismatches before real evidence cases can run.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:20:20 +08:00
houseme 600d037b25 test(heal): cover MRF idle checkpoint cleanup (#7497)
Add a runtime cleanup regression test that publishes both retained replay and runtime committed checkpoints, writes scoped and legacy journals, and verifies idle cleanup removes every recovery anchor from the registered local disks.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 21:00:18 +08:00
houseme 3fa2b334be test(scanner): require two-hour measured ABBA windows (#7493)
Reject measured Scanner/Heal release ABBA manifests and summaries whose evidence window is shorter than the W21 two-hour release requirement.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:48:33 +08:00
houseme 1b549d5907 test(scanner): require G14 same-window field coverage (#7489)
Tighten the Scanner/Heal release bundle checker so G14 same-window evidence must name the EC8+4, multi-set, and multi-pool fields covered in that measurement window.

Keep the release gate blocked when same-window evidence omits one of the required G14 fields, without changing production runtime behavior.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:48:24 +08:00
houseme 11c4ce96eb test(scanner): reject empty release evidence artifacts (#7496)
Require Scanner/Heal release bundle artifact paths to resolve to non-empty files before hashing them.

Cover empty hard-gate artifacts in the existing release bundle checker self-test and keep profile artifact size checking on the shared artifact boundary.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:42:31 +08:00
houseme 4508a0985d test(scanner): profile EC84 evidence case runs (#7495)
Give Scanner/Heal evidence cases explicit runtime profiles so EC8+4 background restart and crash cases use their own object count, object size, and partial-progress timeout defaults instead of inheriting the legacy 4x1 case assumptions.

Expose the runtime profile in plan-only output and cover every registry case in the script self-test.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:42:22 +08:00
houseme ed1b9f25d6 test(scanner): require MRF replay bundle fields (#7486)
Bind Scanner/Heal release bundle evidence for MRF durable replay to replay counts, retained responsibility anchors, and successor snapshot publication evidence.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:41:56 +08:00
houseme 274c2bf402 fix(e2e): group scanner heal evidence payload (#7494)
Group the EC8+4 Scanner/Heal evidence writer inputs into a typed payload so the distributed e2e crate stays within the clippy argument limit without weakening the lint.

The evidence writer still validates the same S3 bodies, physical shard census, process restart PIDs, and node listings.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-08 20:41:31 +08:00
161 changed files with 14804 additions and 3163 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
sha256-linux=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
sha256-darwin=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=f0d15f2d1183be319d9d977d20b48bb05a29a424c266eed910c67aaa6f1ff955
sha256-linux=340aa702576ebed5266b7c47e591f267a11178fa04bf3828d64f53fe92eb0907
sha256-darwin=874c881d7b45f12378a5817c7f42c95c4981960a2ec9ce12dcf4af239ae1f9d5
sha256-linux=9515861be899ceb10e2e0ef93c34208bb7a7a8a7f8067a02db4cfba23270ebd6
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=12d30fff5ed48fe95bbfb310dd507048f81782b2954e00eb72435708b1133f9c
sha256-linux=e917b2fdb303d01e6008ac4f1836c7b268698afe8383bd2aa4e697061df85cf6
sha256-darwin=83a7dcaffd5a789517ae9f02a224f66a9713937885cff96fca2ad7e216f197ae
sha256-linux=626c10f8c964507ff987b6c86069e9019dc6d2ae7fb02db9be5df5aa8cc5145b
+1
View File
@@ -89,6 +89,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
test-wiring-check: ## Check tests stay registered and selected by their intended runners
@echo "🧪 Checking test wiring..."
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
.PHONY: log-analyzer-rules-check
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
+3
View File
@@ -32,6 +32,8 @@ script-tests: ## Run shell script tests
./scripts/test_hotpath_warp_ab_gate.sh
./scripts/test_hotpath_warp_abba.sh
./scripts/test_scanner_validation_harness.sh
./scripts/test_scanner_heal_w13_mrf_evidence.sh
./scripts/test_scanner_heal_w16_recovery_evidence.sh
./scripts/test_exact_1mib_handoff_abba.sh
./scripts/test_pinned_paired_abba_bench.sh
./scripts/test_manual_transition_runbooks.sh
@@ -39,6 +41,7 @@ script-tests: ## Run shell script tests
./scripts/test_python_bin.sh
./scripts/check_embedded_secrets.sh --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
+47 -6
View File
@@ -188,7 +188,11 @@
"lane": "mrf-responsibility",
"status": "pending",
"description": "Durable MRF responsibility at every commit boundary",
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"]
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"],
"evidence_fields": [
"mrf_responsibility_oracle",
"commit_boundary_crash_matrix"
]
},
{
"gate": "G08",
@@ -196,7 +200,12 @@
"lane": "mrf-responsibility",
"status": "pending",
"description": "MRF capacity, disk-full and replica-loss matrix",
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"]
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"],
"evidence_fields": [
"mrf_capacity_evidence",
"disk_full_matrix",
"replica_loss_matrix"
]
},
{
"gate": "G09",
@@ -225,7 +234,16 @@
"lane": "maintenance-producers",
"status": "pending",
"description": "Maintenance and complete producer coverage",
"requires": ["maintenance producer matrix", "complete producer inventory"]
"requires": [
"maintenance producer matrix",
"complete producer inventory",
"segment activation preflight"
],
"evidence_fields": [
"maintenance_producer_matrix",
"complete_producer_inventory",
"segment_activation_preflight"
]
},
{
"gate": "G12",
@@ -249,7 +267,20 @@
"lane": "ec8-4-multiset",
"status": "pending",
"description": "Same-window field evidence with 3x4 EC8+4 and multi-set/pool coverage",
"requires": ["same-window field evidence", "3x4 EC8+4 evidence", "multi-set evidence", "multi-pool evidence"]
"requires": [
"same-window field evidence",
"3x4 EC8+4 evidence",
"multi-set evidence",
"multi-pool evidence",
"distributed segment invalidation evidence"
],
"evidence_fields": [
"same_window_field_evidence",
"ec8_4_evidence",
"multi_set_evidence",
"multi_pool_evidence",
"distributed_segment_invalidation_evidence"
]
},
{
"gate": "P1",
@@ -265,7 +296,11 @@
"lane": "scheduler-pressure",
"status": "pending",
"description": "Measured post-stop convergence and cold segment reuse",
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"]
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"],
"evidence_fields": [
"post_stop_convergence_measurement",
"cold_segment_reuse_measurement"
]
},
{
"gate": "P3",
@@ -281,7 +316,13 @@
"lane": "mrf-responsibility",
"status": "pending",
"description": "Measured MRF scale and replay cost with retained responsibility",
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence"]
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence", "cleanup/GC soak evidence"],
"evidence_fields": [
"mrf_scale_measurement",
"mrf_replay_cost_measurement",
"retained_responsibility_evidence",
"mrf_cleanup_gc_soak_evidence"
]
},
{
"gate": "R-E",
-4
View File
@@ -111,10 +111,6 @@ runs:
shell: bash
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
shell: bash
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
shell: bash
run: ./scripts/check_uring_lane_lib_only.sh
+2 -2
View File
@@ -16,7 +16,7 @@ name: Security Audit
on:
push:
branches: [ main, release ]
branches: [ main ]
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
@@ -32,7 +32,7 @@ on:
- 'scripts/security/check_workflow_pins.sh'
pull_request:
types: [ opened, synchronize, reopened, closed ]
branches: [ main, release ]
branches: [ main ]
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
-79
View File
@@ -1,79 +0,0 @@
# 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.
# Reports the existing required checks for paths excluded by ci.yml.
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
# action to keep validation coverage aligned. Keep this paths list in sync with
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
name: Continuous Integration (docs only)
on:
pull_request:
types: [ opened, synchronize, reopened ]
branches: [ main, release ]
paths:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- "flake.lock"
permissions:
contents: read
jobs:
quick-checks:
name: Quick Checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run shared quick checks
uses: ./.github/actions/quick-checks
test-and-lint:
name: Test and Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
# Docs-only PRs skip the full code CI, but they are exactly where a
# planning-type document could be slipped in (git add -f bypasses
# .gitignore). Run the guard here so the required "Test and Lint" check
# stays meaningful for docs-only changes.
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Satisfy required check for docs-only changes
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
+77 -85
View File
@@ -37,25 +37,6 @@ on:
pull_request:
types: [ opened, synchronize, reopened, closed ]
branches: [ main, release ]
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
# reports the required "Test and Lint" check for PRs skipped here.
paths-ignore:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
- "flake.lock"
merge_group:
types: [ checks_requested ]
schedule:
@@ -88,6 +69,32 @@ jobs:
- name: Explain cancellation run
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
classify-changes:
name: Select CI scope
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
mode: ${{ steps.scope.outputs.mode }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 2
persist-credentials: false
- name: Select scope using the base revision's policy
id: scope
env:
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
else
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
echo "Base CI policy unavailable; running the full matrix."
fi
typos:
name: Typos
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -100,7 +107,7 @@ jobs:
- name: Typos check with custom config file
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
# Fail early with compile-free checks shared with docs-only CI.
# Fail early with compile-free checks for every pull request.
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -116,9 +123,9 @@ jobs:
uses: ./.github/actions/quick-checks
test-and-lint:
name: Test and Lint
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
name: Workspace Test and Lint
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -289,45 +296,6 @@ jobs:
- name: Run rebalance/decommission migration proofs
run: ./scripts/check_migration_gate_count.sh
# Record the reason before this job completes as FAILURE. A separate
# dependent job cancels sibling lanes only after GitHub has preserved this
# required check's failure verdict.
- name: Annotate early-stop reason
if: >-
failure() && github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
run: |
{
echo "## CI early-stop"
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
} >> "$GITHUB_STEP_SUMMARY"
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
# CANCELLED and hid the actionable failure in the PR checks UI.
cancel-after-test-and-lint-failure:
name: Cancel siblings after Test and Lint failure
if: >-
failure() && needs.test-and-lint.result == 'failure'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
needs: [ test-and-lint ]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
steps:
- name: Cancel remaining jobs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -fsS -X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
# drive the object layer through process-global singletons (the GLOBAL_ENV
# ECStore, the global tier-config manager, background-expiry workers) and bind
@@ -340,8 +308,8 @@ jobs:
# See rustfs/backlog#1148 (ilm-1) and #1155.
test-ilm-integration-serial:
name: ILM Integration (serial)
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -408,8 +376,8 @@ jobs:
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
env:
@@ -449,8 +417,8 @@ jobs:
connect-short-credential-boundary:
name: Connect Short Credential Boundary
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 60
env:
@@ -507,8 +475,8 @@ jobs:
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
strategy:
@@ -561,8 +529,8 @@ jobs:
build-rustfs-debug-binary:
name: Build RustFS Debug Binary
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 30
env:
@@ -684,8 +652,8 @@ jobs:
# job had neither, so each closed/merged PR really ran the whole io_uring
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
# 30662728539) and kept the cancellation run in progress for minutes.
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
# a container, applies no seccomp filter that would block io_uring_setup — so
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
@@ -1002,22 +970,11 @@ jobs:
# debug binary; each test spawns its own rustfs server on a random port.
- name: Run e2e full suite
env:
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-full-logs
RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG: rustfs::heal=debug,rustfs_heal=debug,rustfs_ecstore::disk=debug,rustfs_lock=debug,rustfs=error
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
run: cargo nextest run --profile e2e-full -p e2e_test
- name: Upload e2e full diagnostics
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-full-diagnostics-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-e2e-full-logs/
if-no-files-found: warn
retention-days: 7
- name: Upload junit
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -1224,9 +1181,44 @@ jobs:
if-no-files-found: ignore
retention-days: 3
required-checks:
name: Test and Lint
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs:
- classify-changes
- typos
- quick-checks
- test-and-lint
- test-ilm-integration-serial
- test-and-lint-rio-v2
- connect-short-credential-boundary
- test-and-lint-protocols
- build-rustfs-debug-binary
- uring-integration
- e2e-tests
- s3-implemented-tests
- s3-lifecycle-behavior-tests
- build-rustfs-debug-binary-rio-v2
- e2e-tests-rio-v2
- e2e-full
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Require the expected result of every CI lane
env:
CI_NEEDS: ${{ toJSON(needs) }}
shell: bash
run: python3 scripts/ci_gate.py verify
alert-on-failure:
name: Alert on scheduled failure
needs:
- classify-changes
- connect-short-credential-boundary
- required-checks
- typos
- quick-checks
- test-and-lint
+2 -2
View File
@@ -19,7 +19,7 @@
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
# nightly runs, and manual dispatches all execute the same fail-closed suite.
# Upgrade cases use an independent 1.0.0-rc.2 pin defined below.
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
#
# Isolated pool filesystems: expand/decommission/rebalance cases require
# independent `statfs` capacity. This job runs on GitHub-hosted
@@ -87,7 +87,7 @@ jobs:
NO_PROXY: 127.0.0.1,localhost
HTTP_PROXY: ""
HTTPS_PROXY: ""
# Independent 1.0.0-rc.2 source pin for distributed::upgrade_test.
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
+11
View File
@@ -129,11 +129,22 @@ jobs:
: > target/debug/rustfs.features
- name: Run upgrade compatibility test
env:
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
run: |
cargo test --locked -p e2e_test \
"upgrade_compatibility_test::${{ matrix.test }}" \
-- --ignored --exact --nocapture
- name: Upload scanner/heal G09 evidence
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
if-no-files-found: ignore
retention-days: 14
- name: Upload server logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -82,6 +82,12 @@ jobs:
performance-test:
runs-on: pf-testing
timeout-minutes: 900
env:
RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
# Run on manual dispatch, or when the nightly build completed successfully.
# Skipped when nightly failed.
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
@@ -158,19 +164,15 @@ jobs:
- name: Run benchmark (GET/PUT/MIXED)
id: benchmark
run: |
# Empty on automatic (workflow_run) runs -> full 30 rounds.
# Manual dispatch can restrict method(s)/size(s).
export WARP_METHODS="${{ inputs.test_method }}"
export WARP_SIZES="${{ inputs.object_size }}"
./auto-testing/rustfs_performance_test.sh \
--step 5 -y \
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
--log-file "${LOG_FILE}"
- name: Analyze results
if: ${{ steps.benchmark.conclusion == 'success' }}
run: |
export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Collect RustFS version info
+1
View File
@@ -52,6 +52,7 @@ docs
__pycache__/
!docs/
docs/*
!docs/README.md
!docs/architecture/
!docs/architecture/**
!docs/operations/
+30
View File
@@ -7,12 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Replication
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
### Security
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
### Fixed
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
- **KMS failures on the S3 data path carry an actionable status**: only "key not found" and a backend outage were classified; every other KMS failure — a disabled or pending-deletion key, a denied KMS grant, an encryption-context mismatch, an unsupported algorithm, a credential or timeout failure, a capability the backend does not have — collapsed onto `500 InternalError`. SDKs therefore applied exponential backoff to configuration errors that no retry can fix, and monitoring filed every one of them as a server fault. Unusable-key and request-side failures now return `400`, a denied grant `403`, transient backend failures `503` — including a key store the backend could not read, so an outage stays distinguishable from a missing key all the way to the client — and a missing backend capability `501`. Damaged or unreadable key material still returns `500`, which is what it is.
- **SSE-C on buckets with default encryption**: a `PutObject` carrying a valid SSE-C header triple on a bucket that has default encryption configured no longer fails with `400 InvalidArgument` ("The SSE-C and managed server-side encryption headers cannot be used together"). PUT and the POST-object/extract path resolved the bucket default with a hard-coded "no explicit SSE-C" flag, so the default was layered onto the request and then tripped the request's own mutual-exclusion check; an SSE-C request now suppresses the bucket default on all three write paths, matching COPY and AWS S3. Every bucket with default encryption previously refused SSE-C single PUTs outright, while `CreateMultipartUpload` on the same bucket succeeded.
- **Explicit SSE-S3 on SSE-KMS-default buckets**: `x-amz-server-side-encryption: AES256` against a bucket whose default is `aws:kms` no longer fails with `400 InvalidArgument`. The bucket default's KMS key id was inherited independently of the effective algorithm, producing a self-contradictory `AES256` + key-id pair; the key id is now inherited only when the effective algorithm is `aws:kms`. `PutBucketEncryption` fills in a default key id automatically, so this affected nearly every SSE-KMS-default bucket.
- **Restore of encrypted or compressed multipart objects (silent data corruption)**: restoring a multipart object from a remote tier addressed the tier in *plaintext* coordinates while the copy-back reads the *stored* representation. Every part received a misaligned slice of the remote object whose length still satisfied the range, the hash reader and the completion size check, so the restore reported success and replaced the object's bytes. Restore now accumulates stored part sizes, passes the stored length to the hash reader alongside the plaintext length, and validates against the stored size. Objects restored by an affected release must be re-restored from the tier or recovered from a backup — this release does not detect or repair them retroactively.
- **Restore no longer drifts the object ETag**: the copy-back digests stored (encrypted or compressed) bytes, so the recomputed MD5 is not the object's public ETag. Single-part and multipart restores now preserve the original object ETag, and each restored part keeps its own recorded part ETag.
- **ILM archive no longer forwards encryption metadata to the tier**: transition requests carried the object's SSE headers and the RustFS-wrapped data key as request headers. Any S3 target rejected an SSE-C archive outright (`400`, no key supplied), an SSE-KMS archive asked the target to encrypt a second time under a key id it does not own, and the wrapped DEK left the cluster. The archive request now strips every SSE header and encryption marker using the same predicate the replication path uses; the local `xl.meta` keeps all of it, so read-through and restore are unaffected.
- **KMS reload is no longer a no-op on a node whose KMS failed to start**: `POST /rustfs/admin/v3/kms/reload` short-circuited whenever the persisted configuration matched the in-memory one byte for byte. A node whose KMS failed to start (for example Vault briefly unreachable during a rolling restart) keeps that configuration and sits in `Error`, so the documented recovery call returned "reloaded successfully" while leaving the node down — and did the same on every peer through the reload broadcast. Reload now short-circuits only for a service that is actually running, and otherwise reconfigures, which starts the service.
- **AWS KMS capability reporting**: the AWS backend no longer advertises `versioning` support through `GET /rustfs/admin/v3/kms/status`. AWS KMS key versions are not enumerable through this backend, as the backend documentation already stated.
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
@@ -54,6 +72,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
### Changed
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
- **Per-key SSE-KMS authorization** (`RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY`, default `false`). With it on, anonymous callers hold no KMS grants, so **a public bucket serving SSE-KMS objects is an incompatible combination** and those reads return `AccessDenied`.
- Envelope context binding as KMS AAD (`ENV_KMS_ENVELOPE_AAD`, off by default; a node that predates the field cannot open bound envelopes).
- Vault custom CA and mutual TLS; object-level DEK rewrap plus a batch rekey admin API; a backend-locality runtime signal on `kms/status`.
- Single-pass decryption for encrypted GET, and encrypted single-part closed-range seek — the latter is now **on by default** (`RUSTFS_ENCRYPTED_RANGE_SEEK`, default `true`; the switch remains as a kill switch).
- **Vault static tokens are now tracked and renewed**: with `Token` authentication RustFS hard-coded "this token has no lease", so the renewal task never started and no remaining-TTL gauge was published. `vault token create` grants a 768-hour TTL by default, which turned a healthy-looking cluster into one where every KMS call returned 403 about a month later, with no self-healing short of a restart or reconfigure. RustFS now calls `auth/token/lookup-self` at login and adopts what Vault reports: a non-expiring token behaves exactly as before, an expiring renewable one is renewed at half TTL like the other auth methods, and an expiring non-renewable one logs `vault_static_token_not_renewable` and publishes its remaining TTL. The probe never fails the login: a token whose policy omits `lookup-self` (Vault's `default` policy grants it), or a Vault that is unreachable at that moment, logs `vault_static_token_lookup_failed` and falls back to the previous no-lease behaviour, so no deployment that works today stops working.
- **SSE-C over a plaintext transport is reported**: AWS S3 and MinIO refuse an SSE-C request that did not arrive over TLS, because the customer key travels in a request header. RustFS accepted them on any transport and still does by default — flipping to a rejection inside a release window would break plaintext staging and test deployments. Each such request now increments `rustfs_ssec_plaintext_requests_total` and logs one `ssec_request_without_tls` warning per process, and `RUSTFS_SSE_C_REQUIRE_TLS=true` opts into the AWS `400` now. The default is expected to flip in a later release; confirm the counter reads zero first. The verdict is per connection: a TLS listener satisfies it, and so does an `https` protocol forwarded by a proxy the trusted-proxy configuration accepts.
- **Local KMS backend on a distributed deployment says what actually breaks**: the backend keeps key material and its Argon2id salt on each node's own disk, so two nodes derive different keys from the same `master_key` and an object encrypted on one node cannot be decrypted on another — intermittent 500s behind a load balancer. Configuring it while the deployment is distributed now logs `kms_node_local_backend_in_distributed_deployment` and appends that consequence to the `kms/configure` response, instead of only the generic "development only" positioning warning. It remains a warning, not a gate.
- **SSE-KMS is refused when no KMS is running (breaking)**: a write requesting `x-amz-server-side-encryption: aws:kms` on a node with no KMS service no longer succeeds. Earlier releases wrapped the data key with the node-local `RUSTFS_SSE_S3_MASTER_KEY` while still writing `aws:kms` and the requested key id into the object metadata — metadata that claimed a KMS protection the object never had, under a key that was never consulted. Such a request now returns `400 InvalidRequest` when KMS was never configured and `503` when a configured service is not running; the refusal is evaluated after the per-key authorization gate, so an unauthorized caller still receives `403 AccessDenied`. **Upgrade note:** a deployment that relied on this write succeeding will start receiving 4xx/503. Either configure a KMS, or request `AES256` and keep the documented SSE-S3 local-master-key fallback, which is unchanged. Objects already written this way remain readable.
- **Legacy ciphertext nonce layouts are now locked per segment**: while decrypting a v1 segment, the reader locks onto whichever of the three historical nonce layouts decoded the segment's first non-zero-index frame and rejects any later frame that needs a different one. Because a frame encrypted at block index zero authenticates under the pre-`1.0.0-alpha.91` reused-part-nonce layout at any position, an attacker able to rewrite the underlying shards could previously replay it and have the forged plaintext returned with `200`. New `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK` (default `true`) drops that third layout entirely when set to `false`, which closes the residual case of a stream built purely from repeats of frame zero. Turn it off only after migrating pre-alpha.91 encrypted objects (rewrite in place with CopyObject); see [KMS backend security properties](docs/operations/kms-backend-security.md) for what the v1 frame layout does and does not authenticate.
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
Generated
+2
View File
@@ -9928,6 +9928,7 @@ dependencies = [
"async-trait",
"base64-simd",
"bytes",
"chrono",
"crc-fast",
"futures",
"hotpath",
@@ -10578,6 +10579,7 @@ dependencies = [
"serde_json",
"sha1 0.11.0",
"sha2 0.11.0",
"temp-env",
"thiserror 2.0.20",
"tokio",
"tokio-test",
+25 -8
View File
@@ -46,14 +46,31 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
- **简单易用**:设计简洁,易于部署和管理。
| 功能 | 状态 | 功能 | 状态 |
| :----------------- | :------ | :---------------------- | :-------- |
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 |
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 |
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 |
状态说明:✅ 可用 —— 已发布并有 CI 门禁覆盖;🧪 预览 —— 已发布但需显式开关,或兼容性承诺有边界。
| 功能 | 状态 | 功能 | 状态 |
| :-------------------------- | :------ | :----------------------- | :------ |
| **S3 核心功能** | ✅ 可用 | **分布式模式** | ✅ 可用 |
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
| **版本控制** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
| **对象锁定 (WORM)** | ✅ 可用 | **修复与扫描器** | ✅ 可用 |
| **服务端加密 (SSE)** | ✅ 可用 | **存储池扩容 / 下线** | ✅ 可用 |
| **RustFS KMS** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
| **生命周期管理 (ILM)** | ✅ 可用 | **站点复制** | ✅ 可用 |
| **ILM 分层 (远端 S3)** | ✅ 可用 | **存储桶配额** | ✅ 可用 |
| **S3 Select** | ✅ 可用 | **事件通知** | ✅ 可用 |
| **S3 Tables (Iceberg REST)**| 🧪 预览 | **审计日志** | ✅ 可用 |
| **IAM / 策略** | ✅ 可用 | **日志与可观测性** | ✅ 可用 |
| **OIDC / SSO** | ✅ 可用 | **Web 控制台** | ✅ 可用 |
| **Keystone 认证** | ✅ 可用 | **K8s Helm Chart** | ✅ 可用 |
| **Swift API** | ✅ 可用 | **FTPS / WebDAV** | ✅ 可用 |
| **多租户** | ✅ 可用 | **SFTP** | ✅ 可用 |
| **MinIO 磁盘格式兼容** | 🧪 预览 | | |
说明:
- **服务端加密**:支持 SSE-C、SSE-S3 与 SSE-KMS。SSE-KMS 必须先配置 KMS 服务;未配置 KMS 时请求 `aws:kms` 会被拒绝,不会降级到本地主密钥。
- **RustFS KMS**:生产环境支持 VaultKV2 / Transit)与 AWS KMS 后端;`Local``Static` 后端仅供开发与测试使用,详见 [KMS 后端安全属性](docs/operations/kms-backend-security.md)。
## RustFS vs MinIO 性能对比
+55
View File
@@ -593,6 +593,20 @@ pub struct DataUsageSnapshotIdentity {
pub scanner_epoch: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageSegmentInvalidationProof {
#[serde(default)]
pub process_epoch: String,
#[serde(default)]
pub generation_start: u64,
#[serde(default)]
pub generation_end: u64,
#[serde(default)]
pub producer_identity_coverage_complete: bool,
#[serde(default)]
pub cold_zero_walk_oracle: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataUsageSnapshotSetState {
pub pool_index: u64,
@@ -607,6 +621,8 @@ pub struct DataUsageSnapshotSetState {
pub complete: bool,
#[serde(default)]
pub tombstone: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
}
impl DataUsageInfo {
@@ -3073,6 +3089,7 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
}];
assert!(observed_data_usage_is_newer(&partial, &authoritative));
}
@@ -3095,6 +3112,7 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
},
DataUsageSnapshotSetState {
pool_index: 1,
@@ -3104,6 +3122,7 @@ mod tests {
scan_plan_digest: Some([2; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
},
],
..Default::default()
@@ -3113,6 +3132,42 @@ mod tests {
assert!(partial.is_valid_partial_snapshot());
}
#[test]
fn set_state_segment_invalidation_proof_is_additive() {
#[derive(Deserialize)]
struct LegacySetState {
pool_index: u64,
set_index: u64,
complete: bool,
}
let proof = DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 3,
generation_end: 5,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
};
let state = DataUsageSnapshotSetState {
pool_index: 1,
set_index: 2,
scanner_cycle: Some(9),
scanner_epoch: Some(4),
scan_plan_digest: Some([7; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: Some(proof.clone()),
};
let encoded = rmp_serde::to_vec_named(&state).expect("set state should encode with additive proof");
let legacy: LegacySetState = rmp_serde::from_slice(&encoded).expect("legacy readers should ignore proof metadata");
assert_eq!(legacy.pool_index, 1);
assert_eq!(legacy.set_index, 2);
assert!(legacy.complete);
let decoded: DataUsageSnapshotSetState = rmp_serde::from_slice(&encoded).expect("new readers should restore proof");
assert_eq!(decoded.segment_invalidation_proof, Some(proof));
}
#[test]
fn completeness_marker_requires_a_snapshot_timestamp() {
let untimestamped = DataUsageInfo {
+1 -1
View File
@@ -51,7 +51,7 @@ fn main() {
}
}
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty());
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=no"]).is_none_or(|status| !status.is_empty());
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
let mut features = std::env::vars()
.filter_map(|(key, _)| {
-54
View File
@@ -44,7 +44,6 @@ use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::path::{Path, PathBuf};
use tokio::time::{Duration, Instant, sleep};
use tracing::info;
use uuid::Uuid;
use walkdir::WalkDir;
@@ -375,33 +374,6 @@ pub(crate) fn census_object_version_on_disk(
})
}
/// Wait for the background PUT tail to commit every physical part on one disk.
/// Invalid metadata remains an immediate error instead of a retryable absence.
pub(crate) async fn wait_for_complete_physical_shard_on_disk(
disk: &Path,
bucket: &str,
key: &str,
version_id: Option<&str>,
timeout: Duration,
) -> ChaosResult<VersionShardCensus> {
let deadline = Instant::now() + timeout;
loop {
let census = census_object_version_on_disk(disk, bucket, key, version_id)?;
if census.is_complete() && !census.expected_part_numbers.is_empty() {
return Ok(census);
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"physical shard for {bucket}/{key}@{version_id:?} on {} did not become complete within {timeout:?}: {census:?}",
disk.display()
)
.into());
}
sleep(remaining.min(Duration::from_millis(50))).await;
}
}
/// `POST` a signed (SigV4, service `s3`) admin request without relying on the
/// external `awscurl` binary. Mirrors the admin heal calls used by the heal
/// regression suite.
@@ -479,30 +451,4 @@ mod tests {
assert!(expected.matches_manifest(&expected));
assert!(!changed.matches_manifest(&expected));
}
#[tokio::test]
async fn physical_shard_readiness_fails_closed_with_last_census() {
let disk = tempfile::tempdir().expect("temporary disk");
let error = wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "missing", None, Duration::ZERO)
.await
.expect_err("missing physical shards must fail the baseline gate");
assert!(error.to_string().contains("has_xl_meta: false"));
assert!(error.to_string().contains("bucket/missing"));
}
#[tokio::test]
async fn physical_shard_readiness_does_not_retry_invalid_metadata() {
let disk = tempfile::tempdir().expect("temporary disk");
let object = disk.path().join("bucket").join("corrupt");
std::fs::create_dir_all(&object).expect("object directory");
std::fs::write(object.join("xl.meta"), b"invalid metadata").expect("corrupt metadata fixture");
let error = tokio::time::timeout(
Duration::from_secs(1),
wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "corrupt", None, Duration::from_secs(30)),
)
.await
.expect("corrupt metadata must fail immediately")
.expect_err("invalid metadata must not be accepted as a complete baseline");
assert!(!error.to_string().contains("did not become complete"));
}
}
@@ -268,6 +268,7 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
let rule = CorsRule::builder()
.allowed_methods("GET")
.allowed_origins("https://example.com")
.allowed_headers("*")
.build()?;
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
@@ -288,6 +289,60 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
assert_eq!(rules[0].allowed_methods(), ["GET"]);
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
let http = reqwest::Client::builder().no_proxy().build()?;
let url = format!("http://{}/{}", cluster.nodes[1].address, BUCKET_METADATA_RELOAD_BUCKET);
let without_headers = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "GET")
.send()
.await?;
assert!(without_headers.status().is_success());
assert!(!without_headers.headers().contains_key("access-control-allow-headers"));
assert!(
without_headers
.headers()
.get("vary")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
"a cached header-free preflight must not suppress a later requested header grant"
);
let preflight = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "X-Another-Header, x-could-be-anything")
.send()
.await?;
assert!(preflight.status().is_success(), "peer preflight should succeed: {preflight:?}");
assert_eq!(
preflight
.headers()
.get("access-control-allow-headers")
.and_then(|value| value.to_str().ok()),
Some("x-another-header,x-could-be-anything"),
"a wildcard rule must return only the headers requested by this preflight"
);
assert!(
preflight
.headers()
.get("vary")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
"preflight caches must distinguish the requested header list"
);
let denied = http
.request(reqwest::Method::OPTIONS, &url)
.header("Origin", "https://disallowed.example.com")
.header("Access-Control-Request-Method", "GET")
.header("Access-Control-Request-Headers", "x-another-header")
.send()
.await?;
assert!(
!denied.headers().contains_key("access-control-allow-headers"),
"a rejected origin must not receive the requested header grant"
);
writer
.delete_bucket_cors()
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
+33 -27
View File
@@ -34,7 +34,7 @@ use serde_json;
use std::ffi::OsStr;
use std::fs as stdfs;
use std::io::ErrorKind;
use std::net::{Ipv6Addr, SocketAddr};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Once;
@@ -188,6 +188,15 @@ fn write_next_test_port(port: u16) -> Result<(), Box<dyn std::error::Error + Sen
Ok(())
}
fn no_available_test_port_error(config: TestPortAllocatorConfig, attempts: u16, last_error: Option<&std::io::Error>) -> String {
let max_inclusive = config.max_exclusive() - 1;
let detail = last_error.map(|err| format!("; last bind error: {err}")).unwrap_or_default();
format!(
"no available E2E test port found in {}..={} after {} attempts{}",
config.min, max_inclusive, attempts, detail
)
}
pub(crate) fn capture_command_logs(
command: &mut Command,
log_path: Option<&str>,
@@ -694,19 +703,23 @@ impl RustFSTestEnvironment {
let _guard = PortAllocatorGuard::acquire().await?;
let config = test_port_allocator_config()?;
let mut next_port = read_next_test_port(config);
let mut last_error = None;
for _ in 0..config.range {
let port = next_port;
next_port = advance_test_port(next_port, config);
write_next_test_port(next_port)?;
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
drop(listener);
return Ok(port);
match TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => {
drop(listener);
return Ok(port);
}
Err(err) => last_error = Some(err),
}
}
Err("no available E2E test port found".into())
Err(no_available_test_port_error(config, config.range, last_error.as_ref()).into())
}
/// Kill any existing RustFS processes
@@ -1468,9 +1481,7 @@ impl RustFSTestClusterEnvironment {
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
}
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
// Endpoint locality requires the server port to match. IPv6 loopback
// keeps that port while leaving the direct IPv4 S3 listener available.
let proxy = crate::fault_proxy::FaultProxy::start_on((Ipv6Addr::LOCALHOST, target.port()).into(), target).await?;
let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
Ok(proxy)
}
@@ -2203,6 +2214,19 @@ mod tests {
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
}
#[test]
fn e2e_port_allocator_reports_attempt_window_and_last_bind_error() {
let config = TestPortAllocatorConfig { min: 41000, range: 3 };
let error = std::io::Error::from(ErrorKind::PermissionDenied);
let message = no_available_test_port_error(config, config.range, Some(&error));
assert!(message.contains("41000..=41002"));
assert!(message.contains("after 3 attempts"));
assert!(message.contains("last bind error"));
assert!(message.contains("permission denied"));
}
#[test]
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
let workspace = Path::new("workspace");
@@ -2375,7 +2399,7 @@ mod tests {
#[tokio::test]
async fn volume_proxy_rewrites_cluster_volume_endpoint() {
let mut env = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2))
let mut env = RustFSTestClusterEnvironment::new(1)
.await
.expect("cluster environment should allocate a node");
let direct = env.nodes[0].address.clone();
@@ -2390,24 +2414,6 @@ mod tests {
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
proxy.shutdown().await;
for node in &env.nodes {
let local_port = node.address.parse::<SocketAddr>().expect("node address").port();
let local_paths = volumes
.split_whitespace()
.filter_map(|volume| {
let endpoint = reqwest::Url::parse(volume).expect("volume endpoint");
rustfs_utils::is_local_host(
endpoint.host().expect("volume endpoint host"),
endpoint.port().expect("volume endpoint port"),
local_port,
)
.expect("endpoint locality")
.then(|| endpoint.path().to_string())
})
.collect::<Vec<_>>();
assert_eq!(local_paths, node.data_dirs, "the proxy must preserve local disk ownership");
}
}
#[test]
@@ -962,27 +962,6 @@ pub(crate) async fn wait_for_rebalance_active(
}
}
pub(crate) async fn wait_for_rebalance_running_with_progress(
cluster: &RustFSTestClusterEnvironment,
expected_id: &str,
timeout: Duration,
) -> TestResult {
let deadline = Instant::now() + timeout;
loop {
let status = rebalance_status_json(cluster).await?;
if rebalance_running_with_progress(&status, expected_id)? {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"rebalance did not become active with non-zero progress within {timeout:?}; last status: {status}"
)
.into());
}
sleep(Duration::from_millis(100)).await;
}
}
pub(crate) async fn wait_for_rebalance_complete(
cluster: &RustFSTestClusterEnvironment,
expected_id: &str,
+227 -64
View File
@@ -12,22 +12,30 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, assert_inventory, payload_for, put_object, unique_bucket, wait_until};
use crate::chaos::{
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post, wait_for_complete_physical_shard_on_disk,
use super::harness::{
DistCluster, DistLayout, TestResult, assert_inventory, get_object_bytes, payload_for, put_object, sha256_hex, unique_bucket,
wait_until,
};
use crate::common::{init_logging, rustfs_binary_path};
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
use crate::common::init_logging;
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::{Instant, sleep};
const EC84_NODE_COUNT: usize = 3;
const EC84_DRIVES_PER_NODE: usize = 4;
const EC84_DATA_BLOCKS: usize = 8;
const EC84_PARITY_BLOCKS: usize = 4;
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
const EC84_HEAL_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(45);
const EC84_HEAL_CONTROL_RETRY_DELAY: Duration = Duration::from_millis(250);
#[derive(Clone)]
struct ExpectedShard {
@@ -36,6 +44,91 @@ struct ExpectedShard {
baseline: VersionShardCensus,
}
struct ScannerHealEvidenceContext {
directory: PathBuf,
run: Value,
}
struct ScannerHealEvidencePayload<'a> {
dist: &'a DistCluster,
bucket: &'a str,
expected: &'a [ExpectedShard],
outage_key: &'a str,
outage_body: &'a [u8],
replaced_drive: &'a Path,
pid_before: u32,
pid_after: u32,
node_listings: Vec<Vec<String>>,
}
fn file_sha256(path: &Path) -> TestResult<String> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
fn compiled_test_identity() -> Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
fn string_field<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
let mut current = value;
for segment in path.split('.') {
current = current
.get(segment)
.ok_or_else(|| format!("scanner/heal run receipt missing {path}"))?;
}
current
.as_str()
.filter(|text| !text.is_empty())
.ok_or_else(|| format!("scanner/heal run receipt has invalid {path}").into())
}
fn scanner_heal_evidence_context() -> TestResult<Option<ScannerHealEvidenceContext>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: Value = serde_json::from_slice(&std::fs::read(receipt)?)?;
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
if built[key] != run["test_build"][key] {
return Err(format!("compiled test identity differs for {key}").into());
}
}
let binary_path = PathBuf::from(string_field(&run, "binary.path")?);
if file_sha256(&binary_path)? != string_field(&run, "binary.sha256")? {
return Err("server binary must match the run receipt".into());
}
if file_sha256(&std::env::current_exe()?)? != string_field(&run, "test_binary.sha256")? {
return Err("test executable must match the run receipt".into());
}
if directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(ScannerHealEvidenceContext { directory, run }))
}
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
@@ -52,6 +145,65 @@ fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
Ok(())
}
async fn write_scanner_heal_evidence(context: ScannerHealEvidenceContext, payload: ScannerHealEvidencePayload<'_>) -> TestResult {
let verifier = payload.dist.client(0)?;
let mut objects = Vec::new();
for item in payload.expected {
let actual = get_object_bytes(&verifier, payload.bucket, &item.key).await?;
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, &item.key, None)?;
objects.push(serde_json::json!({
"key": item.key,
"version_id": null,
"expected_bytes": item.body.len(),
"actual_bytes": actual.len(),
"expected_sha256": sha256_hex(&item.body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": item.baseline,
"physical": physical,
}));
}
let actual = get_object_bytes(&verifier, payload.bucket, payload.outage_key).await?;
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, payload.outage_key, None)?;
objects.push(serde_json::json!({
"key": payload.outage_key,
"version_id": null,
"expected_bytes": payload.outage_body.len(),
"actual_bytes": actual.len(),
"expected_sha256": sha256_hex(payload.outage_body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": null,
"physical": physical,
}));
let evidence = serde_json::json!({
"schema": 1,
"case": EC84_TARGET_DRIVE_RESTART_CASE,
"evidence": "process-restart",
"run_id": string_field(&context.run, "run_id")?,
"source_revision": string_field(&context.run, "source_revision")?,
"test_build": compiled_test_identity(),
"binary_sha256": string_field(&context.run, "binary.sha256")?,
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
"pid_before": payload.pid_before,
"pid_after": payload.pid_after,
"unclean_shutdown_marker": false,
"objects": objects,
"node_listings": payload.node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(context.directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE))?;
output.write_all(&data)?;
output.sync_all()?;
Ok(())
}
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
for key in keys {
let census = census_object_version_on_disk(drive, bucket, key, None)?;
@@ -62,6 +214,29 @@ fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> T
Ok(())
}
fn is_cluster_heal_coordination_unavailable(error: &(dyn std::error::Error + Send + Sync)) -> bool {
let message = error.to_string();
message.contains("500 Internal Server Error") && message.contains("cluster heal coordination unavailable")
}
async fn start_ec84_root_heal_when_control_ready(
heal_url: &str,
heal_body: &str,
access_key: &str,
secret_key: &str,
) -> TestResult {
let deadline = Instant::now() + EC84_HEAL_CONTROL_READY_TIMEOUT;
loop {
match signed_admin_post(heal_url, Some(heal_body), access_key, secret_key).await {
Ok(_) => return Ok(()),
Err(error) if is_cluster_heal_coordination_unavailable(error.as_ref()) && Instant::now() < deadline => {
sleep(EC84_HEAL_CONTROL_RETRY_DELAY).await;
}
Err(error) => return Err(error),
}
}
}
async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<ExpectedShard>> {
let mut expected = Vec::new();
for index in 0..4 {
@@ -90,19 +265,7 @@ async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<Ex
#[tokio::test]
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
init_logging();
let server_binary = rustfs_binary_path();
let evidence_run = restart_evidence_run(
&server_binary,
ScannerHealEvidenceCase {
id: "ec84-target-drive-restart",
oracle: "ec84-target-drive-restart.json",
evidence: "process-restart",
unclean_shutdown_marker: false,
topology: EvidenceTopology::new(3, 4),
storage_class_standard: Some("EC:4"),
erasure_set_drive_count: Some("12"),
},
)?;
let evidence_context = scanner_heal_evidence_context()?;
let mut dist = DistCluster::start_with_env(
DistLayout::ThreeByFourEc84,
&[
@@ -126,24 +289,18 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
for item in &mut expected {
item.baseline =
wait_for_complete_physical_shard_on_disk(&replaced_drive, &bucket, &item.key, None, Duration::from_secs(10)).await?;
item.baseline = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
assert_ec84_geometry(&item.baseline, &item.key)?;
}
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
let format_json = std::fs::read(&format_path)?;
let pid_before = dist.cluster.nodes[replaced_node]
let target_pid_before = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("target process is absent")?
.ok_or("target process is absent before graceful restart")?
.id();
dist.cluster.stop_node_gracefully(replaced_node).await?;
let unclean_shutdown_marker = Path::new(&dist.cluster.nodes[replaced_node].data_dir)
.join(".rustfs.sys")
.join("unclean-shutdown")
.is_file();
assert!(!unclean_shutdown_marker, "graceful target shutdown must remove its unclean marker");
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
std::fs::rename(&replaced_drive, &retired_drive)?;
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?;
@@ -165,10 +322,15 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.await?;
dist.cluster.start_node(replaced_node).await?;
let target_pid_after = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("target process is absent after restart")?
.id();
let heal_body =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
start_ec84_root_heal_when_control_ready(&heal_url, heal_body, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
wait_until(
Duration::from_secs(120),
@@ -205,46 +367,47 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
.filter_map(|object| object.key().map(str::to_owned))
.collect::<HashSet<_>>();
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
let mut keys = observed.into_iter().collect::<Vec<_>>();
keys.sort();
node_listings.push(keys);
let mut observed = observed.into_iter().collect::<Vec<_>>();
observed.sort();
node_listings.push(observed);
}
if let Some(evidence_run) = evidence_run {
let target_client = dist.client(replaced_node)?;
let mut objects = Vec::with_capacity(inventory.len());
for (key, body) in &inventory {
let response = target_client.get_object().bucket(&bucket).key(key).send().await?;
let actual = response.body.collect().await?.into_bytes();
assert_eq!(actual.as_ref(), body.as_slice(), "object body changed for {key}");
let physical = census_object_version_on_disk(&replaced_drive, &bucket, key, None)?;
assert_ec84_geometry(&physical, key)?;
let baseline = expected.iter().find(|item| item.key == *key).map(|item| &item.baseline);
objects.push(serde_json::json!({
"key": key, "version_id": null,
"expected_bytes": body.len(), "actual_bytes": actual.len(),
"expected_sha256": sha256_hex(body), "actual_sha256": sha256_hex(&actual),
"expected_physical": baseline, "physical": physical,
}));
}
let pid_after = dist.cluster.nodes[replaced_node]
.process
.as_ref()
.ok_or("restarted target is absent")?
.id();
evidence_run.write(
&server_binary,
RestartObservation {
nodes: dist.cluster.nodes.len(),
drives_per_node: dist.cluster.topology.drives_per_node,
pid_before,
pid_after,
unclean_shutdown_marker,
objects,
if let Some(context) = evidence_context {
write_scanner_heal_evidence(
context,
ScannerHealEvidencePayload {
dist: &dist,
bucket: &bucket,
expected: &expected,
outage_key,
outage_body: &outage_body,
replaced_drive: &replaced_drive,
pid_before: target_pid_before,
pid_after: target_pid_after,
node_listings,
},
)?;
)
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cluster_heal_coordination_retry_is_exact() {
let retryable: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 500 Internal Server Error cluster heal coordination unavailable".into();
assert!(is_cluster_heal_coordination_unavailable(retryable.as_ref()));
let other_internal: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 500 Internal Server Error unrelated".into();
assert!(!is_cluster_heal_coordination_unavailable(other_internal.as_ref()));
let wrong_status: Box<dyn std::error::Error + Send + Sync> =
"admin POST failed: 503 Service Unavailable cluster heal coordination unavailable".into();
assert!(!is_cluster_heal_coordination_unavailable(wrong_status.as_ref()));
}
}
@@ -14,9 +14,9 @@
use super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
decommission_status_json, put_inventory_retrying, rebalance_active, rebalance_status_json, retrying_get_equals, retrying_put,
start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_decommission_running_with_progress, wait_for_rebalance_active, wait_for_rebalance_complete,
};
use crate::common::init_logging;
use std::time::Duration;
@@ -67,7 +67,10 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
assert_inventory(&live, &bucket, &inventory).await?;
let rebalance_id = start_rebalance(&dist.cluster).await?;
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
// The status API reads persisted progress, whose first periodic save is
// after 30 seconds. A shorter run can remain at zero until completion.
// Require Started around the S3 operations and nonzero progress at completion.
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
retrying_put(
&live,
&bucket,
@@ -84,11 +87,26 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
Duration::from_secs(30),
)
.await?;
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
assert!(
listed
.contents()
.iter()
.any(|object| object.key() == Some("during-rebalance.bin")),
"list during rebalance missed the newly written key"
);
let status = rebalance_status_json(&dist.cluster).await?;
if !rebalance_running_with_progress(&status, &rebalance_id)? {
if !rebalance_active(&status, &rebalance_id)? {
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
}
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
let after = dist.client(1)?;
assert_inventory(&after, &bucket, &inventory).await?;
for (key, body) in [
("during-decommission.bin", b"written-while-decommissioning".as_slice()),
("during-rebalance.bin", b"written-while-rebalancing".as_slice()),
] {
retrying_get_equals(&after, &bucket, key, body, Duration::from_secs(30)).await?;
}
Ok(())
}
@@ -498,9 +498,7 @@ mod tests {
.stderr(log)
.spawn()?,
);
// macOS evaluates each fresh binary copy before its capability hook can run.
let probe_timeout = if cfg!(target_os = "macos") { 60 } else { 10 };
let status = tokio::time::timeout(Duration::from_secs(probe_timeout), async {
let status = tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let Some(status) = child.0.try_wait()? {
return Ok::<_, std::io::Error>(status);
+1 -6
View File
@@ -114,12 +114,7 @@ impl FaultProxy {
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted
/// connections to `target`. Starts in [`FaultMode::Pass`].
pub async fn start(target: SocketAddr) -> io::Result<Self> {
Self::start_on((Ipv4Addr::LOCALHOST, 0).into(), target).await
}
/// Bind the selected address and forward accepted connections to `target`.
pub(crate) async fn start_on(address: SocketAddr, target: SocketAddr) -> io::Result<Self> {
let listener = TcpListener::bind(address).await?;
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let listen_addr = listener.local_addr()?;
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
@@ -401,11 +401,11 @@ mod tests {
let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch");
baseline_degraded.insert(multipart_key.to_string(), mp_view);
// Stop disk writers before restoring the complete layout reused by Phase B.
harness.kill_server();
// Restore the disk so Phase B restarts from a clean, complete disk set.
harness.bring_disk_online(0)?;
// ---- Phase B: codec streaming (gates opened) ----
harness.kill_server();
for (k, v) in codec_env() {
harness.set_env(k, v);
}
@@ -497,8 +497,6 @@ mod tests {
let mp_view = get_full(&client, multipart_key).await?;
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch");
codec_degraded.insert(multipart_key.to_string(), mp_view);
// All server reads are complete; stop disk writers before restoring disk0.
harness.kill_server();
harness.bring_disk_online(0)?;
// A/B under parity reconstruction: codec == legacy, byte-for-byte and
@@ -16,20 +16,18 @@
#[cfg(test)]
mod tests {
use crate::chaos::{
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post,
wait_for_complete_physical_shard_on_disk,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
use crate::common::{
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
rustfs_binary_path,
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
init_logging, rustfs_binary_path,
};
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::error::Error;
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -41,6 +39,52 @@ mod tests {
const POOL_METADATA_OBJECT: &str = "pool.bin";
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
struct ScannerHealEvidenceCase {
id: &'static str,
oracle: &'static str,
evidence: &'static str,
unclean_shutdown_marker: bool,
topology: EvidenceTopology,
storage_class_standard: Option<&'static str>,
erasure_set_drive_count: Option<&'static str>,
}
#[derive(Clone, Copy)]
struct EvidenceTopology {
nodes: usize,
drives_per_node: usize,
}
impl EvidenceTopology {
const fn new(nodes: usize, drives_per_node: usize) -> Self {
Self { nodes, drives_per_node }
}
fn total_drives(self) -> usize {
self.nodes * self.drives_per_node
}
fn cluster_topology(self) -> ClusterTopology {
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
}
}
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
id: "background-target-restart",
oracle: "background-target-restart.json",
@@ -81,6 +125,81 @@ mod tests {
erasure_set_drive_count: Some("12"),
};
struct RestartEvidenceContext {
directory: PathBuf,
run: RestartEvidenceRun,
case: ScannerHealEvidenceCase,
}
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
fn restart_evidence_run(
binary: &Path,
case: ScannerHealEvidenceCase,
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
if case.id.is_empty()
|| case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
struct TcpPortBlackhole {
port: u16,
comment: String,
@@ -385,79 +504,6 @@ mod tests {
)
}
async fn wait_for_admin_cluster_start_log(
log_path: &Path,
client_token: &str,
deadline: Instant,
) -> Result<(), Box<dyn Error + Send + Sync>> {
loop {
let coordinator_log = std::fs::read_to_string(log_path)?;
if coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
})
{
return Ok(());
}
if Instant::now() >= deadline {
return Err(
format!("node 0 must have started the exact admin task before interruption: task_id={client_token}").into(),
);
}
sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn test_admin_cluster_start_log_waits_for_exact_delayed_event() -> Result<(), Box<dyn Error + Send + Sync>> {
use std::io::Write;
let mut log = tempfile::NamedTempFile::new()?;
for (task_id, heal_type, state) in [
("other-task", "cluster", "started"),
("admin-task", "object", "started"),
("admin-task", "cluster", "completed"),
] {
writeln!(
log,
"{}",
serde_json::json!({"event": "heal_task_state", "task_id": task_id, "heal_type": heal_type, "state": state})
)?;
}
let log_path = log.path().to_path_buf();
let started = wait_for_admin_cluster_start_log(&log_path, "admin-task", Instant::now() + Duration::from_secs(1));
tokio::pin!(started);
// Poll the reader before publishing the start event, without depending
// on scheduling or a fixed writer delay to reproduce log visibility.
tokio::select! {
biased;
result = &mut started => panic!("unrelated events must leave the exact start pending: {result:?}"),
_ = std::future::ready(()) => {}
}
writeln!(
log,
"{}",
serde_json::json!({"event": "heal_task_state", "task_id": "admin-task", "heal_type": "cluster", "state": "started"})
)?;
started.await?;
Ok(())
}
#[tokio::test]
async fn test_admin_cluster_start_log_respects_existing_deadline() -> Result<(), Box<dyn Error + Send + Sync>> {
let log = tempfile::NamedTempFile::new()?;
let error = wait_for_admin_cluster_start_log(log.path(), "admin-task", Instant::now())
.await
.expect_err("missing exact start must fail at the supplied deadline");
assert!(error.to_string().contains("task_id=admin-task"), "{error}");
Ok(())
}
fn cluster_heal_is_idle(status: &serde_json::Value) -> bool {
let operations = &status["healOperations"];
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
@@ -894,10 +940,7 @@ mod tests {
cluster: &RustFSTestClusterEnvironment,
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let started = Instant::now();
let mut deadline = started + Duration::from_secs(60);
let catch_up_deadline = deadline + Duration::from_secs(300);
let mut catch_up_wait_observed = false;
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
@@ -925,61 +968,24 @@ mod tests {
let versions_scanned = metrics["versions_scanned"]
.as_u64()
.ok_or("scanner status is missing its version-coverage counter")?;
let cycle_result = metrics["last_cycle_result"]
.as_str()
.ok_or("scanner status is missing its cycle result")?;
if cycle_result == "success" {
latest_cycle_end = latest_cycle_end.max(cycle_end);
}
latest_cycle_end = latest_cycle_end.max(cycle_end);
versions_observed |= versions_scanned > 0;
let backlog = &status["pause_backlog"];
if !catch_up_wait_observed
&& backlog["persistence_state"].as_str() == Some("healthy")
&& backlog["durable"].as_bool() == Some(true)
&& backlog["phase"].as_str() == Some("catching_up")
&& backlog["rate_limited"].as_bool() == Some(true)
&& backlog["retry_exhausted"].as_bool() == Some(false)
{
let next_attempt = backlog["next_attempt_at_unix_secs"]
.as_u64()
.ok_or("rate-limited scanner backlog is missing its next attempt")?;
let interval = backlog["thresholds"]["catch_up_min_interval_seconds"]
.as_u64()
.ok_or("rate-limited scanner backlog is missing its catch-up interval")?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
let remaining = next_attempt.saturating_sub(now);
if remaining > 0 {
if interval > 300 || remaining > interval {
return Err(
format!("scanner catch-up schedule exceeds the bounded recovery budget: {backlog}").into()
);
}
// The durable catch-up interval overrides SCANNER_CYCLE=1.
// Honor one observed retry without restarting the deadline on every poll.
deadline = deadline
.max(Instant::now() + Duration::from_secs(remaining + 60))
.min(catch_up_deadline);
catch_up_wait_observed = true;
}
}
observations.push(format!(
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, backlog={}",
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
metrics["current_cycle"],
metrics["current_cycle_active"],
metrics["leader_lock_state"],
metrics["last_cycle_result"],
backlog,
));
}
// Only a successful coordinator cycle counts as completion; deferred
// and superseded attempts also advance its end timestamp. Remote
// workers record version coverage, so the witnesses can span nodes.
// The coordinator records cycle completion, but remote workers
// record scanned versions. Both witnesses need not share a node.
if latest_cycle_end > previous_cycle_end && versions_observed {
return Ok(latest_cycle_end);
}
if Instant::now() >= deadline {
return Err(format!(
"enabled scanner did not complete a successful object-scanning cycle after {previous_cycle_end}: {observations:?}"
"enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
)
.into());
}
@@ -999,7 +1005,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(720),
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
)
.await?
@@ -1009,7 +1015,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(720),
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
)
.await?
@@ -1019,7 +1025,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(720),
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
)
.await?
@@ -1029,7 +1035,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(720),
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
)
.await?
@@ -1039,7 +1045,7 @@ mod tests {
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
timeout(
Duration::from_secs(720),
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
)
.await?
@@ -1146,19 +1152,10 @@ mod tests {
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
cluster.set_env("RUST_LOG", server_rust_log);
let log_dir = if let Some(directory) = std::env::var_os("RUSTFS_HEAL_CHAOS_LOG_DIR") {
PathBuf::from(directory)
} else if let Some(directory) = std::env::var_os("RUSTFS_E2E_LOG_DIR") {
let cluster_name = Path::new(&cluster.temp_dir)
.file_name()
.ok_or("cluster directory has no name")?;
PathBuf::from(directory).join(cluster_name).join("heal")
} else {
PathBuf::from(&cluster.temp_dir).join("logs")
};
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, log_dir.join(format!("node{node_index}.log")).to_string_lossy())?;
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
}
cluster.start_with_binary(&server_binary).await?;
let clients = cluster.create_all_clients()?;
@@ -1213,8 +1210,6 @@ mod tests {
attempt_count += 1;
continue;
}
let shard_census =
wait_for_complete_physical_shard_on_disk(&replaced_disk, bucket, &key, None, Duration::from_secs(10)).await?;
assert!(
shard_census.is_complete(),
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
@@ -1401,9 +1396,6 @@ mod tests {
}
sleep(Duration::from_millis(50)).await;
}
// Task execution and its non-blocking log writer advance independently.
// Observe the exact start before taking the partial-rebuild snapshot.
wait_for_admin_cluster_start_log(&log_dir.join("node0.log"), client_token, partial_deadline).await?;
let (partial_count, partial_manifest) = loop {
// Hash one committed shard to prove progress without letting a
// full-corpus hash pass consume the interruption window.
@@ -1438,6 +1430,19 @@ mod tests {
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
assert!(
coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
}),
"node 0 must have started the exact admin task before interruption"
);
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
assert_eq!(
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
@@ -1562,11 +1567,12 @@ mod tests {
"Restored target endpoint forwarding"
);
} else {
let graceful_restart = matches!(
if matches!(
scenario,
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84
);
if graceful_restart {
InterruptionScenario::BackgroundTargetRestart
| InterruptionScenario::BackgroundTargetRestartEc84
| InterruptionScenario::BackgroundCoordinatorRestart
) {
cluster.stop_node_gracefully(interruption_node).await?;
} else {
cluster.stop_node(interruption_node)?;
@@ -1588,7 +1594,10 @@ mod tests {
if background_enabled {
let marker_exists = unclean_shutdown_marker.is_file();
unclean_shutdown_marker_observed = Some(marker_exists);
let expected_marker = !graceful_restart;
let expected_marker = matches!(
scenario,
InterruptionScenario::BackgroundTargetCrash | InterruptionScenario::BackgroundTargetCrashEc84
);
assert!(
marker_exists == expected_marker,
"background restart/crash lane observed unexpected unclean-shutdown marker state"
@@ -1644,19 +1653,7 @@ mod tests {
}
}
if Instant::now() >= heal_deadline {
let mut mismatched_manifests = Vec::new();
for expected in &expected_manifests {
let actual = census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?;
if !actual.matches_manifest(&expected.shard_census) {
mismatched_manifests.push(serde_json::json!({
"key": expected.key,
"expected": expected.shard_census,
"actual": actual,
}));
}
}
let matching = expected_manifests.len() - mismatched_manifests.len();
let mismatched_manifests = serde_json::Value::Array(mismatched_manifests);
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
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)?;
@@ -1679,7 +1676,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}/{}, mismatched_manifests={mismatched_manifests}, 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={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}",
expected_manifests.len()
)
.into());
@@ -1772,49 +1769,52 @@ mod tests {
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
if interruption_node == 0 {
// Admin tasks are process-local. Physical and queue convergence
// above establish recovery; a lost task must not report success.
assert_eq!(
task_status["summary"].as_str(),
Some("notFound"),
"interrupted task status: {task_status}"
);
assert_eq!(
task_status["detail"].as_str(),
Some("heal task not found or expired"),
"interrupted admin task must be explicitly unavailable: {task_status}"
);
// Restart recovery must finish the original durable root request.
info!(
event = "heal_interruption_recovered",
component = "e2e_test",
subsystem = "heal",
interruption_node,
interruption_kind,
task_state = "not_found",
"Physical recovery completed after coordinator restart"
task_state = "finished",
"Original root heal completed after coordinator restart"
);
return Ok(());
}
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
if let Some(evidence_context) = evidence_run {
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
assert_ne!(target_pid, restarted_pid, "target must be a new process");
evidence_context.write(
&server_binary,
RestartObservation {
nodes: cluster.nodes.len(),
drives_per_node: cluster.nodes[0].data_dirs.len(),
pid_before: target_pid,
pid_after: restarted_pid,
unclean_shutdown_marker: unclean_shutdown_marker_observed.ok_or("missing shutdown marker observation")?,
objects: evidence_objects,
node_listings,
},
)?;
assert_eq!(
file_sha256(&server_binary)?,
evidence_context.run.binary.sha256,
"server build changed during restart"
);
let evidence = serde_json::json!({
"schema": 1, "case": evidence_context.case.id, "evidence": evidence_context.case.evidence,
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
"test_build": compiled_test_identity(),
"binary_sha256": evidence_context.run.binary.sha256,
"test_binary_sha256": evidence_context.run.test_binary.sha256,
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
"pid_before": target_pid, "pid_after": restarted_pid,
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
"objects": evidence_objects, "node_listings": node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(evidence_context.directory.join(evidence_context.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
}
Ok(())
@@ -21,7 +21,7 @@
//! One S3 GET can select readers on multiple EC nodes, so the counter tracks
//! distributed reader selection rather than HTTP request count.
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, signal_process};
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
@@ -2207,33 +2207,6 @@ async fn four_node_manual_transition_job_status_survives_node_restart() -> TestR
Ok(())
}
struct SuspendedTransitionTarget<'a> {
// Keep the owned child borrowed until it is resumed so its PID cannot be reused.
child: &'a std::process::Child,
suspended: bool,
}
impl<'a> SuspendedTransitionTarget<'a> {
fn suspend(child: &'a std::process::Child) -> TestResult<Self> {
signal_process(child.id(), "STOP")?;
Ok(Self { child, suspended: true })
}
fn resume(&mut self) -> TestResult {
signal_process(self.child.id(), "CONT")?;
self.suspended = false;
Ok(())
}
}
impl Drop for SuspendedTransitionTarget<'_> {
fn drop(&mut self) {
if self.suspended {
let _ = signal_process(self.child.id(), "CONT");
}
}
}
#[tokio::test]
async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult {
init_logging();
@@ -2271,21 +2244,7 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
.send()
.await?;
}
// Lifecycle PUT starts its own backfill. Keep its first page on a separate
// node and stop it at queue backpressure before it reaches the tested prefix:
// one active worker, one queued item, then the first rejected item.
for index in 0u8..3 {
hot_client
.put_object()
.bucket(&bucket)
.key(format!("transition/automatic-admission/object-{index:02}.bin"))
.body(ByteStream::from(payload(KIB, index)))
.send()
.await?;
}
let mut suspended_cold = SuspendedTransitionTarget::suspend(cold.process.as_ref().ok_or("cold-tier process missing")?)?;
let lifecycle_client = hot.create_s3_client(2)?;
put_lifecycle_with_transition_retry(&lifecycle_client, &bucket, &tier_name).await?;
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
let (node0, node1) = tokio::join!(
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
@@ -2345,31 +2304,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
assert_eq!(status["job_id"].as_str(), Some(job_id));
assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint));
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let status = read_manual_transition_job_status_endpoint(&hot, accepted.0, status_endpoint).await?;
assert_eq!(
status["status"].as_str(),
Some("running"),
"blocked cold tier must keep the admitted job running: {status}"
);
if status["report"]["skipped_queue_full"].as_u64().is_some_and(|count| count > 0) {
assert!(
status["report"]["enqueued"].as_u64().is_some_and(|count| count > 0),
"the job must own pending transitions while the cold tier is suspended: {status}"
);
break;
}
if Instant::now() >= deadline {
return Err(format!(
"manual transition job did not reach queue backpressure while the cold tier was suspended: {status}"
)
.into());
}
sleep(Duration::from_millis(50)).await;
}
suspended_cold.resume()?;
let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?;
assert_eq!(terminal["job_id"].as_str(), Some(job_id));
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
@@ -109,10 +109,10 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
.await;
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500));
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(503));
assert_eq!(
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InternalError")
Some("ServiceUnavailable")
);
let unavailable_absence = s3_client
.get_object()
-3
View File
@@ -23,9 +23,6 @@ pub mod common;
#[cfg(test)]
pub mod chaos;
#[cfg(test)]
mod scanner_heal_evidence;
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
// and on-demand-migration source scenarios (backlog#2151).
#[cfg(test)]
+6 -5
View File
@@ -15,6 +15,7 @@
//! Regression coverage for anonymous access on multipart control APIs.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use crate::kms::common::LocalKMSTestEnvironment;
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
@@ -1465,10 +1466,10 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let master_key = local_sse_master_key_value();
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
.await?;
let mut kms_env = LocalKMSTestEnvironment::new().await?;
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
kms_env.wait_for_kms_ready().await?;
let env = &kms_env.base_env;
let bucket = "anon-post-default-sse-kms";
let object_key = "post-default-sse-kms-object.txt";
@@ -1484,7 +1485,7 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::AwsKms)
.kms_master_key_id("test-key")
.kms_master_key_id(default_key_id)
.build()
.expect("default encryption rule should build"),
)
@@ -1137,7 +1137,12 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
let miss = env.raw_get(bucket, miss_key).await?;
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
}
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
let (listed, _, _) = tokio::try_join!(
env.wait_local_listed(bucket, hit_key, SETTLE),
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
)?;
assert!(listed);
let status = env.status_json(bucket).await?;
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
@@ -79,11 +79,6 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
.await
.map_err(|e| anyhow!("{}", e))?;
// Protocol listeners can accept connections before IAM is initialized.
// A signed S3 request establishes readiness before the first SFTP login.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?;
cmptst_01::run_medium_binary_round_trip(&sftp).await?;
@@ -106,6 +101,8 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
// reach the finalised object as x-amz-meta-* user metadata
// through the CreateMultipartUpload input field. The S3 client
// connects to the same rustfs process this suite already drives.
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
wait_for_s3_ready(&s3, 30).await?;
cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?;
drop(sftp);
+10 -7
View File
@@ -168,10 +168,6 @@ pub async fn test_sftp_core_operations() -> Result<()> {
.await
.map_err(|e| anyhow!("{}", e))?;
// Protocol listeners can accept connections before IAM is initialized.
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
let (session, sftp) = connect_sftp().await?;
// --- 1. Subsystem canary: SFTP session reachable after password auth ---
@@ -352,6 +348,16 @@ pub async fn test_sftp_core_operations() -> Result<()> {
let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
info!("PASS: bad-password authentication rejected");
// --- Cross-protocol setup: aws-sdk-s3 client against the same server ---
// The rustfs binary spawned for this suite serves both SFTP on port
// 9022 and S3 on port 9000. The S3 stack may need a moment to finish
// initialising after TCP is listening, so list_buckets is polled
// until it succeeds before any cross-protocol assertion runs.
info!("Testing SFTP: prepare aws-sdk-s3 client and wait for S3 readiness");
let s3 = build_test_s3_client(S3_ENDPOINT);
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
info!("PASS: S3 endpoint reachable from cross-protocol client");
// --- SFTP write, S3 read: SHA256 round-trip ---
// SFTP creates the object, then assert_cross_protocol_sha_match
// fetches it via both S3 GetObject and SFTP READ and compares
@@ -516,9 +522,6 @@ pub async fn test_sftp_idle_timeout_disconnects() -> Result<()> {
.await
.map_err(|e| anyhow!("{}", e))?;
let s3 = build_test_s3_client(&format!("http://{IDLE_S3_ADDRESS}"));
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?;
// Confirm the session is live before the wait so a failure in the
@@ -4235,6 +4235,16 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
</Rule>
<Rule>
<ID>matrix-and-tags</ID>
<Priority>135</Priority>
<Status>Enabled</Status>
<Filter><And><Prefix>and-tags/</Prefix><Tag><Key>env</Key><Value>prod</Value></Tag><Tag><Key>tier</Key><Value>gold</Value></Tag></And></Filter>
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
</Rule>
<Rule>
<ID>matrix-disabled</ID>
<Priority>140</Priority>
@@ -4289,6 +4299,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
"matrix-prefix",
"matrix-tag",
"matrix-disabled",
"matrix-and-tags",
"matrix-priority-high",
"Priority>200",
"<Status>Disabled</Status>",
@@ -4409,6 +4420,30 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
// S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
// the required tags is not admitted. Matching any single tag would push
// data to a destination the rule never selected (backlog#2366 P1-1), and
// the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
// writes, so a single-tag rule passing is not evidence for this.
source_client
.put_object()
.bucket(source_bucket)
.key("and-tags/partial.txt")
.tagging("env=prod")
.body(ByteStream::from_static(b"one of two tags"))
.send()
.await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
source_client
.put_object()
.bucket(source_bucket)
.key("and-tags/full.txt")
.tagging("env=prod&tier=gold")
.body(ByteStream::from_static(b"both tags"))
.send()
.await?;
wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
source_client
.put_object()
.bucket(source_bucket)
@@ -1,181 +0,0 @@
// 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.
//! Build-bound evidence for scanner and heal restart tests.
use crate::common::ClusterTopology;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
pub(crate) struct ScannerHealEvidenceCase {
pub(crate) id: &'static str,
pub(crate) oracle: &'static str,
pub(crate) evidence: &'static str,
pub(crate) unclean_shutdown_marker: bool,
pub(crate) topology: EvidenceTopology,
pub(crate) storage_class_standard: Option<&'static str>,
pub(crate) erasure_set_drive_count: Option<&'static str>,
}
#[derive(Clone, Copy)]
pub(crate) struct EvidenceTopology {
pub(crate) nodes: usize,
pub(crate) drives_per_node: usize,
}
impl EvidenceTopology {
pub(crate) const fn new(nodes: usize, drives_per_node: usize) -> Self {
Self { nodes, drives_per_node }
}
pub(crate) fn total_drives(self) -> usize {
self.nodes * self.drives_per_node
}
pub(crate) fn cluster_topology(self) -> ClusterTopology {
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
}
}
pub(crate) struct RestartEvidenceContext {
directory: PathBuf,
run: RestartEvidenceRun,
case: ScannerHealEvidenceCase,
}
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
pub(crate) fn restart_evidence_run(
binary: &Path,
case: ScannerHealEvidenceCase,
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
if case.id.is_empty()
|| case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
pub(crate) struct RestartObservation {
pub(crate) nodes: usize,
pub(crate) drives_per_node: usize,
pub(crate) pid_before: u32,
pub(crate) pid_after: u32,
pub(crate) unclean_shutdown_marker: bool,
pub(crate) objects: Vec<serde_json::Value>,
pub(crate) node_listings: Vec<Vec<String>>,
}
impl RestartEvidenceContext {
pub(crate) fn write(self, binary: &Path, observed: RestartObservation) -> Result<(), Box<dyn Error + Send + Sync>> {
assert_ne!(observed.pid_before, observed.pid_after, "target must be a new process");
assert_eq!(file_sha256(binary)?, self.run.binary.sha256, "server build changed during restart");
let evidence = serde_json::json!({
"schema": 1, "case": self.case.id, "evidence": self.case.evidence,
"run_id": self.run.run_id, "source_revision": self.run.source_revision,
"test_build": compiled_test_identity(),
"binary_sha256": self.run.binary.sha256,
"test_binary_sha256": self.run.test_binary.sha256,
"topology": {"nodes": observed.nodes, "drives_per_node": observed.drives_per_node},
"pid_before": observed.pid_before, "pid_after": observed.pid_after,
"unclean_shutdown_marker": observed.unclean_shutdown_marker,
"objects": observed.objects, "node_listings": observed.node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(self.directory.join(self.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
Ok(())
}
}
@@ -32,8 +32,10 @@ use aws_sdk_s3::types::{
VersioningConfiguration,
};
use http::{Method, StatusCode};
use serde_json::Value;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::task::JoinSet;
use tokio::time::{Instant, sleep};
@@ -41,6 +43,7 @@ type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
const G09_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR";
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
@@ -81,6 +84,14 @@ const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
// of treating it as an upgrade failure.
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
struct G09EvidenceContext {
directory: PathBuf,
current_revision: String,
previous_revision: String,
run_id: String,
measurement_window_id: String,
}
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let path = std::env::var_os(SOURCE_BINARY_ENV)
.map(PathBuf::from)
@@ -91,6 +102,155 @@ fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
Ok(path)
}
fn is_lower_hex_revision(value: &str) -> bool {
value.len() == 40
&& value
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
fn current_source_revision() -> Result<String, BoxError> {
let revision = env!("RUSTFS_E2E_BUILD_COMMIT");
if !is_lower_hex_revision(revision) {
return Err(format!("current test binary has invalid source revision {revision}").into());
}
Ok(revision.to_string())
}
async fn binary_source_revision(binary: &Path) -> Result<String, BoxError> {
let output = tokio::process::Command::new(binary).arg("--version").output().await?;
if !output.status.success() {
return Err(format!("{} --version failed with {}", binary.display(), output.status).into());
}
let version = String::from_utf8(output.stdout)?;
source_revision_from_version_output(&version, binary)
}
fn source_revision_from_version_output(version: &str, binary: &Path) -> Result<String, BoxError> {
version
.split(|ch: char| !ch.is_ascii_hexdigit())
.find(|token| is_lower_hex_revision(token))
.map(str::to_string)
.ok_or_else(|| format!("{} --version did not expose a 40-byte source revision", binary.display()).into())
}
async fn g09_evidence_context(previous_binary: &Path) -> Result<Option<G09EvidenceContext>, BoxError> {
let Some(directory) = std::env::var_os(G09_EVIDENCE_DIR_ENV) else {
return Ok(None);
};
let directory = PathBuf::from(directory);
std::fs::create_dir_all(&directory)?;
let current_revision = current_source_revision()?;
let previous_revision = binary_source_revision(previous_binary).await?;
if current_revision == previous_revision {
return Err("G09 mixed-version evidence requires distinct current and previous source revisions".into());
}
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(Some(G09EvidenceContext {
directory,
current_revision,
previous_revision,
run_id: format!("g09-upgrade-{}-{now}", std::process::id()),
measurement_window_id: format!("g09-mixed-version-window-{now}"),
}))
}
fn write_g09_evidence(
context: &G09EvidenceContext,
field: &str,
role: &str,
cases: &[&str],
test: &str,
details: Value,
) -> TestResult {
let path = context.directory.join(format!("G09-{field}.json"));
let mut evidence = serde_json::json!({
"schema": 1,
"evidence_type": "measured",
"artifact_kind": "upgrade-compatibility-e2e",
"source_revision": context.current_revision,
"run_id": context.run_id,
"measurement_window_id": context.measurement_window_id,
"gate": "G09",
"field": field,
"versions": [context.previous_revision, context.current_revision],
"current_revision": context.current_revision,
"previous_revision": context.previous_revision,
"mixed_version_role": role,
"mixed_version_cases": cases,
"test": test,
"details": details,
});
if field == "rollback_payload_evidence" {
evidence["rollback_payload_replayed"] = Value::Bool(true);
}
let data = serde_json::to_vec_pretty(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("G09 mixed-version evidence exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
output.write_all(&data)?;
output.write_all(b"\n")?;
output.sync_all()?;
Ok(())
}
#[cfg(test)]
mod g09_evidence_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn source_revision_parser_requires_lowercase_forty_byte_sha() -> TestResult {
let binary = Path::new("rustfs");
assert_eq!(
source_revision_from_version_output("rustfs 1.0.0 abcdef0123456789abcdef0123456789abcdef01 clean", binary)?,
"abcdef0123456789abcdef0123456789abcdef01"
);
assert!(source_revision_from_version_output("rustfs ABCDEF0123456789ABCDEF0123456789ABCDEF01", binary).is_err());
assert!(source_revision_from_version_output("rustfs abcdef", binary).is_err());
Ok(())
}
#[test]
fn g09_evidence_writer_records_measured_role_and_refuses_overwrite() -> TestResult {
let directory = TempDir::new()?;
let context = G09EvidenceContext {
directory: directory.path().to_path_buf(),
current_revision: "b".repeat(40),
previous_revision: "a".repeat(40),
run_id: "g09-upgrade-test-run".to_string(),
measurement_window_id: "g09-upgrade-test-window".to_string(),
};
write_g09_evidence(
&context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"unit",
serde_json::json!({"assertions": ["reader evidence"]}),
)?;
let path = directory.path().join("G09-mixed_version_reader_evidence.json");
let evidence: Value = serde_json::from_slice(&std::fs::read(path)?)?;
assert_eq!(evidence["evidence_type"], "measured");
assert_eq!(evidence["gate"], "G09");
assert_eq!(evidence["field"], "mixed_version_reader_evidence");
assert_eq!(evidence["mixed_version_role"], "mixed-version-reader");
assert_eq!(evidence["versions"], serde_json::json!(["a".repeat(40), "b".repeat(40)]));
let overwrite = write_g09_evidence(
&context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"unit",
serde_json::json!({}),
);
assert!(overwrite.is_err(), "G09 evidence must not overwrite an existing artifact");
Ok(())
}
}
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
let configuration = VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
@@ -523,6 +683,7 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let evidence_context = g09_evidence_context(&previous_binary).await?;
let current_binary = rustfs_binary_path();
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
@@ -555,6 +716,45 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
}
}
if let Some(context) = evidence_context.as_ref() {
let phases = ["one-current-node", "one-previous-node"];
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
write_g09_evidence(
context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"upgrade_compatibility_test::rolling_upgrade_from_rc2_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"assertions": [
"current node reads objects written through previous-release client",
"previous-release node reads objects written through current client",
"all nodes list every mixed-version object after homogeneous-current convergence"
],
}),
)?;
write_g09_evidence(
context,
"mixed_version_writer_evidence",
"mixed-version-writer",
&["old-reader-new-writer", "new-reader-old-writer"],
"upgrade_compatibility_test::rolling_upgrade_from_rc2_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
"objects_per_phase": objects_per_phase,
"assertions": [
"current writer publishes objects readable by previous-release node",
"previous-release writer publishes objects readable by current node",
"multipart writers continue under one-current-node and one-previous-node layouts"
],
}),
)?;
}
Ok(())
}
@@ -1136,6 +1336,7 @@ async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let evidence_context = g09_evidence_context(&previous_binary).await?;
let replication_target = FakeS3Target::start().await?;
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
@@ -1204,6 +1405,54 @@ async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestRes
);
assert_eq!(body, post_rollback_bytes);
env.restart_server_preserving_data(vec![], &server_env).await?;
let current_again = env.create_s3_client();
assert_versioning_enabled(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_default_sse_s3_encryption(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_bucket_tag(&current_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after rolling forward again").await?;
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, single_key, Some(&single_version))
.await?
.1,
single_bytes
);
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, multipart_key, None).await?.1,
multipart_bytes
);
assert_eq!(
read_object(&current_again, ROLLBACK_BUCKET, post_rollback_key, None).await?.1,
post_rollback_bytes
);
if let Some(context) = evidence_context.as_ref() {
write_g09_evidence(
context,
"rollback_payload_evidence",
"rollback-payload",
&["rollback-to-old", "rollback-to-new", "unknown-field-retained"],
"upgrade_compatibility_test::rollback_to_previous_release_reads_current_bucket_metadata",
serde_json::json!({
"bucket": ROLLBACK_BUCKET,
"cases": {
"rollback-to-old": [
"previous-release binary reads current-build versioning, SSE-S3, tags, replication target and objects",
"previous-release writer honors the decoded current-build encryption configuration"
],
"rollback-to-new": [
"current build reads the object written by the rolled-back previous release",
"current build reads the current-build single-part and multipart objects after rolling forward again"
],
"unknown-field-retained": [
"previous release skips current-build bucket metadata extension fields without dropping known bucket configuration",
"current build reads the retained bucket configuration after the previous-release round trip"
]
},
}),
)?;
}
replication_target.shutdown().await;
Ok(())
}
@@ -5165,6 +5165,9 @@ pub async fn put_restore_opts(
user_defined: meta,
version_id: oi.version_id.map(|e| e.to_string()),
mod_time: oi.mod_time,
// Restore writes stored (possibly encrypted) bytes, so the writer's
// computed MD5 is not the object's public plaintext ETag.
preserve_etag: oi.etag.clone(),
//expires: oi.expires,
..Default::default()
})
+492 -21
View File
@@ -2606,6 +2606,24 @@ where
usize::try_from(size).unwrap_or_default()
}
fn is_decommission_set_local_usage_cache(bucket: &str, object: &str) -> bool {
if bucket != RUSTFS_META_BUCKET {
return false;
}
let Some(path) = object
.strip_prefix(BUCKET_META_PREFIX)
.and_then(|path| path.strip_prefix('/'))
else {
return false;
};
let name = match path.rsplit_once('/') {
Some((bucket, name)) if !bucket.is_empty() && !bucket.contains('/') && bucket != "." && bucket != ".." => name,
Some(_) => return false,
None => path,
};
name.strip_suffix(".bkp").unwrap_or(name) == DATA_USAGE_CACHE_NAME
}
fn with_decommission_entry_context<E: Display>(stage: &str, bucket: &str, object: &str, err: E) -> Error {
Error::other(format!("decommission entry {stage} failed for bucket {bucket} object {object}: {err}"))
}
@@ -3463,6 +3481,11 @@ impl PoolRebalanceActivationFence {
}
}
#[cfg(test)]
tokio::task_local! {
pub(crate) static REBALANCE_ACTIVATION_LOCK_ATTEMPT: Arc<tokio::sync::Notify>;
}
pub(crate) async fn acquire_pool_rebalance_activation_locks<S>(
pool: Arc<S>,
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
@@ -3473,17 +3496,21 @@ where
NamespaceLock = rustfs_lock::NamespaceLockWrapper,
>,
{
// Activation lock order is always pool.bin -> rebalance.bin.
// Match entry admission: rebalance.bin -> pool.bin. An entry retains its
// run read fence while target mutations acquire the pool metadata fence;
// activation must not hold pool.bin while waiting for that entry to drain.
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
#[cfg(test)]
let _ = REBALANCE_ACTIVATION_LOCK_ATTEMPT.try_with(|attempted| attempted.notify_one());
let rebalance_meta_guard = rebalance_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_rebalance_meta_lock_error)?;
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
let pool_meta_guard = pool_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_pool_meta_lock_error)?;
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
let rebalance_meta_guard = rebalance_meta_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(activation_rebalance_meta_lock_error)?;
Ok(PoolRebalanceActivationFence {
pool_meta_guard,
@@ -13285,6 +13312,12 @@ impl ECStore {
);
return Ok(DecommissionEntryAttemptOutcome::Complete);
}
// Scanner caches describe their own erasure set and are rebuilt there.
// Copying one onto another set can overwrite unrelated cache contents or
// leave an unresolvable target-capacity intent after a conditional PUT.
if is_decommission_set_local_usage_cache(&bucket, &entry.name) {
return Ok(DecommissionEntryAttemptOutcome::Complete);
}
let durable_ilm_record = if bucket == RUSTFS_META_BUCKET {
classify_durable_ilm_record(&entry.name)
.map_err(|err| with_decommission_entry_context("durable_ilm_namespace", &bucket, &entry.name, err))?
@@ -13842,7 +13875,7 @@ impl ECStore {
let bucket = bucket.clone();
let rd = match set
let read_result = set
.get_object_reader(
bucket.as_str(),
&encode_dir_object(&version.name),
@@ -13850,8 +13883,11 @@ impl ECStore {
HeaderMap::new(),
&decommission_object_migration_read_opts(version_id.clone()),
)
.await
{
.await;
#[cfg(test)]
let read_result =
decommission_test_wrap_result("object_read", &bucket, &version.name, version_attempt, read_result);
let rd = match read_result {
Ok(rd) => rd,
Err(err) => {
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
@@ -13860,15 +13896,6 @@ impl ECStore {
break;
}
if !ignore {
//
if bucket == RUSTFS_META_BUCKET && version.name.contains(DATA_USAGE_CACHE_NAME) {
ignore = true;
error!("decommission_pool: ignore data usage cache {}", &version.name);
break;
}
}
failure = true;
if version_attempt == DECOMMISSION_VERSION_COPY_ATTEMPTS {
error!(
@@ -16836,7 +16863,7 @@ impl ECStore {
return;
}
if bucket_name == RUSTFS_META_BUCKET && entry.name.contains(DATA_USAGE_CACHE_NAME) {
if is_decommission_set_local_usage_cache(&bucket_name, &entry.name) {
return;
}
@@ -17154,6 +17181,361 @@ mod tests {
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use serde::Serialize;
#[test]
fn decommission_set_local_usage_cache_classification_is_exact() {
for object in [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
] {
assert!(is_decommission_set_local_usage_cache(RUSTFS_META_BUCKET, object), "{object}");
assert!(!is_decommission_set_local_usage_cache("user-bucket", object), "{object}");
}
for object in [
"buckets/.usage.v2.json",
"buckets/.usage.v2.json.bkp",
"buckets/.usage-cache.bin.extra",
"buckets/.usage-cache.bin.bkp.extra",
"buckets/prefix.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp.bkp",
"buckets/photos/nested/.usage-cache.bin",
"buckets//.usage-cache.bin",
"buckets/../.usage-cache.bin",
"buckets/./.usage-cache.bin",
"buckets/.usage-cache.bin/child",
"config/.usage-cache.bin",
"buckets-other/.usage-cache.bin",
".usage-cache.bin",
] {
assert!(!is_decommission_set_local_usage_cache(RUSTFS_META_BUCKET, object), "{object}");
}
}
#[tokio::test]
#[serial_test::serial]
async fn decommission_keeps_set_local_usage_caches_out_of_target_capacity() {
use crate::object_api::PutObjReader;
use tokio::io::AsyncReadExt as _;
// Keep the scenario's large setup and migration futures off the test
// future so ordinary metadata I/O retains the default thread stack.
let (_temp_dirs, store, _other_store) =
Box::pin(crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None)).await;
let user_bucket = "decommission-usage-cache-control";
Box::pin(store.make_bucket(user_bucket, &MakeBucketOptions::default()))
.await
.expect("create the ordinary-object control bucket");
let incarnation = Box::pin(store.bucket_incarnation_id(user_bucket))
.await
.expect("control bucket incarnation");
let source_time = OffsetDateTime::now_utc();
let cache_objects = [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
];
let conflict_object = "buckets/.usage-cache.bin.conflict";
let source_read_failure_object = "buckets/.usage-cache.bin.read-error";
let source_body = b"source set cache";
let target_body = b"independent older target set cache";
for object in cache_objects.into_iter().chain([conflict_object]) {
for (pool_index, body, mod_time) in [
(0, source_body.as_slice(), source_time),
(1, target_body.as_slice(), source_time - Duration::seconds(1)),
] {
// Scanner cache persistence writes directly to its own set.
store.pools[pool_index]
.get_disks_by_key(object)
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(body.to_vec()),
&ObjectOptions {
mod_time: Some(mod_time),
..Default::default()
},
)
.await
.expect("seed distinct native set-local objects");
}
}
let controls = [
(RUSTFS_META_BUCKET, "buckets/.usage.v2.json"),
(RUSTFS_META_BUCKET, "buckets/photos/.usage-cache.bin.extra"),
(user_bucket, "ordinary-object"),
(user_bucket, "buckets/.usage-cache.bin"),
];
for (bucket, object) in controls {
store.pools[0]
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(b"ordinary object contents".to_vec()),
&ObjectOptions {
expected_bucket_incarnation_id: (bucket == user_bucket).then_some(incarnation),
..Default::default()
},
)
.await
.expect("seed a control that must migrate");
}
store.pools[0]
.put_object(
RUSTFS_META_BUCKET,
source_read_failure_object,
&mut PutObjReader::from_vec(source_body.to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed a similarly named object whose source read will fail");
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 16_384, 16_384),
DecommissionPoolCapacityInfo::for_test(1, layout, 131_072, 131_072, 0),
]],
);
Box::pin(store.save_current_pool_meta_for_decommission_start(&[0], Vec::new()))
.await
.expect("activate the decommission capacity reservation");
for object in cache_objects {
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(object),
))
.await
.expect("set-local cache must not enter cross-pool migration");
for (pool_index, expected) in [(0, source_body.as_slice()), (1, target_body.as_slice())] {
let mut reader = store.pools[pool_index]
.get_disks_by_key(object)
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("each set must retain its own cache");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read retained cache");
assert_eq!(actual, expected, "pool {pool_index}, {object}");
}
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("decommission progress");
assert_eq!((info.items_decommissioned, info.items_decommission_failed), (0, 0));
assert_eq!((info.bytes_done, info.bytes_failed), (0, 0));
let reservation = info.capacity_reservation.as_ref().expect("capacity reservation");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert_eq!(reservation.consumed_target_physical_bytes, 0);
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
}
let mut persisted = PoolMeta::default();
Box::pin(persisted.load_no_lock_from_replicas(store.pools.clone()))
.await
.expect("reload durable capacity intents after cache entries");
let reservation = persisted.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("durable reservation");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
let injected_reads = Arc::new(AtomicUsize::new(0));
let observed_reads = Arc::clone(&injected_reads);
let read_fault = DecommissionTestFaultGuard::install(Arc::new(move |stage, bucket, object, _, success| {
if stage == "object_read" && bucket == RUSTFS_META_BUCKET && object == source_read_failure_object && success {
observed_reads.fetch_add(1, Ordering::SeqCst);
return true;
}
false
}));
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: source_read_failure_object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(source_read_failure_object),
))
.await
.expect("entry must record the non-NotFound source read failure");
drop(read_fault);
assert_eq!(injected_reads.load(Ordering::SeqCst), DECOMMISSION_VERSION_COPY_ATTEMPTS);
{
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("source read failure progress");
assert_eq!((info.items_decommissioned, info.items_decommission_failed), (0, 1));
assert_eq!(info.bytes_failed, source_body.len());
assert_eq!(
info.capacity_reservation
.as_ref()
.expect("reservation")
.pending_target_physical_bytes,
0
);
}
let mut retained = store.pools[0]
.get_object_reader(
RUSTFS_META_BUCKET,
source_read_failure_object,
None,
HeaderMap::new(),
&ObjectOptions::default(),
)
.await
.expect("source read failure must retain the source");
let mut retained_body = Vec::new();
retained
.stream
.read_to_end(&mut retained_body)
.await
.expect("read retained source");
assert_eq!(retained_body, source_body);
drop(retained);
let target_err = store.pools[1]
.get_object_info(RUSTFS_META_BUCKET, source_read_failure_object, &ObjectOptions::default())
.await
.expect_err("failed source read must not create a target object");
assert!(is_err_object_not_found(&target_err), "unexpected target state: {target_err:?}");
for (bucket, object) in controls {
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: object.to_string(),
..Default::default()
},
bucket.to_string(),
store.pools[0].get_disks_by_key(object),
))
.await
.expect("ordinary and similarly named objects must migrate");
let mut reader = store.pools[1]
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("control must exist on the target");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read migrated control");
assert_eq!(actual, b"ordinary object contents", "{bucket}/{object}");
let err = store.pools[0]
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect_err("migrated control must be removed from the source");
assert!(is_err_object_not_found(&err), "{bucket}/{object}: {err:?}");
}
Box::pin(store.decommission_entry_for_test(
0,
MetaCacheEntry {
name: conflict_object.to_string(),
..Default::default()
},
RUSTFS_META_BUCKET.to_string(),
store.pools[0].get_disks_by_key(conflict_object),
))
.await
.expect("entry must record a real conditional-copy failure");
let meta = store.pool_meta.read().await;
let info = meta.pools[0].decommission.as_ref().expect("final progress");
assert_eq!(info.items_decommissioned, controls.len());
assert_eq!(
info.items_decommission_failed, 2,
"similar names must not hide read or migration failures"
);
assert_eq!(info.bytes_failed, source_body.len() * 2);
drop(meta);
for (pool_index, expected) in [(0, source_body.as_slice()), (1, target_body.as_slice())] {
let mut reader = store.pools[pool_index]
.get_object_reader(RUSTFS_META_BUCKET, conflict_object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("failed migration must preserve both objects");
let mut actual = Vec::new();
reader.stream.read_to_end(&mut actual).await.expect("read conflict object");
assert_eq!(actual, expected);
}
}
#[tokio::test]
#[serial_test::serial]
async fn decommission_final_sweep_excludes_only_set_local_usage_caches() {
use crate::object_api::PutObjReader;
let (_temp_dirs, store, _other_store) =
crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(None).await;
for object in [
"buckets/.usage-cache.bin",
"buckets/.usage-cache.bin.bkp",
"buckets/photos/.usage-cache.bin",
"buckets/photos/.usage-cache.bin.bkp",
] {
store.pools[0]
.get_disks_by_key(object)
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(b"set-local cache".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed each supported set-local cache path");
}
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 16_384, 16_384),
DecommissionPoolCapacityInfo::for_test(1, layout, 131_072, 131_072, 0),
]],
);
store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("activate the final-sweep generation");
let generation = store.active_decommission_generation(0).await.expect("active generation");
store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect("the four set-local cache forms must not block the final sweep");
for object in [
"buckets/.usage-cache.bin.extra",
"buckets/photos/.usage-cache.bin.bkp.extra",
"buckets/.usage.v2.json",
] {
let source_set = store.pools[0].get_disks_by_key(object);
source_set
.put_object(
RUSTFS_META_BUCKET,
object,
&mut PutObjReader::from_vec(b"unmigrated ordinary metadata".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("seed ordinary metadata that must prevent completion");
let err = store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect_err("a remaining similar name or global usage snapshot must block completion");
assert!(err.to_string().contains("after decommissioning"), "unexpected final-sweep error: {err:?}");
assert!(err.to_string().contains(object), "the final sweep must identify {object}: {err:?}");
source_set
.delete_object(RUSTFS_META_BUCKET, object, ObjectOptions::default())
.await
.expect("remove only the ordinary-metadata control before the next sweep");
}
store
.check_after_decommission(0, &CancellationToken::new(), generation)
.await
.expect("only the four set-local caches remain after removing the controls");
}
#[test]
fn pool_activation_fleet_proof_error_classifier_matches_only_retryable_proof_failures() {
assert!(is_pool_activation_fleet_proof_error(&Error::other(POOL_ACTIVATION_FLEET_PROOF_REQUIRED)));
@@ -19865,6 +20247,55 @@ mod tests {
assert!(!is_decommission_copy_cleanup_safe_error(&wrap(Error::SlowDown)));
}
#[test]
fn decommission_target_gate_retry_recognizes_multipart_part_errors() {
let wrap = |inner: Error| {
data_movement::data_movement_part_stage_error_for_test(
"decommission_object",
"put_object_part",
"bucket-a",
"object-a",
1,
inner,
)
};
let gate_busy_message =
format!("{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_PREFIX}7{DECOMMISSION_CAPACITY_TARGET_GATE_BUSY_SUFFIX}");
let wrapped = wrap(decommission_capacity_blocked_error(&gate_busy_message));
assert!(is_decommission_capacity_target_gate_busy(&wrapped));
assert_eq!(decommission_capacity_target_gate_busy_index(&wrapped), Some(7));
assert_eq!(
wrapped.to_string(),
format!(
"Io error: decommission_object: put_object_part failed for bucket-a/object-a part 1: {}",
decommission_capacity_blocked_error(&gate_busy_message)
)
);
for unrelated in [
Error::SlowDown,
Error::DiskFull,
decommission_capacity_blocked_error("target capacity is exhausted"),
Error::other(gate_busy_message),
] {
let wrapped = wrap(unrelated);
assert!(!is_decommission_capacity_target_gate_busy(&wrapped));
assert_eq!(decommission_capacity_target_gate_busy_index(&wrapped), None);
}
for missing_target in [
Error::FileNotFound,
Error::ObjectNotFound("bucket-a".to_string(), "object-a".to_string()),
Error::VersionNotFound("bucket-a".to_string(), "object-a".to_string(), "version-a".to_string()),
] {
assert!(is_decommission_copy_cleanup_safe_error(&missing_target));
assert!(
!is_decommission_copy_cleanup_safe_error(&wrap(missing_target)),
"a missing target part must never authorize source cleanup"
);
}
assert!(is_decommission_target_capacity_error(&wrap(Error::DiskFull)));
}
#[test]
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
@@ -22094,7 +22525,7 @@ mod pools_tests {
.resources
.lock()
.expect("activation lock recorder should not be poisoned"),
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()]
);
let mut second_acquire = Box::pin(acquire_pool_rebalance_activation_locks(second.clone(), None));
@@ -22110,10 +22541,50 @@ mod pools_tests {
.resources
.lock()
.expect("activation lock recorder should not be poisoned"),
vec![POOL_META_NAME.to_string(), REBAL_META_NAME.to_string()]
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()]
);
}
#[tokio::test]
async fn test_activation_cancellation_releases_rebalance_fence_while_pool_fence_is_contended() {
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
let pool = Arc::new(ActivationLockRecorder {
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
owner: "activation-cancellation",
resources: StdMutex::new(Vec::new()),
});
let pool_lock = pool
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("pool lock should be created");
let pool_reader = pool_lock
.get_read_lock(std::time::Duration::from_secs(5))
.await
.expect("ordinary mutation should hold the pool read fence");
pool.resources.lock().expect("recorder should not be poisoned").clear();
let mut activation = Box::pin(acquire_pool_rebalance_activation_locks(Arc::clone(&pool), None));
assert!(matches!(futures::poll!(&mut activation), Poll::Pending));
assert_eq!(
*pool.resources.lock().expect("recorder should not be poisoned"),
vec![REBAL_META_NAME.to_string(), POOL_META_NAME.to_string()],
"activation must hold the run fence before waiting for the pool fence",
);
drop(activation);
let rebalance_lock = pool
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, REBAL_META_NAME)
.await
.expect("run lock should be created");
let run_writer = rebalance_lock
.get_write_lock(std::time::Duration::from_secs(5))
.await
.expect("cancelling activation must release its already-acquired run fence");
assert!(
!pool_reader.is_released(),
"cancelling activation must not release another caller's pool fence"
);
assert!(!run_writer.is_lock_lost());
}
#[test]
fn decommission_receipt_run_token_changes_with_persisted_start_time() {
let first = OffsetDateTime::from_unix_timestamp(1_000).expect("first run timestamp should be valid");
+29 -4
View File
@@ -1521,9 +1521,27 @@ fn data_movement_part_stage_error(
bucket: &str,
object: &str,
part_number: usize,
err: impl std::fmt::Display,
err: Error,
) -> Error {
Error::other(format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}"))
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}");
if matches!(&err, Error::DecommissionCapacityBlocked { .. }) {
return data_movement_context_error(rendered, err);
}
// A missing target part is not evidence that the source can be deleted.
// Keep other part errors opaque to the source-cleanup classifiers.
Error::other(rendered)
}
#[cfg(test)]
pub(crate) fn data_movement_part_stage_error_for_test(
op_label: &str,
stage: &str,
bucket: &str,
object: &str,
part_number: usize,
err: Error,
) -> Error {
data_movement_part_stage_error(op_label, stage, bucket, object, part_number, err)
}
fn is_data_movement_part_read_error(err: &Error) -> bool {
@@ -2428,8 +2446,15 @@ mod tests {
let err =
data_movement_part_stage_error("rebalance_object", "put_object_part", "bucket-a", "object-a", 7, Error::SlowDown);
let message = err.to_string();
assert!(message.contains("rebalance_object: put_object_part failed for bucket-a/object-a part 7"));
assert!(message.contains(Error::SlowDown.to_string().as_str()));
assert_eq!(
message,
Error::other(format!(
"rebalance_object: put_object_part failed for bucket-a/object-a part 7: {}",
Error::SlowDown
))
.to_string()
);
assert!(data_movement_stage_source(&err).is_none());
}
#[test]
+2
View File
@@ -3397,6 +3397,7 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
}];
partial.buckets_usage.insert(
"bucket".to_string(),
@@ -3469,6 +3470,7 @@ mod tests {
scan_plan_digest: Some([1; 32]),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
}],
..Default::default()
};
+106
View File
@@ -22543,6 +22543,112 @@ mod test {
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_mrf_manifest_storage_full_keeps_recovery_anchors() {
use tempfile::tempdir;
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
let dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let previous_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
let successor_manifest = Bytes::from_static(b"mrf-committed-manifest-v2");
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
assert_eq!(
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(previous_manifest.clone()),)
.await
.expect("previous MRF manifest should commit"),
ConditionalFileUpdate::Updated
);
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
.await
.expect("legacy MRF journal should be retained");
let manifest_path = disk
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.expect("MRF manifest path should resolve");
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
let err = disk
.compare_and_update_file(
RUSTFS_META_BUCKET,
MRF_COMMIT_MANIFEST_SLOT_0,
Some(previous_manifest.clone()),
Some(successor_manifest),
)
.await
.expect_err("storage-full fsync failure must fail the MRF manifest successor commit");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.await
.expect("previous committed MRF manifest should remain readable after storage-full rollback"),
previous_manifest
);
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await
.expect("legacy MRF journal should remain readable after storage-full manifest publication failure"),
legacy_journal
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_mrf_manifest_storage_full_delete_keeps_recovery_anchors() {
use tempfile::tempdir;
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
let dir = tempdir().expect("temp dir should be created");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let committed_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
assert_eq!(
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(committed_manifest.clone()),)
.await
.expect("committed MRF manifest should publish"),
ConditionalFileUpdate::Updated
);
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
.await
.expect("legacy MRF journal should be retained");
let manifest_path = disk
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.expect("MRF manifest path should resolve");
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
let err = disk
.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, Some(committed_manifest.clone()), None)
.await
.expect_err("storage-full fsync failure must fail the MRF manifest cleanup delete");
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
.await
.expect("committed MRF manifest should be restored after failed cleanup delete"),
committed_manifest
);
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
.await
.expect("legacy MRF journal should remain readable after failed cleanup delete"),
legacy_journal
);
}
#[cfg(unix)]
#[tokio::test]
async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() {
+2 -16
View File
@@ -570,25 +570,11 @@ pub(crate) async fn initialize_local_disk_maps(
Ok(())
}
#[cfg(test)]
tokio::task_local! {
pub(crate) static TEST_TIER_CONFIG_MGR: Arc<tokio::sync::RwLock<TierConfigMgr>>;
}
pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
#[cfg(not(test))]
let handle = get_global_tier_config_mgr();
#[cfg(test)]
let handle = TEST_TIER_CONFIG_MGR
.try_with(Arc::clone)
.unwrap_or_else(|_| get_global_tier_config_mgr());
let initial_reload = TierConfigMgr::reload_handle(&handle, store.clone()).await;
if initial_reload.is_err() {
// Keep local recovery active when the initial snapshot cannot be loaded.
TierConfigMgr::request_committed_mutation_refresh(&handle).await;
}
TierConfigMgr::reload_handle(&handle, store.clone()).await?;
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store));
initial_reload.map_err(Error::from)
Ok(())
}
#[cfg(test)]
@@ -572,7 +572,7 @@ impl ECStore {
where
S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
{
// Lock order: pool_meta_save_gate -> pool.bin -> rebalance.bin.
// Lock order: pool_meta_save_gate -> rebalance.bin -> pool.bin.
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
pool_meta_guard.ensure_write_safe("rebalance worker activation")?;
// Classify the durable rebalance record while holding both namespace
@@ -50,6 +50,11 @@ fn ensure_rebalance_entry_active(cancel: &CancellationToken) -> Result<()> {
Ok(())
}
#[cfg(test)]
tokio::task_local! {
static REBALANCE_ENTRY_RUN_FENCE_BARRIER: (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>);
}
#[derive(Debug)]
struct RebalanceEntryTarget {
bucket: String,
@@ -256,9 +261,15 @@ impl ECStore {
.sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time)));
// Entry lock order is bucket incarnation -> activation_gate -> rebalance.bin -> movement gate.
// Target capacity admission can then acquire pool.bin under the run fence.
// Stop waits for in-flight entries through cleanup, but not for entries admitted later.
ensure_rebalance_entry_active(&cancel)?;
let run_guard = self.rebalance_run_guard(rebalance_id.as_ref(), "rebalance entry").await?;
#[cfg(test)]
if let Ok((arrived, release)) = REBALANCE_ENTRY_RUN_FENCE_BARRIER.try_with(Clone::clone) {
arrived.notify_one();
release.notified().await;
}
let lock_lost_signal = run_guard.lock_lost_signal();
#[cfg(test)]
let _run_signal_test_fence = lock_lost_signal
@@ -1237,6 +1248,130 @@ mod tests {
assert_eq!(pool_stats.cleanup_warnings.count, 1, "deferred cleanup must not add a permanent warning");
}
#[tokio::test]
#[serial_test::serial]
async fn real_rebalance_entry_progresses_while_peer_activation_waits_for_run_fence() {
const REBALANCE_ID: &str = "rebalance-peer-activation-lock-order";
let (_temp_dirs, store, peer) = crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(Some(
active_rebalance_meta(REBALANCE_ID),
))
.await;
assert!(!Arc::ptr_eq(&store.ctx, &peer.ctx), "node-local movement gates must be independent");
{
let mut meta = peer.rebalance_meta.write().await;
let meta = meta.as_mut().expect("peer should know the durable run");
meta.activation_gate = Arc::default();
meta.cancel = None;
}
let bucket = crate::disk::RUSTFS_META_BUCKET;
let object = "rebalance-peer-activation-object";
let version_id = uuid::Uuid::new_v4();
let payload = b"entry must drain before peer activation takes the pool fence".repeat(1024);
let source_set = store.pools[0].get_disks_by_key(object);
let target_set = store.pools[1].get_disks_by_key(object);
let opts = ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
};
let mut writer = PutObjReader::from_vec(payload.clone());
let source_before = source_set
.put_object(bucket, object, &mut writer, &opts)
.await
.expect("source version should be written");
let entry = metacache_entry_from_source(&source_set, bucket, object).await;
let arrived = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
// JoinSet aborts both scoped tasks if an assertion or timeout fails.
let mut tasks = tokio::task::JoinSet::new();
let entry_store = Arc::clone(&store);
tasks.spawn(
REBALANCE_ENTRY_RUN_FENCE_BARRIER.scope((Arc::clone(&arrived), Arc::clone(&release)), async move {
entry_store
.rebalance_entry(
RebalanceEntryTarget {
bucket: bucket.to_string(),
pool_index: 0,
},
entry,
source_set,
Arc::new(RebalanceBucketConfigs::default()),
Arc::from(REBALANCE_ID),
CancellationToken::new(),
)
.await
}),
);
tokio::time::timeout(StdDuration::from_secs(30), arrived.notified())
.await
.expect("real entry must acquire its persisted run read fence");
let attempted = Arc::new(tokio::sync::Notify::new());
let peer_pool = Arc::clone(&peer.pools[0]);
let (activation_done, activation_result) = tokio::sync::oneshot::channel();
tasks.spawn(
crate::core::pools::REBALANCE_ACTIVATION_LOCK_ATTEMPT.scope(Arc::clone(&attempted), async move {
let result = peer.fence_rebalance_worker_activation(peer_pool, REBALANCE_ID).await;
let result = result.map(|fence| match fence {
super::super::control::RebalanceWorkerActivationFence::Ready(fence) => {
fence.ensure_held().expect("peer activation must retain both fences");
}
super::super::control::RebalanceWorkerActivationFence::NotStartedTerminal => {
panic!("the paused entry's run must still require activation");
}
});
activation_done.send(result).expect("activation receiver should remain alive");
Ok(RebalanceEntryOutcome::Completed)
}),
);
tokio::time::timeout(StdDuration::from_secs(30), attempted.notified())
.await
.expect("peer activation must attempt the persisted rebalance write fence");
release.notify_one();
tokio::time::timeout(StdDuration::from_secs(30), async {
while let Some(result) = tasks.join_next().await {
assert!(matches!(
result
.expect("scoped task must not panic")
.expect("entry must not fail or defer"),
RebalanceEntryOutcome::Completed
));
}
})
.await
.expect("entry and peer activation must both make progress");
activation_result
.await
.expect("peer activation result should be sent")
.expect("peer activation must not time out behind the entry it blocks");
let mut reader = target_set
.get_object_reader(bucket, object, None, HeaderMap::new(), &opts)
.await
.expect("the exact target version must be readable");
let mut actual = Vec::new();
reader
.stream
.read_to_end(&mut actual)
.await
.expect("target body should drain completely");
assert_eq!(actual, payload);
assert_eq!(reader.object_info.version_id, source_before.version_id);
assert_eq!(reader.object_info.etag, source_before.etag);
assert_eq!(reader.object_info.mod_time, source_before.mod_time);
let source_error = store.pools[0]
.get_object_info(bucket, object, &opts)
.await
.expect_err("completed entry must clean up the source version");
assert!(crate::error::is_err_object_not_found(&source_error) || crate::error::is_err_version_not_found(&source_error));
let meta = store.rebalance_meta.read().await;
let stats = &meta.as_ref().expect("local run must remain installed").pool_stats[0];
assert_eq!(stats.num_objects, 1);
assert_eq!(stats.num_versions, 1);
assert_eq!(stats.cleanup_warnings.count, 0);
}
#[tokio::test]
#[serial_test::serial]
async fn real_rebalance_run_fence_loss_before_target_commit_preserves_target_and_source() {
@@ -1907,6 +1907,124 @@ fn test_is_transient_rebalance_error_accepts_wrapped_disk_timeout() {
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other(DiskError::Timeout))));
}
#[test]
fn test_rebalance_stage_wrapped_transient_errors_remain_retryable() {
let cases = [
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
Error::Lock(rustfs_lock::LockError::network(
"peer unavailable",
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
)),
Error::SlowDown,
Error::ErasureReadQuorum,
Error::ErasureWriteQuorum,
Error::Io(std::io::Error::other(DiskError::Timeout)),
Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut)),
];
for mut error in cases {
for depth in 0..=3 {
assert!(is_transient_rebalance_error(&error), "transient source lost at depth {depth}: {error:?}");
assert!(
should_defer_rebalance_entry_failure(&error),
"exhausted transient entries must be deferred"
);
assert!(should_retry_rebalance_listing(&error, 0, 3));
assert!(
!should_retry_rebalance_listing(&error, 2, 3),
"wrapping must not bypass the attempt limit"
);
error = data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"baseline/00042.bin",
error,
);
}
}
}
#[test]
fn test_rebalance_stage_wrapped_terminal_errors_remain_terminal() {
let cases = [
Error::FileAccessDenied,
Error::FileCorrupt,
Error::OperationCanceled,
Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string()),
Error::Lock(rustfs_lock::LockError::already_locked("bucket/object", "owner")),
Error::other("permission denied"),
];
for mut error in cases {
for depth in 0..=3 {
assert!(
!is_transient_rebalance_error(&error),
"terminal source must survive depth {depth}: {error:?}"
);
assert!(!should_defer_rebalance_entry_failure(&error));
// Object names are untrusted context, not evidence of a transient failure.
error = data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"remote lock rpc timed out",
error,
);
}
}
}
#[tokio::test]
async fn test_rebalance_stage_wrapped_lock_timeout_retries_real_migration_loop() {
for succeeds_on_retry in [true, false] {
let backend = MigrationBackendSpy::new(None, None);
let attempts = AtomicUsize::new(0);
let waits = AtomicUsize::new(0);
let mut transfer = |_, _, _| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
if succeeds_on_retry && attempt > 0 {
return Ok(());
}
Err(data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"baseline/00042.bin",
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
))
}
};
let version = version_normal();
let result = migrate_entry_version_with_retry_wait(
&backend,
"bucket".to_string(),
0,
&version,
None,
3,
false,
&mut transfer,
|_: String, _: String, _: ObjectOptions| async { Ok::<_, Error>(ObjectInfo::default()) },
|_| {
waits.fetch_add(1, Ordering::SeqCst);
std::future::ready(())
},
)
.await;
assert_eq!(result.moved, succeeds_on_retry);
assert_eq!(result.failed, !succeeds_on_retry);
assert_eq!(attempts.load(Ordering::SeqCst), if succeeds_on_retry { 2 } else { 3 });
assert_eq!(backend.get_calls(), attempts.load(Ordering::SeqCst));
assert_eq!(waits.load(Ordering::SeqCst), attempts.load(Ordering::SeqCst) - 1);
if !succeeds_on_retry {
assert_eq!(result.stage, Some("write_target"));
assert!(should_defer_rebalance_entry_failure(
result.error.as_ref().expect("exhaustion must retain its source error")
));
}
}
}
#[test]
fn test_is_transient_rebalance_error_accepts_io_timeout_message() {
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other("timeout"))));
@@ -244,6 +244,7 @@ pub(super) fn resolve_rebalance_bucket_result(
}
pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
let err = rebalance_error_source(err);
match err {
Error::SlowDown
| Error::ErasureReadQuorum
@@ -256,6 +257,15 @@ pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
}
}
fn rebalance_error_source(mut err: &Error) -> &Error {
// Stage context contains object names, so classify the preserved source,
// not timeout-like text supplied by an object name. Iterate nested stages.
while let Some(source) = crate::data_movement::data_movement_stage_source(err) {
err = source;
}
err
}
fn is_rebalance_transient_lock_error(err: &rustfs_lock::LockError) -> bool {
match err {
rustfs_lock::LockError::Timeout { .. } | rustfs_lock::LockError::Network { .. } => true,
@@ -309,6 +319,7 @@ pub(super) fn rebalance_listing_retry_delay(attempt: usize) -> Duration {
}
fn is_rebalance_lock_or_rpc_timeout(err: &Error) -> bool {
let err = rebalance_error_source(err);
match err {
Error::Lock(rustfs_lock::LockError::Timeout { .. }) | Error::Lock(rustfs_lock::LockError::Network { .. }) => true,
Error::Io(io_err) => is_rebalance_lock_or_rpc_timeout_message(&io_err.to_string()),
@@ -585,3 +596,48 @@ impl SetDisks {
Ok(())
}
}
#[cfg(test)]
mod error_source_tests {
use super::*;
#[test]
fn stage_wrapped_errors_select_the_source_backoff_policy() {
let cases = [
(
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
true,
),
(
Error::Lock(rustfs_lock::LockError::network(
"peer unavailable",
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
)),
true,
),
(Error::other("remote lock rpc timed out"), true),
(Error::SlowDown, false),
(Error::Io(std::io::Error::other(DiskError::Timeout)), false),
(Error::FileAccessDenied, false),
];
for (mut error, lock_backoff) in cases {
for depth in 0..=3 {
assert_eq!(
is_rebalance_lock_or_rpc_timeout(&error),
lock_backoff,
"wrong backoff at depth {depth}: {error:?}"
);
if !lock_backoff {
assert_eq!(rebalance_migration_retry_delay(1, &error), REBALANCE_MIGRATION_RETRY_BASE_DELAY * 2);
}
error = crate::data_movement::data_movement_stage_error_for_test(
"rebalance_object",
"put_object",
"bucket",
"remote lock rpc timed out",
error,
);
}
}
}
}
+322 -6
View File
@@ -9209,6 +9209,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let dest_obj = transaction.remote_object.clone();
let mut transition_meta = (*oi.user_defined).clone();
rustfs_utils::http::remove_str(&mut transition_meta, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
// The tier holds opaque stored bytes. Its metadata must not be treated
// as a second object header set: forwarding SSE intent or wrapped DEKs
// would request a second encryption pass and disclose local envelope
// material to the remote provider.
transition_meta.retain(|key, _| !rustfs_utils::http::is_replication_stripped_encryption_key(key));
transition_meta.insert("name".to_string(), object.to_string());
rustfs_utils::http::metadata_compat::insert_str(
&mut transition_meta,
@@ -9765,13 +9770,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
part_opts.part_number = Some(part_info.number);
#[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::InvalidPartSize)?;
if part_info.actual_size <= 0 {
return Err(Error::other(format!("invalid multipart restore part size {}", part_info.actual_size)));
if part_info.size == 0 {
return Err(Error::other(format!("invalid multipart restore stored part size {}", part_info.size)));
}
let stored_part_size = i64::try_from(part_info.size).map_err(|_| {
Error::other(format!("multipart restore stored part size exceeds i64: {}", part_info.size))
})?;
#[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::RangeOverflow)?;
let part_end = part_offset
.checked_add(part_info.actual_size - 1)
.checked_add(stored_part_size - 1)
.ok_or_else(|| Error::other("multipart restore part range overflow".to_string()))?;
let rs = Some(HTTPRangeSpec {
is_suffix_length: false,
@@ -9799,13 +9807,19 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
#[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::HashReader)?;
let hash_reader =
HashReader::from_stream(reader, part_info.actual_size, part_info.actual_size, None, None, false)?;
HashReader::from_stream(reader, stored_part_size, part_info.actual_size, None, None, false)?;
let mut p_reader = PutObjReader::new(hash_reader);
#[cfg(test)]
fail_restore_multipart_at(RestoreMultipartFailurePoint::PutPart)?;
// `ropts` carries the object's ETag so the single-part copy-back
// keeps it (the writer only ever sees stored bytes). A part write
// must not inherit that object-level value, or every restored part
// would be recorded under the same ETag; each part keeps its own.
let mut part_write_opts = ropts.clone();
part_write_opts.preserve_etag = Some(part_info.etag.clone()).filter(|etag| !etag.is_empty());
let p_info = self_
.clone()
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &ropts)
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &part_write_opts)
.await?;
#[cfg(test)]
let p_info = if restore_multipart_failure_is(RestoreMultipartFailurePoint::SizeMismatch) {
@@ -9815,7 +9829,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
} else {
p_info
};
if p_info.size as i64 != part_info.actual_size {
if p_info.size as i64 != stored_part_size {
return Err(Error::other(ObjectApiError::InvalidObjectState(GenericError {
bucket: bucket.to_string(),
object: object.to_string(),
@@ -9847,6 +9861,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
user_defined: restore_commit_metadata,
no_lock: false,
decommission_capacity_admission: opts.decommission_capacity_admission.clone(),
// The composite ETag would otherwise be recomputed from the
// parts as they were written back, which for an encrypted or
// compressed object digests stored bytes rather than the
// object's public ETag.
preserve_etag: oi.etag.clone(),
..Default::default()
};
self_
@@ -13488,6 +13507,303 @@ mod transition_commit_failure_tests {
);
}
/// backlog#2368 B5: the tier stores opaque bytes, so the archive request
/// must not carry the object's encryption headers. Forwarding them made
/// every S3 target reject an SSE-C archive outright, asked the target to
/// encrypt an SSE-KMS object a second time under a key id it does not own,
/// and handed the wrapped DEK to a third-party provider.
#[tokio::test]
#[serial_test::serial]
async fn transition_does_not_forward_encryption_metadata_to_the_tier() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "transition-encryption-metadata-bucket";
let object = "object.bin";
for disk in &disk_stores {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
let encryption_metadata = [
("x-amz-server-side-encryption", "aws:kms"),
("x-amz-server-side-encryption-aws-kms-key-id", "arn:aws:kms:us-east-1:123:key/abc"),
("x-amz-server-side-encryption-customer-algorithm", "AES256"),
(rustfs_utils::http::INTERNAL_ENCRYPTION_KEY_HEADER, "d3JhcHBlZC1kZWs="),
(rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER, "AAAAAAAAAAAAAAAA"),
(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER, "AES256"),
];
let mut user_defined: HashMap<String, String> = encryption_metadata
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect();
user_defined.insert("x-amz-meta-owner".to_string(), "finance".to_string());
let mut reader = PutObjReader::from_vec(b"stored bytes the tier keeps opaque ".repeat(64));
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
user_defined,
..Default::default()
},
)
.await
.expect("the encrypted source object should be written");
let original = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the source object should be readable");
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
set_disks
.transition_object(
bucket,
object,
&ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().unwrap_or_default(),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("the encrypted object should transition");
let transitioned = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the transitioned object should be readable");
let remote_metadata = backend
.metadata(&transitioned.transitioned_object.name)
.await
.expect("the tier must have received the object");
for (key, _) in encryption_metadata {
assert!(
!remote_metadata.keys().any(|stored| stored.eq_ignore_ascii_case(key)),
"transition must not forward {key} to the tier: {remote_metadata:?}"
);
}
assert!(
remote_metadata
.iter()
.any(|(key, value)| key.eq_ignore_ascii_case("x-amz-meta-owner") && value == "finance"),
"ordinary user metadata must still travel to the tier: {remote_metadata:?}"
);
// Read-through and restore both resolve encryption locally, so the
// stripped keys must survive untouched in the local metadata.
for (key, value) in encryption_metadata {
assert_eq!(
transitioned.user_defined.get(key).map(String::as_str),
Some(value),
"the local copy must keep {key}"
);
}
}
/// Deterministic bytes that do not repeat with a short period, so a slice
/// taken at the wrong offset cannot coincidentally compare equal.
fn stored_representation_bytes(seed: u32, len: usize) -> Vec<u8> {
(0..len)
.map(|index| {
let mixed = (index as u32).wrapping_add(seed).wrapping_mul(2_654_435_761);
(mixed >> 13) as u8
})
.collect()
}
/// Compares two stored representations without dumping megabytes of bytes
/// into the failure output.
fn assert_stored_representation_eq(actual: &[u8], expected: &[u8], what: &str) {
assert_eq!(actual.len(), expected.len(), "{what}: stored length differs");
if let Some(offset) = actual.iter().zip(expected).position(|(left, right)| left != right) {
panic!(
"{what}: stored bytes differ at offset {offset} (found {:#04x}, expected {:#04x})",
actual[offset], expected[offset]
);
}
}
async fn read_stored_representation(set_disks: &Arc<SetDisks>, bucket: &str, object: &str) -> Vec<u8> {
let mut reader = set_disks
.get_object_reader(
bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
raw_data_movement_read: true,
..Default::default()
},
)
.await
.expect("stored-representation reader should open");
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("stored body should drain");
body
}
/// backlog#2368 B3: the multipart restore loop addresses the tier in STORED
/// coordinates. Accumulating each part's PLAINTEXT length instead handed
/// every part a misaligned slice of the remote object whose length still
/// satisfied the range, the `HashReader` and the completion size check, so
/// the copy-back reported success while silently replacing the bytes.
///
/// The fixture reproduces the encrypted geometry — a stored form LONGER
/// than the plaintext it encodes — because that is what keeps a
/// plaintext-coordinate range inside the tier object and makes the
/// corruption silent rather than a short read.
#[tokio::test]
#[serial_test::serial]
async fn multipart_restore_copies_the_stored_representation_back_verbatim() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "restore-multipart-stored-coordinates-bucket";
let object = "object.bin";
for disk in &disk_stores {
disk.make_volume(bucket).await.expect("bucket volume should be created");
}
// The minimum-part-size gate reads the PLAINTEXT length, so part one
// clears 5 MiB there while its stored form carries encoding overhead.
let part_shapes = [(6 * 1024 * 1024_usize, 9_216_usize), (256 * 1024_usize, 512_usize)];
let mut user_defined = HashMap::new();
user_defined.insert(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "AES256".to_string());
user_defined.insert(
rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER.to_string(),
"AAAAAAAAAAAAAAAA".to_string(),
);
let upload = set_disks
.new_multipart_upload(
bucket,
object,
&ObjectOptions {
user_defined: user_defined.clone(),
..Default::default()
},
)
.await
.expect("multipart upload should be created");
let mut uploaded_parts = Vec::new();
let mut expected_stored = Vec::new();
for (index, (plaintext_len, overhead)) in part_shapes.iter().enumerate() {
let stored = stored_representation_bytes(index as u32 * 7 + 1, plaintext_len + overhead);
expected_stored.extend_from_slice(&stored);
let stored_len = stored.len() as i64;
let hash_reader =
HashReader::from_stream(std::io::Cursor::new(stored), stored_len, *plaintext_len as i64, None, None, false)
.expect("hash reader over the stored representation");
let mut reader = PutObjReader::new(hash_reader);
let info = set_disks
.put_object_part(bucket, object, &upload.upload_id, index + 1, &mut reader, &ObjectOptions::default())
.await
.expect("stored part should be staged");
assert_eq!(info.size as i64, stored_len, "a part is stored in its encoded length");
uploaded_parts.push(CompletePart {
part_num: info.part_num,
etag: info.etag,
..Default::default()
});
}
let original = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload.upload_id, uploaded_parts, &ObjectOptions::default())
.await
.expect("source multipart upload should complete");
let original_parts: Vec<(usize, usize, i64, String)> = original
.parts
.iter()
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
.collect();
for (_, size, actual_size, _) in &original_parts {
assert!(
*size as i64 > *actual_size,
"the fixture must keep the two coordinate systems apart: stored {size} vs plaintext {actual_size}"
);
}
let stored_before = read_stored_representation(&set_disks, bucket, object).await;
assert_stored_representation_eq(&stored_before, &expected_stored, "the fixture must store its encoded bytes verbatim");
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
set_disks
.transition_object(
bucket,
object,
&ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().unwrap_or_default(),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
},
)
.await
.expect("multipart source should transition before restore");
let operation_id = Uuid::new_v4();
set_disks
.put_object_metadata(
bucket,
object,
&ObjectOptions {
eval_metadata: Some(restore_metadata(operation_id, true)),
..Default::default()
},
)
.await
.expect("the restore generation should be installed");
let mut restore_opts = ObjectOptions::default();
restore_opts.transition.restore_request.days = Some(1);
restore_opts.user_defined = restore_operation_id_metadata(operation_id);
set_disks
.clone()
.restore_transitioned_object(bucket, object, &restore_opts)
.await
.expect("multipart restore should complete");
let stored_after = read_stored_representation(&set_disks, bucket, object).await;
assert_stored_representation_eq(
&stored_after,
&expected_stored,
"a multipart restore must copy the stored representation back verbatim",
);
let restored = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("the restored object should be readable");
let restored_parts: Vec<(usize, usize, i64, String)> = restored
.parts
.iter()
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
.collect();
assert_eq!(
restored_parts, original_parts,
"restore must rebuild the same part layout, sizes and part ETags"
);
assert_eq!(restored.size, original.size, "restore must keep the stored object size");
// backlog#2369 P7.1: the copy-back digests stored bytes, so the object's
// public ETag has to be carried over rather than recomputed.
assert_eq!(restored.etag, original.etag, "restore must preserve the object ETag");
}
#[tokio::test]
#[serial_test::serial]
async fn restore_failure_after_snapshot_cleans_exact_generation_and_returns_primary_error() {
-317
View File
@@ -18,7 +18,6 @@ use crate::services::rebalance::{REBAL_META_NAME, RebalStatus};
use crate::set_disk::get_lock_acquire_timeout;
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use rustfs_heal_contracts::heal_channel::DriveState;
use rustfs_lock::NamespaceLockGuard;
use std::collections::BTreeSet;
use tracing::trace;
@@ -379,79 +378,6 @@ impl ECStore {
Ok(result)
}
/// Heal every pool metadata owner in the selected scope without allowing
/// one healthy pool to hide another pool's failed repair.
pub async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
let scopes = self.heal_erasure_set_scopes(opts).await?;
let mut results = Vec::new();
for (pool_index, set_index) in scopes {
if !self.replacement_pool_metadata_applies(pool_index, set_index)? {
continue;
}
let set = &self.pools[pool_index].disk_set[set_index];
let targets = set.set_endpoints.iter().map(ToString::to_string).collect::<Vec<_>>();
if targets.is_empty()
|| targets.len() != set.set_drive_count
|| targets.iter().collect::<BTreeSet<_>>().len() != targets.len()
{
return Err(Error::SlowDown);
}
// Administrative remove/no-lock options apply to user objects,
// never to the cluster's authoritative metadata transaction.
let metadata_opts = HealOpts {
dry_run: opts.dry_run,
recreate: opts.recreate,
scan_mode: opts.scan_mode,
pool: Some(pool_index),
set: Some(set_index),
..Default::default()
};
let (result, error) = self
.handle_heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, "", &metadata_opts)
.await?;
if let Some(error) = error {
return Err(error);
}
if !opts.dry_run {
let ok_state = DriveState::Ok.to_string();
let complete = result.after.drives.len() == targets.len()
&& targets.iter().all(|target| {
let mut outcomes = result.after.drives.iter().filter(|drive| drive.endpoint == *target);
outcomes.next().is_some_and(|drive| drive.state == ok_state) && outcomes.next().is_none()
});
if !complete
|| !set
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, "", &targets)
.await?
{
return Err(Error::SlowDown);
}
}
results.push(result);
}
Ok(results)
}
/// Whether this replacement set owns the pool's metadata replica.
///
/// Pool metadata follows normal object placement within each pool. A valid
/// non-owner set has no replica to repair; missing metadata on the owner
/// set still requires healing and target-specific readback.
pub fn replacement_pool_metadata_applies(&self, pool_index: usize, set_index: usize) -> Result<bool> {
let pool = self
.pools
.get(pool_index)
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
let selected = pool.get_disks_for_heal_object(
POOL_META_NAME,
&HealOpts {
set: Some(set_index),
..Default::default()
},
)?;
Ok(Arc::ptr_eq(&selected, &pool.get_disks_by_key(POOL_META_NAME)))
}
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
pub async fn replacement_targets_have_version(
&self,
@@ -903,249 +829,6 @@ mod tests {
}
}
#[tokio::test]
async fn replacement_pool_metadata_applies_to_the_written_replica_in_each_pool() {
let mut store = minimal_heal_store().await;
for pool_index in 0..store.pools.len() {
assert!(
store
.replacement_pool_metadata_applies(pool_index, 0)
.expect("a valid single-set pool should have a metadata owner")
);
}
store.ctx = Arc::new(InstanceContext::new());
for algorithm in [
crate::disk::format::DistributionAlgoVersion::V1,
crate::disk::format::DistributionAlgoVersion::V2,
crate::disk::format::DistributionAlgoVersion::V3,
] {
let mut temp_dirs = Vec::new();
for pool_index in 0..store.pools.len() {
let (dirs, mut pool) =
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
temp_dirs.extend(dirs);
Arc::get_mut(&mut pool)
.expect("fixture pool should have one owner")
.distribution_algo = algorithm.clone();
store.pools[pool_index] = pool;
}
for (pool_index, pool) in store.pools.iter().enumerate() {
let mut required_sets = 0;
for set_index in 0..pool.disk_set.len() {
required_sets += usize::from(
store
.replacement_pool_metadata_applies(pool_index, set_index)
.expect("valid replacement topology should be classified before metadata exists"),
);
}
assert_eq!(required_sets, 1, "missing metadata cannot exempt the owner set");
save_config(pool.clone(), POOL_META_NAME, b"pool metadata placement".to_vec())
.await
.expect("normal config writes should persist one metadata replica per pool");
for (set_index, set) in pool.disk_set.iter().enumerate() {
let applies = store
.replacement_pool_metadata_applies(pool_index, set_index)
.expect("valid replacement topology should be classified");
let disks = set.disks.read().await.clone();
for disk in disks.iter().flatten() {
let replica = disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await;
if applies {
replica.expect("the metadata owner must match actual persisted shards");
} else {
assert!(
matches!(replica, Err(crate::disk::error::DiskError::FileNotFound)),
"non-owner sets must have no persisted metadata shard; observed error: {:?}",
replica.as_ref().err()
);
}
}
}
assert!(
store
.replacement_pool_metadata_applies(pool_index, pool.disk_set.len())
.is_err()
);
}
}
assert!(store.replacement_pool_metadata_applies(store.pools.len(), 0).is_err());
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_repairs_each_owner_and_preserves_dry_run() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
let first_missing = remove_pool_meta_shard(&store, 0).await;
let second_missing = remove_pool_meta_shard(&store, 1).await;
let destructive_options = HealOpts {
remove: true,
no_lock: true,
..Default::default()
};
let results = store
.heal_pool_metadata(&HealOpts {
dry_run: true,
..destructive_options
})
.await
.expect("dry-run should inspect both metadata owners without requiring a commit");
assert_eq!(results.len(), 2);
assert!(
first_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_err()
);
assert!(
second_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_err()
);
let lock = store.pools[0]
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("metadata namespace lock should be available");
let guard = lock
.get_read_lock(get_lock_acquire_timeout())
.await
.expect("a metadata reader should hold the shared fence");
let error = temp_env::async_with_vars(
[(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))],
store.heal_pool_metadata(&destructive_options),
)
.await
.expect_err("administrative no-lock cannot bypass the metadata write fence");
assert!(matches!(error, Error::Lock(rustfs_lock::LockError::Timeout { .. })));
drop(guard);
let results = store
.heal_pool_metadata(&destructive_options)
.await
.expect("every metadata owner should be repaired");
assert_eq!(results.len(), 2);
assert!(first_missing.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_ok());
assert!(
second_missing
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
.await
.is_ok()
);
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_does_not_hide_missing_later_pool() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
delete_config(store.pools[1].clone(), POOL_META_NAME)
.await
.expect("the second pool metadata replica should be removed");
let error = store
.heal_pool_metadata(&HealOpts::default())
.await
.expect_err("the healthy first pool must not hide the second owner's missing replica");
assert!(!matches!(error, Error::NoHealRequired));
let second_set = store.pools[1].get_disks_by_key(POOL_META_NAME);
for disk in second_set.disks.read().await.iter().flatten() {
assert!(disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_err());
}
}
#[tokio::test]
async fn ordinary_pool_metadata_heal_skips_only_valid_non_owner_sets() {
let mut store = minimal_heal_store().await;
store.ctx = Arc::new(InstanceContext::new());
for algorithm in [
crate::disk::format::DistributionAlgoVersion::V1,
crate::disk::format::DistributionAlgoVersion::V2,
crate::disk::format::DistributionAlgoVersion::V3,
] {
let mut temp_dirs = Vec::new();
for pool_index in 0..store.pools.len() {
let (dirs, mut pool) =
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
temp_dirs.extend(dirs);
Arc::get_mut(&mut pool)
.expect("fixture pool should have one owner")
.distribution_algo = algorithm.clone();
store.pools[pool_index] = pool;
}
for pool_index in 0..store.pools.len() {
let owner = (0..store.pools[pool_index].disk_set.len())
.find(|set_index| {
store
.replacement_pool_metadata_applies(pool_index, *set_index)
.expect("valid metadata placement")
})
.expect("every pool must have one metadata owner");
let non_owner = 1 - owner;
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(non_owner),
..Default::default()
})
.await
.expect("valid non-owner should need no metadata write")
.is_empty()
);
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(owner),
..Default::default()
})
.await
.is_err(),
"an owner with no authoritative metadata must fail"
);
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(pool_index),
set: Some(2),
..Default::default()
})
.await
.is_err(),
"invalid sets cannot claim the non-owner exemption"
);
}
}
assert!(
store
.heal_pool_metadata(&HealOpts {
pool: Some(2),
..Default::default()
})
.await
.is_err()
);
}
#[tokio::test]
#[serial_test::serial]
async fn ordinary_pool_metadata_heal_requires_every_owner_endpoint() {
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
let owner = store.pools[0].get_disks_by_key(POOL_META_NAME);
let offline_disk = owner.disks.write().await[0]
.take()
.expect("fixture owner disk should start online");
let result = store
.heal_pool_metadata(&HealOpts {
pool: Some(0),
..Default::default()
})
.await;
assert!(result.is_err(), "a surviving metadata shard must not hide an offline owner endpoint");
owner.disks.write().await[0] = Some(offline_disk);
}
async fn remove_pool_meta_shard(store: &ECStore, pool_idx: usize) -> DiskStore {
let target_set = store.pools[pool_idx].get_disks_by_key(POOL_META_NAME);
let missing_disk = target_set.disks.read().await[0]
-118
View File
@@ -18932,124 +18932,6 @@ mod tests {
.await;
}
#[cfg(feature = "test-util")]
#[test]
#[serial_test::serial(storage_class_env)]
fn tier_config_init_recovers_after_initial_reload_failure_without_another_mutation() {
run_large_stack_async_test("tier-config-init-recovery", || async {
use crate::services::tier::tier::{TIER_DRIVER_TEST_FACTORY, TierDriverTestFactory};
let temp_dir = tempfile::tempdir().expect("create tier startup recovery store dir");
let (ctx, store, _shutdown) = build_isolated_test_store(temp_dir.path(), "tier-startup-recovery", &[4]).await;
let manager = ctx.tier_config_mgr();
let candidate = TierConfigMgr::new();
let tier_name = "STARTUP-RECOVERY";
let backend = register_mock_tier(&candidate, tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&candidate, tier_name)
.await
.expect("candidate backend identity should resolve")
.backend_identity();
let candidate_digest = {
let candidate = candidate.read().await;
candidate
.save_tiering_config(store.clone())
.await
.expect("committed tier config should persist");
tier_config_candidate_digest(&candidate).expect("committed candidate digest should build")
};
let config_path = format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE);
let config_bytes = com::read_config(store.clone(), &config_path)
.await
.expect("committed tier config bytes should load");
let config_etag = store
.get_object_info(RUSTFS_META_BUCKET, &config_path, &ObjectOptions::default())
.await
.expect("committed tier config metadata should load")
.etag
.expect("committed tier config should have an ETag");
let mutation_id = uuid::Uuid::new_v4();
let intent = TierMutationIntent {
mutation_id,
revision: 2,
kind: TierMutationIntentKind::Add,
state: TierMutationIntentState::Committed,
old_config_etag: None,
committed_config_etag: Some(config_etag),
candidate_digest,
affected_targets: vec![TierMutationIntentTarget {
tier_name: tier_name.to_string(),
old_backend_identity: None,
new_backend_identity: Some(backend_identity),
}],
expires_at_unix_nanos: 1,
};
// Persist the restart state directly: no peer Commit or runtime block
// installation may leave a notification that masks a missing startup wakeup.
save_tier_mutation_intent_record(store.clone(), &intent)
.await
.expect("committed restart intent should persist without notifying the manager");
com::save_config(store.clone(), &config_path, vec![0])
.await
.expect("controlled invalid config should persist");
runtime_sources::TEST_TIER_CONFIG_MGR
.scope(manager.clone(), async {
let err = runtime_sources::init_tier_config_mgr(store.clone())
.await
.expect_err("initial reload must report the controlled config parse failure");
assert!(err.to_string().contains("tierConfigInit: no data"), "unexpected startup failure: {err}");
})
.await;
assert!(manager.read().await.tiers.is_empty(), "failed startup must not publish the candidate");
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
Ok(_) => panic!("failed startup must retain the recovered committed fence"),
Err(err) => err,
};
assert!(TierConfigMgr::operation_lease_blocked_by_mutation(&blocked));
// Restore only the durable bytes. Recovery must come from the worker
// started by init, after the test-only handle scope has already ended.
com::save_config(store.clone(), &config_path, config_bytes)
.await
.expect("restoring committed config bytes should remove the startup failure");
// Reload only publishes this Add into an empty manager, so it has
// no replaced backend to construct or probe. Only the lease check
// below needs a driver; keep its factory on the observing task.
let driver_factory: TierDriverTestFactory = Arc::new(move |_| Ok(Box::new(backend.clone())));
TIER_DRIVER_TEST_FACTORY
.scope(driver_factory, async {
tokio::time::timeout(Duration::from_secs(10), async {
loop {
match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
Ok(lease) => {
drop(lease);
match load_tier_mutation_intent_record(store.clone(), mutation_id).await {
Err(Error::ConfigNotFound) => break,
Ok(retained) => assert_eq!(retained, intent),
Err(err) => panic!("committed recovery intent should remain readable: {err}"),
}
}
Err(err) => assert!(
TierConfigMgr::operation_lease_blocked_by_mutation(&err),
"recovery must retain the fence until the tier becomes available: {err}"
),
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("startup worker must publish the tier and clean its fence without another mutation");
})
.await;
let recovered = manager.read().await;
assert_eq!(
tier_config_candidate_digest(&recovered).expect("recovered config digest should build"),
candidate_digest,
"startup recovery must publish the committed configuration"
);
});
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
+248 -9
View File
@@ -164,6 +164,16 @@ pub fn max_keys_plus_one(max_keys: i32, add_one: bool) -> i32 {
max_keys
}
fn list_versions_scan_limit(max_keys: i32, has_version_marker: bool) -> i32 {
if max_keys <= 0 {
return 0;
}
// The marker object's versions may all be filtered out after gathering.
// Reserve its raw entry in addition to the next-page lookahead entry.
max_keys_plus_one(max_keys, true) + i32::from(has_version_marker)
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum GatherResultsState {
LimitReached,
@@ -2139,15 +2149,19 @@ fn build_list_versions_next_marker(
// here; advertise it as the literal `null` marker so a resumed listing
// parses it back to `VersionMarker::Null` instead of a nil UUID that
// `find_version_index` can never match (issue #6745).
(
Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)),
let version_marker = if last.is_dir && last.mod_time.is_none() {
// A CommonPrefix has no version to resume; a version marker would
// make the next page include this same prefix again.
None
} else {
Some(
last.version_id
.filter(|v| !v.is_nil())
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string()),
),
)
)
};
(Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)), version_marker)
} else if let Some(last_prefix) = prefixes.last() {
(Some(append_list_cache_id_to_marker(last_prefix.clone(), cache_id)), None)
} else {
@@ -2866,6 +2880,20 @@ fn listing_entries_supplement_target(
return None;
}
if let Some(directory) = entries.0.iter().flatten().find(|entry| entry.is_dir()) {
let directory_copies = entries
.0
.iter()
.flatten()
.filter(|entry| entry.is_dir() && entry.name == directory.name)
.count();
// A committed child may have some of its directory copies only on
// fallback disks, just like object metadata in a partial primary sample.
if directory_copies < resolver.dir_quorum {
return Some(directory.name.clone());
}
}
for (idx, entry) in entries.0.iter().enumerate() {
let Some(entry) = entry.as_ref().filter(|entry| entry.is_object()) else {
continue;
@@ -4018,8 +4046,7 @@ impl ECStore {
None
};
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
// Always request max_keys + 1 to detect if there are more results
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -5325,7 +5352,7 @@ impl Sets {
None
};
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -6034,7 +6061,7 @@ impl SetDisks {
let has_version_marker = version_marker.is_some();
let version_marker = version_marker.map(parse_version_marker).transpose()?;
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -6248,7 +6275,7 @@ impl SetDisks {
None
};
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
let mut opts = ListPathOptions {
bucket: bucket.to_owned(),
prefix: prefix.to_owned(),
@@ -7441,6 +7468,153 @@ mod test {
assert!(cancel.is_cancelled());
}
#[test]
fn list_versions_pagination_scan_limit_boundaries() {
for has_version_marker in [false, true] {
assert_eq!(super::list_versions_scan_limit(-1, has_version_marker), 0);
assert_eq!(super::list_versions_scan_limit(0, has_version_marker), 0);
let marker_slot = i32::from(has_version_marker);
assert_eq!(super::list_versions_scan_limit(1, has_version_marker), 2 + marker_slot);
assert_eq!(super::list_versions_scan_limit(MAX_OBJECT_LIST, has_version_marker), 1001 + marker_slot);
assert_eq!(super::list_versions_scan_limit(i32::MAX, has_version_marker), 1001 + marker_slot);
}
}
#[tokio::test]
async fn list_versions_pagination_does_not_require_an_empty_final_page() {
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "version-pagination-bucket";
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("pagination bucket should be created");
let mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
for kind in ["objects", "deletes", "null", "mixed", "delimiter"] {
let count = if kind == "mixed" { 5 } else { 10 };
let mut expected = Vec::new();
for index in 0..count {
let name = if kind == "delimiter" && index % 2 == 1 {
format!("{kind}/testobject-{index:02}/child")
} else {
format!("{kind}/testobject-{index:02}")
};
let entry = match kind {
"deletes" => test_delete_marker_meta_entry(&name, mod_time),
"null" => test_object_meta_entry(&name),
"mixed" => test_object_with_delete_marker_meta_entry(&name, mod_time, mod_time + time::Duration::SECOND),
_ => test_object_meta_entry_with_erasure_versions(&name, &[(mod_time, "etag", 2, 2)]),
};
for dir in &dirs {
let object_dir = dir.path().join(bucket).join(&name);
tokio::fs::create_dir_all(&object_dir)
.await
.expect("pagination object directory should be created");
tokio::fs::write(object_dir.join(STORAGE_FORMAT_FILE), &entry.metadata)
.await
.expect("pagination metadata should be written");
}
if kind == "delimiter" && index % 2 == 1 {
expected.push((name.trim_end_matches("child").to_owned(), None, false));
} else {
let versions = entry.file_info_versions(bucket).expect("fixture versions should decode");
expected.extend(
versions
.versions
.iter()
.map(|version| (name.clone(), version.version_id, version.deleted)),
);
}
}
let prefix = format!("{kind}/");
let delimiter = (kind == "delimiter").then(|| "/".to_owned());
// Exercise each public/internal entry point with the reported page size.
// The store entry point also covers exact and one-over limit boundaries.
for (layer, max_keys) in [(0, 0), (0, 1), (0, 5), (0, 9), (0, 10), (0, 11), (1, 5), (2, 5), (3, 5)] {
if layer == 3 && delimiter.is_some() {
continue;
}
let mut marker = None;
let mut version_marker = None;
let expected_pages = if max_keys == 0 {
1
} else {
10usize.div_ceil(usize::try_from(max_keys).expect("positive page size"))
};
let mut actual = Vec::new();
for page in 0..expected_pages {
let result = match layer {
0 => {
store
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
1 => {
store.pools[0]
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
2 => {
store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
.await
}
_ => {
store.pools[0].disk_set[0]
.clone()
.inner_list_object_versions_for_recursive_delete(
bucket,
&prefix,
marker,
version_marker,
max_keys,
)
.await
}
}
.expect("version page should list successfully");
let page_size = usize::try_from(max_keys).expect("nonnegative page size");
assert_eq!(result.objects.len() + result.prefixes.len(), (10 - page * page_size).min(page_size));
let has_more = page + 1 < expected_pages;
assert_eq!(result.is_truncated, has_more, "{kind}, layer {layer}, max_keys {max_keys}, page {page}");
assert_eq!(
result.next_marker.is_some(),
has_more,
"key marker must exist only when another page exists"
);
if !has_more {
assert!(
result.next_version_idmarker.is_none(),
"the final page must not advertise a version marker"
);
}
actual.extend(
result
.objects
.into_iter()
.map(|object| (object.name, object.version_id, object.delete_marker)),
);
actual.extend(result.prefixes.into_iter().map(|prefix| (prefix, None, false)));
marker = result.next_marker;
version_marker = result.next_version_idmarker;
}
// Objects and CommonPrefixes are serialized separately; compare their
// identities without relying on their relative position in the response.
actual.sort();
let mut expected = if max_keys == 0 { Vec::new() } else { expected.clone() };
expected.sort();
assert_eq!(actual, expected, "{kind}, layer {layer}, max_keys {max_keys}");
}
}
}
#[test]
fn version_marker_is_applied_only_when_key_marker_entry_is_present() {
let version_marker = Some(VersionMarker::Null);
@@ -9448,6 +9622,71 @@ mod test {
assert!(supplemented.is_latest_delete_marker());
}
#[tokio::test]
async fn latest_listing_supplement_checks_fallback_disks_for_common_prefix_quorum() {
let mut fallback_disks = Vec::new();
let mut fallback_tempdirs = Vec::new();
for index in 0..4 {
let tempdir = tempfile::tempdir().expect("fallback tempdir should be created");
let endpoint = Endpoint::try_from(tempdir.path().to_str().expect("fallback path should be utf8"))
.expect("fallback endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("fallback disk should be created");
disk.make_volume("bucket").await.expect("fallback bucket should be created");
for copies in [3, 4] {
if index < copies {
let object = format!("quux-{copies}/thud");
let entry = test_object_meta_entry(&object);
disk.write_all("bucket", &format!("{object}/{STORAGE_FORMAT_FILE}"), bytes::Bytes::from(entry.metadata))
.await
.expect("fallback child metadata should be written");
}
}
fallback_disks.push(disk);
fallback_tempdirs.push(tempdir);
}
let supplement = ListingSupplement::new(
ListingSupplementOptions {
bucket: "bucket".to_owned(),
path: String::new(),
recursive: false,
incl_deleted: false,
skip_hidden_prefix_check: false,
filter_prefix: None,
forward_to: None,
per_disk_limit: 100,
skip_total_timeout: true,
walkdir_timeout: None,
walkdir_stall_timeout: None,
},
Arc::new(fallback_disks),
FallbackClaimTracker::default(),
);
// A 16-drive EC:4 set asks 12 primary disks. A committed write may
// exist on eight primary disks and all four remaining fallback disks.
let resolver = list_metadata_resolution_params("bucket".to_owned(), 4, 12, false, 0);
for fallback_copies in [3, 4] {
let prefix = format!("quux-{fallback_copies}/");
let mut primary = vec![Some(test_dir_meta_entry(&prefix)); 8];
primary.extend([None, None, None, None]);
let entry =
resolve_listing_entries_with_supplement(MetaCacheEntries(primary), resolver.clone(), true, supplement.clone())
.await;
assert_eq!(
entry.map(|entry| entry.name),
(fallback_copies == 4).then_some(prefix),
"the common prefix needs all twelve copies, including fallback disks"
);
}
}
#[test]
fn latest_listing_supplement_keeps_a_subquorum_delete_marker_hidden() {
let object_mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
+1
View File
@@ -104,6 +104,7 @@ walkdir = { workspace = true }
http = { workspace = true }
temp-env = { workspace = true, features = ["async_closure"] }
tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] }
chrono = { workspace = true }
[lib]
doctest = false
+66 -397
View File
@@ -851,7 +851,7 @@ impl ErasureSetHealer {
}
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
self.heal_pool_metadata(
self.heal_replacement_pool_metadata(
set_disk_id,
&mut ErasureSetPassCounters {
processed_objects: &mut processed_objects,
@@ -950,43 +950,26 @@ impl ErasureSetHealer {
Ok(())
}
async fn heal_pool_metadata(
async fn heal_replacement_pool_metadata(
&self,
set_disk_id: &str,
counters: &mut ErasureSetPassCounters<'_>,
resume_manager: &ResumeManager,
checkpoint_manager: &CheckpointManager,
) -> Result<()> {
let mut metadata_opts = self.heal_opts;
metadata_opts.remove = false;
metadata_opts.no_lock = false;
if self.replacement_task_id.is_none() {
let (pool_index, set_index) = crate::heal::utils::parse_set_disk_id(set_disk_id)?;
if metadata_opts.pool.is_some_and(|pool| pool != pool_index) || metadata_opts.set.is_some_and(|set| set != set_index)
{
return Err(Error::TaskExecutionFailed {
message: format!("Pool metadata scope does not match resumed set {set_disk_id}"),
});
}
metadata_opts.pool = Some(pool_index);
metadata_opts.set = Some(set_index);
}
let target_endpoints = if self.replacement_task_id.is_some() || self.pool_metadata_target_endpoints.is_empty() {
let target_endpoints = if self.pool_metadata_target_endpoints.is_empty() {
self.target_endpoints.as_ref()
} else {
self.pool_metadata_target_endpoints.as_ref()
};
let target_scoped_recreate = !metadata_opts.dry_run && metadata_opts.recreate && !target_endpoints.is_empty();
let ordinary_heal = self.replacement_task_id.is_none() && !target_scoped_recreate;
if !ordinary_heal {
if target_endpoints.is_empty() {
return Err(Error::TaskExecutionFailed {
message: "Replacement pool metadata heal requires target endpoints".to_string(),
});
}
if !self.storage.replacement_pool_metadata_applies(&metadata_opts).await? {
return Ok(());
}
let target_scoped_recreate = !self.heal_opts.dry_run && self.heal_opts.recreate && !target_endpoints.is_empty();
if self.replacement_task_id.is_none() && !target_scoped_recreate {
return Ok(());
}
if target_endpoints.is_empty() {
return Err(Error::TaskExecutionFailed {
message: "Replacement pool metadata heal requires target endpoints".to_string(),
});
}
let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}");
@@ -1005,88 +988,61 @@ impl ErasureSetHealer {
.set_current_item(Some(RUSTFS_META_BUCKET.to_string()), Some(POOL_META_NAME.to_string()))
.await?;
let result = if ordinary_heal {
match self.storage.heal_pool_metadata(&metadata_opts).await {
Ok(results) if results.is_empty() => return Ok(()),
Ok(results) => {
let [result] = results.as_slice() else {
return Err(Error::TaskExecutionFailed {
message: format!("Pool metadata returned multiple replicas for set {set_disk_id}"),
});
};
(result_object_size_u64(result), Ok(()))
let result = match self
.storage
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
let object_size = result_object_size_u64(&result);
match self
.storage
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts, target_endpoints)
.await
{
Ok(true) => (object_size, Ok(())),
Ok(false) => (
object_size,
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
)),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal because target readback failed: {err}"
))),
),
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => {
(0, Err(Error::transient_skip(format!("Pool metadata heal must be retried: {err}"))))
}
HealObjectOutcome::Failed => (0, Err(err)),
},
}
} else {
match self
.storage
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &metadata_opts)
.await
{
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
let object_size = result_object_size_u64(&result);
match self
.storage
.replacement_targets_have_version(
RUSTFS_META_BUCKET,
POOL_META_NAME,
None,
&metadata_opts,
target_endpoints,
)
.await
{
Ok(true) => (object_size, Ok(())),
Ok(false) => (
object_size,
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
)),
),
Err(err) => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal because target readback failed: {err}"
))),
),
}
}
Ok((result, None)) => (
result_object_size_u64(&result),
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because a replacement target was not committed",
)),
),
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (object_size, Err(err)),
}
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
Ok((result, None)) => (
result_object_size_u64(&result),
Err(Error::transient_skip(
"Skipped replacement pool metadata heal because a replacement target was not committed",
)),
),
Ok((result, Some(err))) => {
let object_size = result_object_size_u64(&result);
match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
0,
object_size,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (0, Err(err)),
},
HealObjectOutcome::Failed => (object_size, Err(err)),
}
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(err) => match Self::classify_heal_object_error(&err) {
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
0,
Err(Error::transient_skip(format!(
"Skipped replacement pool metadata heal due to transient error: {err}"
))),
),
HealObjectOutcome::Failed => (0, Err(err)),
},
};
let (object_size, result) = result;
@@ -1105,7 +1061,7 @@ impl ErasureSetHealer {
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
state = "healed",
"Pool metadata healed"
"Replacement pool metadata healed"
);
CheckpointObjectOutcome::Processed
}
@@ -1122,7 +1078,7 @@ impl ErasureSetHealer {
object = POOL_META_NAME,
state = "transient_skip",
error = %message,
"Pool metadata heal skipped due to transient error"
"Replacement pool metadata heal skipped due to transient error"
);
CheckpointObjectOutcome::Skipped
}
@@ -1139,7 +1095,7 @@ impl ErasureSetHealer {
object = POOL_META_NAME,
state = "failed",
error = %err,
"Pool metadata heal failed"
"Replacement pool metadata heal failed"
);
CheckpointObjectOutcome::Failed
}
@@ -1580,9 +1536,7 @@ impl ErasureSetHealer {
);
CheckpointObjectOutcome::Processed
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => {
return Err(err);
}
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
Err(Error::TransientSkip { message }) => {
telemetry_unknown |= !increment_counter(skipped_objects);
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
@@ -2084,8 +2038,6 @@ mod resume_loop_tests {
#[derive(Clone)]
enum HealOutcome {
Ok,
/// The object has no metadata on any disk in the selected set.
FileNotFound,
/// The version vanished before heal ran (deleted mid-heal).
VersionNotFound,
/// A transient infrastructure condition (offline disk / unmet quorum):
@@ -2111,10 +2063,6 @@ mod resume_loop_tests {
/// Target-specific physical readback evidence per `compose_key`; the
/// fake models a healthy backend unless a test explicitly revokes it.
replacement_commit_evidence: Mutex<HashMap<String, ReplacementCommitEvidence>>,
ordinary_pool_metadata_required: AtomicBool,
pool_metadata_opts: Mutex<Vec<HealOpts>>,
pool_metadata_not_applicable: AtomicBool,
fail_pool_metadata_scope: AtomicBool,
lifecycle_expired: Mutex<HashSet<String>>,
/// every heal_object call recorded as (name, version_id)
heal_calls: Mutex<Vec<(String, Option<String>)>>,
@@ -2203,14 +2151,11 @@ mod resume_loop_tests {
}
async fn heal_object(
&self,
bucket: &str,
_bucket: &str,
object: &str,
version_id: Option<&str>,
opts: &HealOpts,
_opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
if bucket == RUSTFS_META_BUCKET && object == POOL_META_NAME {
self.pool_metadata_opts.lock().expect("metadata options").push(*opts);
}
self.heal_calls
.lock()
.unwrap()
@@ -2219,7 +2164,6 @@ mod resume_loop_tests {
let outcome = self.outcomes.lock().unwrap().get(&key).cloned().unwrap_or(HealOutcome::Ok);
match outcome {
HealOutcome::Ok => Ok((self.results.lock().unwrap().get(&key).cloned().unwrap_or_default(), None)),
HealOutcome::FileNotFound => Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileNotFound)))),
HealOutcome::VersionNotFound => {
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound))))
}
@@ -2233,30 +2177,6 @@ mod resume_loop_tests {
async fn heal_format(&self, _dry: bool) -> Result<(HealResultItem, Option<Error>)> {
Ok((HealResultItem::default(), None))
}
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
if !self.ordinary_pool_metadata_required.load(Ordering::SeqCst) {
return Ok(Vec::new());
}
if !self.replacement_pool_metadata_applies(opts).await? {
return Ok(Vec::new());
}
let (result, error) = self.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, opts).await?;
if let Some(error) = error {
return Err(error);
}
Ok(vec![result])
}
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
if self.fail_pool_metadata_scope.load(Ordering::SeqCst) {
return Err(Error::other("injected pool metadata scope failure"));
}
if self.pool_metadata_not_applicable.load(Ordering::SeqCst) {
assert_eq!(opts.pool, Some(0));
assert_eq!(opts.set, Some(1));
return Ok(false);
}
Ok(true)
}
async fn replacement_targets_have_version(
&self,
_bucket: &str,
@@ -2748,115 +2668,6 @@ mod resume_loop_tests {
assert!(state.completed, "successful data heal must be persisted before cleanup is attempted");
}
#[tokio::test]
async fn ordinary_set_heals_pool_metadata_without_replacement_generation_or_targets() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage
.set_result(POOL_META_NAME, None, replacement_target_ok_result("metadata-disk", POOL_META_NAME));
assert!(env.healer.replacement_task_id.is_none());
assert!(env.healer.target_endpoints.is_empty());
env.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect("ordinary set recovery should repair metadata even without user buckets");
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
{
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
}
let state = env.resume.get_state().await;
assert!(state.completed);
assert_eq!(state.successful_objects, 1, "metadata must enter durable completion counters");
}
#[tokio::test]
async fn ordinary_set_pool_metadata_respects_non_owner_and_dry_run() {
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.pool_metadata_not_applicable.store(true, Ordering::SeqCst);
env.healer.heal_opts.pool = Some(0);
env.healer.heal_opts.set = Some(1);
env.healer
.execute_heal_with_resume(&[], "pool_0_set_1", &env.resume, &env.checkpoint)
.await
.expect("a valid non-owner set must not invent a metadata replica");
assert!(env.storage.calls().is_empty());
assert_eq!(env.resume.get_state().await.successful_objects, 0);
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.healer.heal_opts.dry_run = true;
env.healer.heal_opts.remove = true;
env.healer.heal_opts.no_lock = true;
env.storage.set_replacement_commit_evidence(POOL_META_NAME, None, false);
env.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect("ordinary dry-run metadata work must not require a replacement commit");
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert!(opts[0].dry_run);
assert!(!opts[0].remove);
assert!(!opts[0].no_lock);
}
#[tokio::test]
async fn ordinary_set_missing_pool_metadata_preserves_retry_state() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("missing required pool metadata must prevent ordinary set completion");
assert!(matches!(error, Error::TransientSkip { .. }));
let state = env.resume.get_state().await;
assert!(!state.completed);
assert_eq!(state.retry_count, 1);
assert!(CheckpointManager::has_checkpoint(&env.healer.disk, &env.task_id).await);
}
#[tokio::test]
async fn ordinary_set_pool_metadata_timeout_keeps_control_error() {
let env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::Timeout);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("metadata timeout must abort the ordinary set pass");
assert!(matches!(error, Error::TaskTimeout));
assert!(!env.resume.get_state().await.completed);
}
#[tokio::test]
async fn ordinary_set_pool_metadata_rejects_mismatched_explicit_scope() {
let mut env = make_env().await;
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
env.healer.heal_opts.pool = Some(1);
let error = env
.healer
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
.await
.expect_err("explicit metadata scope must agree with the resumed set");
assert!(matches!(error, Error::TaskExecutionFailed { .. }));
assert!(env.storage.calls().is_empty(), "scope mismatch must fail before metadata mutation");
assert!(!env.resume.get_state().await.completed);
}
#[tokio::test]
async fn replacement_completion_keeps_resume_artifacts_until_marker_cleanup() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
@@ -2911,111 +2722,6 @@ mod resume_loop_tests {
drop(checkpoint);
}
#[tokio::test]
async fn replacement_pool_metadata_non_owner_completes_but_missing_owner_retries() {
for owns_pool_metadata in [false, true] {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let replacement_task_id = ResumeUtils::generate_task_id();
let set_index = usize::from(!owns_pool_metadata);
let set_disk_id = format!("pool_0_set_{set_index}");
ResumeManager::new_replacement_intent(
env.healer.disk.clone(),
replacement_task_id.clone(),
set_disk_id.clone(),
vec!["b".to_string()],
vec!["replacement-a".to_string()],
vec![crate::heal::resume::ReplacementTargetIdentity {
endpoint: "replacement-a".to_string(),
canonical_path: "/mnt/replacement-a".to_string(),
physical_device_ids: vec!["device-a".to_string()],
filesystem_identity: "1:2:3".to_string(),
}],
)
.await
.expect("replacement intent should persist");
env.storage
.pool_metadata_not_applicable
.store(!owns_pool_metadata, Ordering::SeqCst);
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
pool: Some(0),
set: Some(set_index),
..Default::default()
},
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
let result = healer.heal_erasure_set(&["b".to_string()], &set_disk_id).await;
let state = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
.await
.expect("replacement state must remain until marker cleanup")
.get_state()
.await;
if owns_pool_metadata {
let error = result.expect_err("missing metadata in the owner set must keep replacement incomplete");
assert!(error.to_string().contains("Replacement erasure set heal incomplete"));
assert!(!state.completed);
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Intent);
assert_eq!(state.retry_count, 1);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
} else {
result.expect("a non-owner set must complete without a pool metadata replica");
assert!(state.completed);
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Verified);
assert_eq!(state.retry_count, 0);
assert!(env.storage.calls().is_empty(), "non-owner sets must not attempt pool metadata repair");
}
}
}
#[tokio::test]
async fn replacement_pool_metadata_unknown_scope_cannot_complete() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
pool: Some(0),
set: Some(0),
..Default::default()
},
HealRequestSource::AutoHeal,
)
.with_replacement_targets(vec!["replacement-a".to_string()], Some("generation-a".to_string()));
env.storage.fail_pool_metadata_scope.store(true, Ordering::SeqCst);
env.storage
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
let mut processed_objects = 0;
let mut successful_objects = 0;
let mut failed_objects = 0;
let mut skipped_objects = 0;
let error = healer
.heal_pool_metadata(
"pool_0_set_0",
&mut super::ErasureSetPassCounters {
processed_objects: &mut processed_objects,
successful_objects: &mut successful_objects,
failed_objects: &mut failed_objects,
skipped_objects: &mut skipped_objects,
},
&env.resume,
&env.checkpoint,
)
.await
.expect_err("unknown metadata placement must keep replacement incomplete");
assert!(error.to_string().contains("injected pool metadata scope failure"));
assert!(env.storage.calls().is_empty());
assert_eq!((processed_objects, successful_objects, failed_objects, skipped_objects), (0, 0, 0, 0));
}
#[tokio::test]
async fn replacement_pool_metadata_readback_failure_schedules_retry() {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
@@ -3075,8 +2781,8 @@ mod resume_loop_tests {
env.healer.disk.clone(),
HealOpts {
recreate: true,
remove: true,
no_lock: true,
pool: Some(0),
set: Some(0),
..Default::default()
},
HealRequestSource::Admin,
@@ -3092,43 +2798,6 @@ mod resume_loop_tests {
assert!(env.resume.get_state().await.completed);
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1);
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
assert!(opts[0].recreate);
assert!(!opts[0].remove);
assert!(!opts[0].no_lock);
}
#[tokio::test]
async fn admin_recreate_pool_metadata_validates_owner_scope_before_io() {
for (non_owner, unknown_scope, pool) in [(true, false, None), (false, true, None), (false, false, Some(1))] {
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
env.storage.pool_metadata_not_applicable.store(non_owner, Ordering::SeqCst);
env.storage.fail_pool_metadata_scope.store(unknown_scope, Ordering::SeqCst);
let healer = ErasureSetHealer::new(
env.storage.clone(),
Arc::new(RwLock::new(HealProgress::new())),
CancellationToken::new(),
env.healer.disk.clone(),
HealOpts {
recreate: true,
pool,
..Default::default()
},
HealRequestSource::Admin,
)
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
let set_disk_id = if non_owner { "pool_0_set_1" } else { "pool_0_set_0" };
let result = healer
.execute_heal_with_resume(&[], set_disk_id, &env.resume, &env.checkpoint)
.await;
assert_eq!(result.is_ok(), non_owner, "only a known non-owner may skip metadata: {result:?}");
assert!(env.storage.calls().is_empty(), "scope validation must precede metadata I/O");
assert_eq!(env.resume.get_state().await.completed, non_owner);
}
}
#[tokio::test]
+130 -17
View File
@@ -841,6 +841,10 @@ pub struct HealManager {
replacement_recovery_anchors: Arc<std::sync::Mutex<HashMap<String, String>>>,
/// Set IDs whose durable replacement metadata is corrupt or conflicting.
replacement_recovery_blocked_sets: Arc<std::sync::Mutex<HashSet<String>>>,
/// Durable handoff of interrupted administrator root traversals.
root_recovery: Arc<root_recovery::RootHealRecovery>,
/// Keep forceStart's cancellation side effects inside the shutdown fence.
force_start_shutdown: Mutex<()>,
/// Storage layer interface
storage: Arc<dyn HealStorageAPI>,
/// Cancel token
@@ -876,6 +880,7 @@ struct HealQueueContext<'a> {
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>,
replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>,
root_recovery: &'a Arc<root_recovery::RootHealRecovery>,
config: &'a Arc<RwLock<HealConfig>>,
statistics: &'a Arc<RwLock<HealStatistics>>,
storage: &'a Arc<dyn HealStorageAPI>,
@@ -1377,6 +1382,8 @@ impl HealManager {
mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())),
replacement_recovery_anchors: Arc::new(std::sync::Mutex::new(HashMap::new())),
replacement_recovery_blocked_sets: Arc::new(std::sync::Mutex::new(HashSet::new())),
root_recovery: Arc::new(root_recovery::RootHealRecovery::default()),
force_start_shutdown: Mutex::new(()),
storage,
cancel_token: CancellationToken::new(),
statistics: Arc::new(RwLock::new(HealStatistics::new())),
@@ -1412,6 +1419,23 @@ impl HealManager {
"Heal manager starting"
);
// Restore graceful-shutdown root responsibilities before automatic
// repair can admit overlapping work.
if let Err(error) = self.replay_root_heals().await {
// A missing owner or invalid root record must not block existing
// replacement recovery. Keep its file for a later restart after
// the owner is readable or the record has been repaired.
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_MANAGER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
state = "root_recovery_deferred",
error = %error,
"Root heal restart recovery deferred"
);
}
// start scheduler
self.start_scheduler().await?;
@@ -1449,6 +1473,7 @@ impl HealManager {
/// Stop HealManager
pub async fn stop(&self) -> Result<()> {
let _force_start_guard = self.force_start_shutdown.lock().await;
info!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_MANAGER_STATE,
@@ -1458,11 +1483,39 @@ impl HealManager {
"Heal manager stopping"
);
// cancel all tasks
self.cancel_token.cancel();
// wait for all tasks to complete
// Keep scheduler, cancellation, and retry ownership stable until every
// unfinished root traversal has a durable successor. A failed write
// must leave the manager running and the shutdown marker unclean.
let mut active_heals = self.active_heals.lock().await;
let queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
for task in active_heals.values() {
if root_recovery::is_root_heal(&task.heal_type, task.source) {
if task.get_status().await == HealTaskStatus::Completed {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
} else {
let mut request = match task.retry_request_with_remaining_timeout().await {
Ok(request) => request,
Err(Error::TaskTimeout) => {
let mut request = task.retry_request();
request.options.timeout = Some(Duration::ZERO);
request
}
Err(error) => return Err(error),
};
request.retry_attempts = task.retry_attempts;
self.root_recovery.persist(&request).await?;
}
}
}
for request in queue.requests().chain(retrying.values().map(|retrying| &retrying.request)) {
self.root_recovery.persist(request).await?;
}
self.cancel_token.cancel();
drop(retrying);
drop(queue);
// cancel active workers after the durable handoff
for task in active_heals.values() {
if let Err(e) = task.cancel().await {
warn!(
@@ -1589,6 +1642,17 @@ impl HealManager {
let admission_start = Instant::now();
let source = request.source;
let force_start = request.force_start;
// A forceStart must not retire an old durable owner if shutdown will
// reject its replacement. Hold the same gate through final admission.
let _force_start_guard = if source == HealRequestSource::Admin && force_start {
let guard = self.force_start_shutdown.lock().await;
if self.cancel_token.is_cancelled() {
return Err(Error::Other("Heal manager is stopping".to_string()));
}
Some(guard)
} else {
None
};
// HS-06 forceStart semantics (admin only): MinIO stops the old task
// first and then starts the new one. Cancel any active admin task
// overlapping this request's path before entering admission, so the
@@ -1596,7 +1660,9 @@ impl HealManager {
if request.source == HealRequestSource::Admin && request.force_start {
let overlapping: Vec<String> = {
let active_heals = self.active_heals.lock().await;
active_heals
let queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
let mut ids = active_heals
.iter()
.filter(|(task_id, task)| {
task.source == HealRequestSource::Admin
@@ -1604,7 +1670,17 @@ impl HealManager {
&& *task_id != &request.id
})
.map(|(task_id, _)| task_id.clone())
.collect()
.collect::<Vec<_>>();
ids.extend(
queue
.requests()
.chain(retrying.values().map(|retrying| &retrying.request))
.filter(|pending| {
root_recovery::is_root_heal(&pending.heal_type, pending.source) && pending.id != request.id
})
.map(|pending| pending.id.clone()),
);
ids
};
for task_id in overlapping {
match self.cancel_task(&task_id).await {
@@ -1618,17 +1694,14 @@ impl HealManager {
result = "force_start_cancelled_overlap",
"Admin forceStart cancelled an overlapping heal task"
),
Err(err) => warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_QUEUE_ADMISSION,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
request_id = %request.id,
cancelled_task_id = %task_id,
error = %err,
result = "force_start_cancel_failed",
"Admin forceStart failed to cancel an overlapping heal task"
),
Err(err) => return Err(err),
}
}
// A failed or timed-out replay may have only its durable owner
// left. Root responsibility overlaps every administrator path.
for pending in self.root_recovery.pending().await? {
if pending.id != request.id {
self.cancel_task(&pending.id).await?;
}
}
}
@@ -1641,6 +1714,9 @@ impl HealManager {
// active -> retrying transitions can slip between duplicate checks.
let lock_phase_start = Instant::now();
let active_heals = self.active_heals.lock().await;
if self.cancel_token.is_cancelled() {
return Err(Error::Other("Heal manager is stopping".to_string()));
}
#[cfg(test)]
pause_duplicate_admission_after_active_lock(&request.id).await;
let mut queue = self.heal_queue.lock().await;
@@ -2145,6 +2221,7 @@ impl HealManager {
{
let mut active_heals = self.active_heals.lock().await;
if let Some(task) = active_heals.get(&canonical_task_id) {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
task.cancel().await?;
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
publish_completed_heal(&self.completed_heals, &self.task_aliases, &canonical_task_id, completed, true).await;
@@ -2168,6 +2245,11 @@ impl HealManager {
{
let mut retrying_heals = self.retrying_heals.lock().await;
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
self.root_recovery
.remove(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
}
if let Some(retrying) = retrying_heals.remove(&canonical_task_id) {
retrying.cancel_token.cancel();
drop(retrying_heals);
@@ -2188,6 +2270,11 @@ impl HealManager {
}
let mut queue = self.heal_queue.lock().await;
if let Some(request) = queue.requests().find(|request| request.id == canonical_task_id) {
self.root_recovery
.remove(&request.id, &request.heal_type, request.source)
.await?;
}
if queue.remove_request_id(&canonical_task_id).is_some() {
publish_heal_queue_length(&queue);
info!(
@@ -2205,6 +2292,10 @@ impl HealManager {
return Ok(());
}
drop(queue);
if self.root_recovery.cancel_pending(&canonical_task_id).await? {
return Ok(());
}
Err(Error::TaskNotFound {
task_id: task_id.to_string(),
})
@@ -2223,6 +2314,7 @@ impl HealManager {
for task_id in &task_ids {
if let Some(task) = active_heals.get(task_id) {
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
task.cancel().await?;
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
publish_completed_heal(&self.completed_heals, &self.task_aliases, task_id, completed, true).await;
@@ -2251,6 +2343,11 @@ impl HealManager {
.collect::<Vec<_>>();
for task_id in &task_ids {
if let Some(retrying) = retrying_heals.get(task_id) {
self.root_recovery
.remove(task_id, &retrying.request.heal_type, retrying.request.source)
.await?;
}
if let Some(retrying) = retrying_heals.remove(task_id) {
retrying.cancel_token.cancel();
cancelled += 1;
@@ -2273,6 +2370,14 @@ impl HealManager {
}
let mut queue = self.heal_queue.lock().await;
for request in queue
.requests()
.filter(|request| heal_type_matches_path(&request.heal_type, heal_path))
{
self.root_recovery
.remove(&request.id, &request.heal_type, request.source)
.await?;
}
let queued_cancelled = queue.remove_matching(|request| heal_type_matches_path(&request.heal_type, heal_path));
if !queued_cancelled.is_empty() {
publish_heal_queue_length(&queue);
@@ -2284,6 +2389,13 @@ impl HealManager {
self.remove_mrf_repair_notice_targets_for_task(&request.id);
}
if heal_type_matches_path(&HealType::Cluster, heal_path) {
for pending in self.root_recovery.pending().await? {
if self.root_recovery.cancel_pending(&pending.id).await? {
cancelled += 1;
}
}
}
if cancelled == 0 {
return Err(Error::TaskNotFound {
task_id: heal_path.to_string(),
@@ -2395,6 +2507,7 @@ impl std::fmt::Debug for HealManager {
mod auto_scan;
mod queue;
mod root_recovery;
mod scheduler;
mod unclean_shutdown;
@@ -0,0 +1,342 @@
// 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.
//! Graceful-shutdown handoff for administrator root heals. This namespace is
//! separate from erasure-set checkpoints and replacement generations, which
//! cannot represent a cluster traversal. One coordinator disk owns each
//! record; never create a fallback copy after an uncertain write or deletion.
use super::*;
use crate::heal::storage_api::owner::{EcstoreConditionalFileUpdate, EcstoreDiskAPI, EcstoreDiskBytes};
use crate::heal::{DiskStore, RUSTFS_META_BUCKET};
use serde::{Deserialize, Serialize};
// The metadata bucket already exists and its parent is durable. Creating a
// nested journal directory here would also require syncing every ancestor.
const ROOT_RECOVERY_PREFIX: &str = "root-heal-";
const ROOT_RECOVERY_SCHEMA: u32 = 1;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RootHealIntent {
schema: u32,
task_id: String,
#[serde(deserialize_with = "decode_options")]
options: HealOptions,
priority: HealPriority,
retry_attempts: u32,
created_at: SystemTime,
}
impl RootHealIntent {
fn from_request(request: &HealRequest) -> Self {
Self {
schema: ROOT_RECOVERY_SCHEMA,
task_id: request.id.clone(),
options: request.options.clone(),
priority: request.priority,
retry_attempts: request.retry_attempts,
created_at: request.created_at,
}
}
fn into_request(self) -> HealRequest {
let mut request = HealRequest::new(HealType::Cluster, self.options, self.priority);
request.id = self.task_id;
request.source = HealRequestSource::Admin;
request.retry_attempts = self.retry_attempts;
request.created_at = self.created_at;
request
}
}
#[derive(Default)]
pub(super) struct RootHealRecovery {
mutation: Mutex<()>,
#[cfg(test)]
disks: Option<Vec<DiskStore>>,
}
pub(super) fn is_root_heal(heal_type: &HealType, source: HealRequestSource) -> bool {
source == HealRequestSource::Admin && matches!(heal_type, HealType::Cluster)
}
fn decode_options<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<HealOptions, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
let object = value
.as_object()
.ok_or_else(|| serde::de::Error::custom("root heal options must be an object"))?;
const FIELDS: &[&str] = &[
"scan_mode",
"remove_corrupted",
"recreate_missing",
"update_parity",
"recursive",
"dry_run",
"no_lock",
"timeout",
"pool_index",
"set_index",
];
if object.keys().any(|key| !FIELDS.contains(&key.as_str())) {
return Err(serde::de::Error::custom("unknown root heal recovery option"));
}
let options: HealOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
if options.no_lock {
return Err(serde::de::Error::custom("administrator root heal cannot skip namespace locking"));
}
Ok(options)
}
fn intent_path(task_id: &str) -> Result<String> {
let parsed = uuid::Uuid::parse_str(task_id).map_err(|_| Error::Other("Invalid root heal recovery task id".to_string()))?;
if parsed.to_string() != task_id {
return Err(Error::Other("Noncanonical root heal recovery task id".to_string()));
}
Ok(format!("{ROOT_RECOVERY_PREFIX}{task_id}.json"))
}
fn decode_intent(task_id: &str, bytes: &[u8]) -> Result<RootHealIntent> {
let _ = intent_path(task_id)?;
let intent: RootHealIntent = serde_json::from_slice(bytes)
.map_err(|error| Error::Other(format!("Invalid root heal recovery record {task_id}: {error}")))?;
if intent.schema != ROOT_RECOVERY_SCHEMA || intent.task_id != task_id {
return Err(Error::Other(format!("Unsupported or mismatched root heal recovery record {task_id}")));
}
Ok(intent)
}
impl RootHealRecovery {
#[cfg(test)]
pub(super) fn with_disks(disks: Vec<DiskStore>) -> Self {
Self {
mutation: Mutex::new(()),
disks: Some(disks),
}
}
async fn disks(&self) -> Result<Vec<DiskStore>> {
#[cfg(test)]
if let Some(disks) = &self.disks {
return Ok(disks.clone());
}
let map = local_disk_map_read().await;
if map.values().any(Option::is_none) {
return Err(Error::Other("Root heal recovery owner may be on an unavailable local disk".to_string()));
}
let mut disks = map.values().flatten().cloned().collect::<Vec<_>>();
disks.sort_by_key(|disk| EcstoreDiskAPI::endpoint(disk.as_ref()).to_string());
Ok(disks)
}
async fn find(disks: &[DiskStore], task_id: &str) -> Result<Option<(DiskStore, EcstoreDiskBytes)>> {
let path = intent_path(task_id)?;
let mut found = None;
for disk in disks {
// read_all reports FileNotFound even when the whole metadata
// volume is absent; that is an unknown owner, not empty state.
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
match EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, &path).await {
Ok(bytes) => {
decode_intent(task_id, &bytes)?;
if found.is_some() {
return Err(Error::Other(format!("Multiple root heal recovery owners for {task_id}")));
}
found = Some((disk.clone(), bytes));
}
Err(DiskError::FileNotFound) => {}
Err(error) => return Err(Error::Disk(error)),
}
}
Ok(found)
}
pub(super) async fn persist(&self, request: &HealRequest) -> Result<()> {
if !is_root_heal(&request.heal_type, request.source) {
return Ok(());
}
let _guard = self.mutation.lock().await;
let disks = self.disks().await?;
let existing = Self::find(&disks, &request.id).await?;
let (disk, expected) = match existing {
Some((disk, bytes)) => (disk, Some(bytes)),
None => {
let disk = disks
.first()
.cloned()
.ok_or_else(|| Error::Other("No local disk available for root heal shutdown recovery".to_string()))?;
(disk, None)
}
};
if request.options.no_lock {
return Err(Error::Other("Administrator root heal cannot skip namespace locking".to_string()));
}
let bytes = serde_json::to_vec(&RootHealIntent::from_request(request))
.map_err(|error| Error::Other(format!("Serialize root heal recovery record: {error}")))?;
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(&request.id)?,
expected,
Some(bytes.into()),
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(()),
_ => Err(Error::Other(format!("Root heal recovery record changed for {}", request.id))),
}
}
pub(super) async fn remove(&self, task_id: &str, heal_type: &HealType, source: HealRequestSource) -> Result<bool> {
if !is_root_heal(heal_type, source) {
return Ok(false);
}
let _guard = self.mutation.lock().await;
let Some((disk, bytes)) = Self::find(&self.disks().await?, task_id).await? else {
return Ok(false);
};
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(task_id)?,
Some(bytes),
None,
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(true),
_ => Err(Error::Other(format!("Root heal recovery record changed while retiring {task_id}"))),
}
}
pub(super) async fn checkpoint_failed_execution(&self, task: &HealTask) -> Result<()> {
if !is_root_heal(&task.heal_type, task.source) {
return Ok(());
}
let remaining = match task.retry_request_with_remaining_timeout().await {
Ok(request) => request.options.timeout,
Err(Error::TaskTimeout) => Some(Duration::ZERO),
Err(error) => return Err(error),
};
let _guard = self.mutation.lock().await;
let Some((disk, expected)) = Self::find(&self.disks().await?, &task.id).await? else {
// A first execution that failed has no restart handoff to update.
return Ok(());
};
let mut intent = decode_intent(&task.id, &expected)?;
let mut expected_options = intent.options.clone();
expected_options.timeout = task.options.timeout;
if intent.created_at != task.created_at || intent.priority != task.priority || expected_options != task.options {
return Err(Error::Other(format!("Root heal recovery owner changed for {}", task.id)));
}
// A terminal timeout leaves no runtime owner for stop() to snapshot.
// Checkpoint its consumed budget before publishing terminal status;
// never refund time if an earlier checkpoint is already stricter.
intent.options.timeout = match (intent.options.timeout, remaining) {
(Some(previous), Some(remaining)) => Some(previous.min(remaining)),
(previous, remaining) => previous.or(remaining),
};
intent.retry_attempts = intent.retry_attempts.max(task.retry_attempts);
let bytes = serde_json::to_vec(&intent)
.map_err(|error| Error::Other(format!("Serialize root heal recovery checkpoint: {error}")))?;
match EcstoreDiskAPI::compare_and_update_file(
disk.as_ref(),
RUSTFS_META_BUCKET,
&intent_path(&task.id)?,
Some(expected),
Some(bytes.into()),
)
.await?
{
EcstoreConditionalFileUpdate::Updated => Ok(()),
_ => Err(Error::Other(format!("Root heal recovery record changed while checkpointing {}", task.id))),
}
}
pub(super) async fn cancel_pending(&self, task_id: &str) -> Result<bool> {
if intent_path(task_id).is_err() {
return Ok(false);
}
self.remove(task_id, &HealType::Cluster, HealRequestSource::Admin).await
}
pub(super) async fn pending(&self) -> Result<Vec<HealRequest>> {
let _guard = self.mutation.lock().await;
let disks = self.disks().await?;
let mut ids = HashSet::new();
for disk in &disks {
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
let entries = match EcstoreDiskAPI::list_dir(disk.as_ref(), "", RUSTFS_META_BUCKET, "", -1).await {
Ok(entries) => entries,
Err(DiskError::FileNotFound) => continue,
Err(error) => return Err(Error::Disk(error)),
};
for entry in entries {
let Some(task_id) = entry
.strip_prefix(ROOT_RECOVERY_PREFIX)
.and_then(|entry| entry.strip_suffix(".json"))
else {
continue;
};
let _ = intent_path(task_id)?;
ids.insert(task_id.to_string());
}
}
let mut requests = Vec::new();
for task_id in ids {
if let Some((_, bytes)) = Self::find(&disks, &task_id).await? {
requests.push(decode_intent(&task_id, &bytes)?.into_request());
}
}
requests.sort_by(|left, right| left.created_at.cmp(&right.created_at).then_with(|| left.id.cmp(&right.id)));
Ok(requests)
}
}
impl HealManager {
pub(super) async fn replay_root_heals(&self) -> Result<()> {
// Decode every record before admitting anything. These are already
// accepted responsibilities, so restore distinct IDs even when their
// paths overlap or the configured admission capacity has changed.
let requests = self.root_recovery.pending().await?;
let active = self.active_heals.lock().await;
let mut queue = self.heal_queue.lock().await;
let retrying = self.retrying_heals.lock().await;
for mut request in requests {
request.force_start = true;
let existing = active
.get(&request.id)
.map(|task| request_matches_task(&request, task))
.or_else(|| {
queue
.requests()
.find(|queued| queued.id == request.id)
.map(|queued| request_matches_request(&request, queued))
})
.or_else(|| {
retrying
.get(&request.id)
.map(|retrying| request_matches_request(&request, &retrying.request))
});
match existing {
Some(true) => continue,
Some(false) => return Err(Error::Other(format!("Conflicting root heal recovery task {}", request.id))),
None => {}
}
queue.push(request);
}
publish_heal_queue_length(&queue);
Ok(())
}
}
+40
View File
@@ -26,6 +26,7 @@ impl HealManager {
let retrying_heals = self.retrying_heals.clone();
let mrf_repair_notice_targets = self.mrf_repair_notice_targets.clone();
let replacement_recovery_anchors = self.replacement_recovery_anchors.clone();
let root_recovery = self.root_recovery.clone();
let cancel_token = self.cancel_token.clone();
let statistics = self.statistics.clone();
let storage = self.storage.clone();
@@ -59,6 +60,7 @@ impl HealManager {
retrying_heals: &retrying_heals,
mrf_repair_notice_targets: &mrf_repair_notice_targets,
replacement_recovery_anchors: &replacement_recovery_anchors,
root_recovery: &root_recovery,
config: &config,
statistics: &statistics,
storage: &storage,
@@ -78,6 +80,7 @@ impl HealManager {
retrying_heals: &retrying_heals,
mrf_repair_notice_targets: &mrf_repair_notice_targets,
replacement_recovery_anchors: &replacement_recovery_anchors,
root_recovery: &root_recovery,
config: &config,
statistics: &statistics,
storage: &storage,
@@ -106,6 +109,7 @@ impl HealManager {
retrying_heals,
mrf_repair_notice_targets,
replacement_recovery_anchors,
root_recovery,
config,
statistics,
storage,
@@ -117,6 +121,9 @@ impl HealManager {
let config = config.read().await;
let mainline_pressure = Self::mainline_throttle_active(&config, workload_provider);
let mut active_heals_guard = active_heals.lock().await;
if cancel_token.is_cancelled() {
return;
}
publish_active_heal_count(&active_heals_guard);
// Check if new heal tasks can be started
@@ -206,6 +213,7 @@ impl HealManager {
let replacement_recovery_anchors_clone = replacement_recovery_anchors.clone();
let statistics_clone = statistics.clone();
let notify_clone = notify.clone();
let root_recovery_clone = root_recovery.clone();
let manager_cancel_token = cancel_token.clone();
let task_type_label_for_spawn = task_type_label.clone();
let task_set_label_for_spawn = task_set_label.clone();
@@ -294,6 +302,38 @@ impl HealManager {
tests::pause_completed_retention_before_publish(&task_id, &completed_status).await;
let mut active_heals_guard = active_heals_clone.lock().await;
let owns_completion = active_heals_guard.contains_key(&task_id);
if owns_completion
&& result.is_ok()
&& let Err(error) = root_recovery_clone.remove(&task_id, &task.heal_type, task.source).await
{
// Keep the durable responsibility if retirement fails.
// Replaying a completed traversal is idempotent.
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_SCHEDULER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
state = "root_recovery_retirement_failed",
error = %error,
"Failed to retire root heal recovery record"
);
}
if owns_completion
&& result.is_err()
&& let Err(error) = root_recovery_clone.checkpoint_failed_execution(&task).await
{
warn!(
target: "rustfs::heal::manager",
event = EVENT_HEAL_SCHEDULER_STATE,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_MANAGER,
task_id,
state = "root_recovery_checkpoint_failed",
error = %error,
"Failed to checkpoint root heal recovery execution budget"
);
}
let cancelled_completion = if owns_completion {
false
} else {
+4 -8
View File
@@ -26,6 +26,7 @@ use rustfs_madmin::heal_commands::HealResultItem;
use std::sync::Mutex as StdMutex;
use tempfile::TempDir;
mod root_recovery;
mod running_mainline;
use super::super::{DiskOption, DiskStore, Endpoint, new_disk, storage_api::status::BucketInfo};
@@ -94,6 +95,7 @@ async fn process_manager_queue_once(manager: &HealManager) {
retrying_heals: &manager.retrying_heals,
mrf_repair_notice_targets: &manager.mrf_repair_notice_targets,
replacement_recovery_anchors: &manager.replacement_recovery_anchors,
root_recovery: &manager.root_recovery,
config: &manager.config,
statistics: &manager.statistics,
storage: &manager.storage,
@@ -555,10 +557,6 @@ async fn completed_retention_scheduler_preserves_progress_aliases_and_atomic_han
#[async_trait::async_trait]
impl HealStorageAPI for MockStorage {
async fn heal_pool_metadata(&self, _opts: &HealOpts) -> Result<Vec<HealResultItem>> {
Ok(Vec::new())
}
async fn get_object_meta(&self, _bucket: &str, _object: &str) -> Result<Option<HealObjectInfo>> {
Ok(None)
}
@@ -1873,7 +1871,7 @@ async fn test_submit_heal_request_returns_merged_for_duplicate() {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new(storage, None);
let mut request = HealRequest::new(
let request = HealRequest::new(
HealType::Object {
bucket: "bucket".to_string(),
object: "object".to_string(),
@@ -1890,7 +1888,6 @@ async fn test_submit_heal_request_returns_merged_for_duplicate() {
.expect("first request should be accepted"),
HealAdmissionResult::Accepted
);
request.id = uuid::Uuid::new_v4().to_string();
assert_eq!(
manager
.submit_heal_request(request)
@@ -3730,7 +3727,7 @@ async fn test_submit_heal_request_returns_merged_before_full_for_duplicate() {
}),
);
let mut request = HealRequest::new(
let request = HealRequest::new(
HealType::Object {
bucket: "bucket".to_string(),
object: "object".to_string(),
@@ -3747,7 +3744,6 @@ async fn test_submit_heal_request_returns_merged_before_full_for_duplicate() {
.expect("first request should be accepted"),
HealAdmissionResult::Accepted
);
request.id = uuid::Uuid::new_v4().to_string();
assert_eq!(
manager
.submit_heal_request(request)
@@ -0,0 +1,465 @@
// 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::super::root_recovery::RootHealRecovery;
use super::*;
use crate::heal::RUSTFS_META_BUCKET;
async fn recovery_disk() -> (TempDir, DiskStore) {
let temp = TempDir::new().expect("temporary root recovery disk");
let endpoint = Endpoint::try_from(temp.path().to_string_lossy().as_ref()).expect("disk endpoint");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("local recovery disk");
match disk.make_volume(RUSTFS_META_BUCKET).await {
Ok(()) | Err(DiskError::VolumeExists) => {}
Err(error) => panic!("metadata volume: {error}"),
}
(temp, disk)
}
fn recovery_manager(disks: Vec<DiskStore>) -> HealManager {
let mut manager = HealManager::new(
Arc::new(MockStorage),
Some(HealConfig {
enable_auto_heal: false,
..Default::default()
}),
);
manager.root_recovery = Arc::new(RootHealRecovery::with_disks(disks));
manager
}
fn root_request() -> HealRequest {
let mut request = HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::High);
request.source = HealRequestSource::Admin;
request
}
async fn active_root(manager: &HealManager, request: HealRequest) -> Arc<HealTask> {
let task = Arc::new(HealTask::from_request(request, manager.storage.clone()));
*task.status.write().await = HealTaskStatus::Running;
task.progress.write().await.update_object_progress(1, 1, 0, 0, 128);
manager.active_heals.lock().await.insert(task.id.clone(), task.clone());
task
}
#[tokio::test]
async fn root_recovery_shutdown_restart_replays_same_id_and_success_retires_intent() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let mut request = root_request();
request.options.recursive = true;
let task = active_root(&manager, request.clone()).await;
manager.stop().await.expect("durable shutdown handoff");
assert!(task.cancel_token.is_cancelled());
drop(manager);
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("replay durable root");
restarted.replay_root_heals().await.expect("replay is idempotent");
assert_eq!(restarted.get_queue_length().await, 1);
let restored = restarted
.heal_queue
.lock()
.await
.requests()
.next()
.cloned()
.expect("restored request");
assert_eq!(restored.id, request.id);
assert_eq!(restored.options, request.options);
assert_eq!(restored.priority, request.priority);
assert_eq!(restored.retry_attempts, request.retry_attempts);
assert_eq!(restored.created_at, request.created_at);
process_manager_queue_once(&restarted).await;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if matches!(restarted.get_task_status(&request.id).await, Ok(HealTaskStatus::Completed))
&& !restarted.active_heals.lock().await.contains_key(&request.id)
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("restored root executes successfully");
assert!(restarted.root_recovery.pending().await.expect("read completion").is_empty());
}
#[tokio::test]
async fn root_recovery_explicit_cancel_covers_active_queued_retrying_and_durable_only() {
for state in ["active", "queued", "retrying", "durable_only", "root_path"] {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let request = root_request();
manager.root_recovery.persist(&request).await.expect("durable responsibility");
match state {
"active" => {
active_root(&manager, request.clone()).await;
}
"queued" => {
manager.replay_root_heals().await.expect("queued recovery");
}
"retrying" => {
insert_retrying_request(&manager, request.clone()).await;
}
_ => {}
}
if state == "root_path" {
assert_eq!(manager.cancel_tasks_for_path("").await.expect("cancel durable root path"), 1);
} else {
manager.cancel_task(&request.id).await.expect("cancel root responsibility");
}
drop(manager);
let restarted = recovery_manager(vec![disk]);
restarted
.replay_root_heals()
.await
.expect("restart after explicit cancellation");
assert_eq!(restarted.get_queue_length().await, 0, "state={state}");
}
}
#[tokio::test]
async fn root_recovery_force_start_cancels_durable_only_responsibility() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let old = root_request();
manager
.root_recovery
.persist(&old)
.await
.expect("old terminal responsibility");
let mut new = root_request();
new.force_start = true;
assert_eq!(
manager
.submit_heal_request(new.clone())
.await
.expect("force start replacement"),
HealAdmissionResult::Accepted
);
assert!(manager.root_recovery.pending().await.expect("old owner retired").is_empty());
manager.stop().await.expect("persist new root only");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("restart replacement");
let ids = restarted
.heal_queue
.lock()
.await
.requests()
.map(|request| request.id.clone())
.collect::<Vec<_>>();
assert_eq!(ids, [new.id]);
}
#[tokio::test]
async fn root_recovery_force_start_replaces_fresh_queued_and_retrying_admin_roots() {
for retrying in [false, true] {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let old = root_request();
if retrying {
insert_retrying_request(&manager, old.clone()).await;
} else {
manager.submit_heal_request(old.clone()).await.expect("queue original root");
}
assert!(manager.root_recovery.pending().await.expect("not handed off yet").is_empty());
let mut new = root_request();
new.force_start = true;
assert_eq!(
manager.submit_heal_request(new.clone()).await.expect("force replacement"),
HealAdmissionResult::Accepted
);
manager.stop().await.expect("handoff only the new responsibility");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("restart after forceStart");
let ids = restarted
.heal_queue
.lock()
.await
.requests()
.map(|request| request.id.clone())
.collect::<Vec<_>>();
assert_eq!(ids, [new.id], "retrying={retrying}; old={}", old.id);
}
}
#[tokio::test]
async fn root_recovery_invalid_records_are_retained_without_partial_replay() {
for kind in ["truncated", "schema", "identity", "option", "no_lock"] {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let valid = root_request();
let invalid = root_request();
manager.root_recovery.persist(&valid).await.expect("valid root record");
manager
.root_recovery
.persist(&invalid)
.await
.expect("record before corruption");
let path = format!("root-heal-{}.json", invalid.id);
let original = disk.read_all(RUSTFS_META_BUCKET, &path).await.expect("read root record");
let mut value: serde_json::Value = serde_json::from_slice(&original).expect("record JSON");
match kind {
"schema" => value["schema"] = 2.into(),
"identity" => value["task_id"] = valid.id.clone().into(),
"option" => value["options"]["future_delete_mode"] = true.into(),
"no_lock" => value["options"]["no_lock"] = true.into(),
_ => {}
}
let bytes = if kind == "truncated" {
b"{".to_vec()
} else {
serde_json::to_vec(&value).expect("modified record")
};
disk.write_all(RUSTFS_META_BUCKET, &path, bytes.clone().into())
.await
.expect("inject bad record");
assert!(manager.replay_root_heals().await.is_err(), "kind={kind}");
assert_eq!(manager.get_queue_length().await, 0, "no partial admission for {kind}");
assert_eq!(
disk.read_all(RUSTFS_META_BUCKET, &path)
.await
.expect("bad record retained")
.as_ref(),
bytes
);
let mut forced = root_request();
forced.force_start = true;
assert!(
manager.submit_heal_request(forced).await.is_err(),
"forceStart must not discard unknown state"
);
}
}
#[tokio::test]
async fn root_recovery_failed_handoff_keeps_runtime_owner_and_does_not_try_another_disk() {
let (_temp, disk) = recovery_disk().await;
let (unavailable_temp, unavailable) = recovery_disk().await;
std::fs::remove_dir_all(unavailable_temp.path().join(RUSTFS_META_BUCKET)).expect("make owner volume unavailable");
let manager = recovery_manager(vec![unavailable, disk.clone()]);
let task = active_root(&manager, root_request()).await;
assert!(manager.stop().await.is_err());
assert!(
manager.cancel_task(&task.id).await.is_err(),
"missing owner cannot acknowledge cancellation"
);
assert!(!manager.cancel_token.is_cancelled());
assert!(!task.cancel_token.is_cancelled());
assert!(manager.active_heals.lock().await.contains_key(&task.id));
assert!(
RootHealRecovery::with_disks(vec![disk])
.pending()
.await
.expect("other disk remains empty")
.is_empty()
);
}
#[tokio::test]
async fn root_recovery_shutdown_fences_new_admission_and_preserves_later_cancellation() {
for operation_kind in ["submit", "force_start", "cancel"] {
let cancel = operation_kind == "cancel";
let (_temp, disk) = recovery_disk().await;
let manager = Arc::new(recovery_manager(vec![disk.clone()]));
let request = root_request();
active_root(&manager, request.clone()).await;
let queue = manager.heal_queue.lock().await;
let stopping = manager.clone();
let stop = tokio::spawn(async move { stopping.stop().await });
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if manager.active_heals.try_lock().is_err() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("shutdown owns active lock while waiting for queue");
let concurrent = manager.clone();
let operation = tokio::spawn(async move {
if cancel {
concurrent.cancel_task(&request.id).await
} else {
let mut new = root_request();
new.force_start = operation_kind == "force_start";
concurrent.submit_heal_request(new).await.map(|_| ())
}
});
drop(queue);
stop.await.expect("shutdown task").expect("durable shutdown");
let result = operation.await.expect("concurrent operation");
assert_eq!(result.is_ok(), cancel, "operation={operation_kind}");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("read final responsibility");
assert_eq!(restarted.get_queue_length().await, usize::from(!cancel));
}
}
#[tokio::test]
async fn root_recovery_exhausted_timeout_is_not_reset_by_restart() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let mut request = root_request();
request.options.timeout = Some(Duration::from_secs(10));
let task = active_root(&manager, request.clone()).await;
task.set_execution_elapsed_for_test(Duration::from_secs(11)).await;
manager.stop().await.expect("persist exhausted execution budget");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("restore bounded request");
assert_eq!(
restarted
.heal_queue
.lock()
.await
.requests()
.next()
.expect("restored root")
.options
.timeout,
Some(Duration::ZERO)
);
process_manager_queue_once(&restarted).await;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if matches!(restarted.get_task_status(&request.id).await, Ok(HealTaskStatus::Timeout)) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("exhausted request stays timed out");
restarted
.cancel_task(&request.id)
.await
.expect("timeout responsibility remains cancellable");
assert!(restarted.root_recovery.pending().await.expect("retired timeout").is_empty());
}
#[tokio::test]
async fn root_recovery_shutdown_preserves_remaining_execution_budget() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let mut request = root_request();
request.options.timeout = Some(Duration::from_secs(60));
let task = active_root(&manager, request).await;
task.set_execution_elapsed_for_test(Duration::from_secs(20)).await;
manager.stop().await.expect("handoff with consumed execution time");
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("restore remaining budget");
let queue = restarted.heal_queue.lock().await;
let remaining = queue
.requests()
.next()
.expect("restored root")
.options
.timeout
.expect("remaining timeout");
assert!(remaining <= Duration::from_secs(40), "elapsed execution must not be refunded");
assert!(
remaining >= Duration::from_secs(30),
"shutdown fixture should retain most of its remaining budget"
);
}
#[tokio::test]
async fn root_recovery_force_start_after_shutdown_does_not_retire_original_owner() {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let old = root_request();
active_root(&manager, old.clone()).await;
manager.stop().await.expect("handoff original root");
let mut new = root_request();
new.force_start = true;
assert!(manager.submit_heal_request(new).await.is_err());
let restarted = recovery_manager(vec![disk]);
restarted.replay_root_heals().await.expect("original responsibility remains");
let ids = restarted
.heal_queue
.lock()
.await
.requests()
.map(|request| request.id.clone())
.collect::<Vec<_>>();
assert_eq!(ids, [old.id]);
}
#[tokio::test]
async fn root_recovery_terminal_timeout_updates_only_existing_journal_before_second_restart() {
for durable in [false, true] {
let (_temp, disk) = recovery_disk().await;
let manager = recovery_manager(vec![disk.clone()]);
let mut request = root_request();
request.options.timeout = Some(Duration::from_nanos(1));
if durable {
manager
.root_recovery
.persist(&request)
.await
.expect("persist nonzero execution budget");
manager.replay_root_heals().await.expect("first restart");
} else {
manager
.submit_heal_request(request.clone())
.await
.expect("first root execution");
}
process_manager_queue_once(&manager).await;
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if matches!(manager.get_task_status(&request.id).await, Ok(HealTaskStatus::Timeout))
&& !manager.active_heals.lock().await.contains_key(&request.id)
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("real execution exhausts a nonzero budget");
assert!(!manager.active_heals.lock().await.contains_key(&request.id));
let restarted = recovery_manager(vec![disk]);
restarted
.replay_root_heals()
.await
.expect("second restart after terminal timeout");
let queue = restarted.heal_queue.lock().await;
if durable {
assert_eq!(
queue
.requests()
.next()
.expect("remaining timeout responsibility")
.options
.timeout,
Some(Duration::ZERO)
);
} else {
assert!(queue.is_empty(), "terminal failure must not create a new durable responsibility");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1140,6 +1140,54 @@ mod tests {
);
}
#[tokio::test]
async fn committed_snapshot_writer_payload_failure_does_not_publish_manifest() {
let root = TempDir::new().expect("test directory");
let store = disk(&root, "disk").await;
let owner = Uuid::new_v4();
let old = payload("old");
let next = payload("next");
commit(&store, 0, owner, 1, &old).await;
std::fs::create_dir(root.path().join("disk").join(RUSTFS_META_BUCKET).join(PAYLOAD_PATHS[1]))
.expect("payload path blocks successor staging");
let result = publish_committed_snapshot(std::slice::from_ref(&store), owner, 2, &next, 4096).await;
assert!(
matches!(result, Err(SnapshotError::Disk(_) | SnapshotError::Read(_))),
"payload failure must be visible before manifest publication: {result:?}"
);
let reopened = disk(&root, "disk").await;
let recovered = read_committed(std::slice::from_ref(&reopened), 4096)
.await
.expect("read previous committed snapshot")
.expect("old anchor remains committed");
assert_eq!(recovered.sequence(), 1);
assert_eq!(recovered.slot(), 0);
assert_eq!(recovered.payload(), old.as_slice());
assert_eq!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0])
.await
.expect("old payload retained")
.as_ref(),
old.as_slice()
);
assert_eq!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[0])
.await
.expect("old manifest retained")
.as_ref(),
manifest(owner, 1, &old).as_slice()
);
assert!(
matches!(
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[1]).await,
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound)
),
"successor manifest must not be published when payload staging fails"
);
}
#[tokio::test]
async fn committed_snapshot_writer_capacity_failure_preserves_previous_anchor() {
let root = TempDir::new().expect("test directory");
-33
View File
@@ -436,23 +436,6 @@ pub trait HealStorageAPI: Send + Sync {
Err(Error::other("target-scoped replacement format is unsupported"))
}
/// Heal each pool metadata replica owned by the selected live scope.
///
/// A successful result requires every applicable owner to finish; an empty
/// result is valid only for a known scope with no metadata replica. Backends
/// without pool metadata must explicitly implement that empty result.
async fn heal_pool_metadata(&self, _opts: &HealOpts) -> Result<Vec<HealResultItem>> {
Err(Error::other("pool metadata healing is unsupported"))
}
/// Whether the selected replacement set owns the pool metadata replica.
///
/// Only a topology-aware backend may exempt a valid non-owner set. The
/// conservative default requires the existing repair and readback checks.
async fn replacement_pool_metadata_applies(&self, _opts: &HealOpts) -> Result<bool> {
Ok(true)
}
/// Read target-specific physical evidence for one replacement version.
///
/// This is only used by automatic replacement healing after the normal
@@ -1285,22 +1268,6 @@ impl HealStorageAPI for ECStoreHealStorage {
.map_err(Error::Storage)
}
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
self.ecstore.heal_pool_metadata(opts).await.map_err(Error::Storage)
}
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
let pool_index = opts
.pool
.ok_or_else(|| Error::other("replacement pool metadata is missing pool scope"))?;
let set_index = opts
.set
.ok_or_else(|| Error::other("replacement pool metadata is missing set scope"))?;
self.ecstore
.replacement_pool_metadata_applies(pool_index, set_index)
.map_err(Error::Storage)
}
async fn replacement_targets_have_version(
&self,
bucket: &str,
+6 -1
View File
@@ -45,7 +45,7 @@ use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, RUSTFS_META_BUCKET};
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, POOL_META_NAME, RUSTFS_META_BUCKET};
#[cfg(test)]
pub(crate) struct OutcomeFinishTestHook {
@@ -513,6 +513,11 @@ impl HealTask {
}
}
#[cfg(test)]
pub(crate) async fn set_execution_elapsed_for_test(&self, elapsed: Duration) {
*self.task_start_instant.write().await = Some(Instant::now() - elapsed);
}
pub(crate) async fn retry_request_with_remaining_timeout(&self) -> Result<HealRequest> {
let mut request = self.retry_request();
if self.options.timeout.is_some() {
+79 -17
View File
@@ -328,23 +328,6 @@ impl HealTask {
}
}
if !self.options.dry_run {
let metadata_opts = HealOpts {
dry_run: self.options.dry_run,
recreate: self.options.recreate_missing,
scan_mode: self.options.scan_mode,
pool: self.options.pool_index,
set: self.options.set_index,
..Default::default()
};
for result in self
.await_with_control(self.storage.heal_pool_metadata(&metadata_opts))
.await?
{
self.record_result_item(result).await;
}
}
if failed > 0 {
let failure = BatchHealFailure {
scope: "cluster".to_string(),
@@ -357,9 +340,88 @@ impl HealTask {
return Err(self.record_batch_failure(failure).await);
}
if self.options.recreate_missing && !self.options.dry_run {
self.heal_cluster_pool_metadata().await?;
}
Ok(())
}
async fn heal_cluster_pool_metadata(&self) -> Result<()> {
let heal_opts = HealOpts {
recursive: false,
dry_run: self.options.dry_run,
remove: false,
recreate: self.options.recreate_missing,
scan_mode: self.options.scan_mode,
update_parity: self.options.update_parity,
no_lock: self.options.no_lock,
read_repair: false,
pool: self.options.pool_index,
set: self.options.set_index,
};
let heal_result = self
.await_with_control(self.storage.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &heal_opts))
.await;
match heal_result {
Ok((result, None)) => {
debug!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
drives_healed = result.drives_healed(),
drives_total = result.drives_reported(),
result = "pool_metadata_ok",
"Heal cluster pool metadata repaired"
);
self.record_result_item(result).await;
Ok(())
}
Ok((result, Some(err))) => {
self.record_result_item(result).await;
warn!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
result = "pool_metadata_failed",
error = %err,
"Heal cluster pool metadata failed"
);
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal cluster pool metadata: {err}"),
})
}
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
Err(err) => {
warn!(
target: "rustfs::heal::task",
event = EVENT_HEAL_BUCKET_RESULT,
component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_TASK,
task_id = %self.id,
bucket = RUSTFS_META_BUCKET,
object = POOL_META_NAME,
result = "pool_metadata_failed",
error = %err,
"Heal cluster pool metadata failed"
);
Err(Error::TaskExecutionFailed {
message: format!("Failed to heal cluster pool metadata: {err}"),
})
}
}
}
pub(super) async fn heal_prefix(&self, bucket: &str, prefix: &str) -> Result<()> {
debug!(
target: "rustfs::heal::task",
+6 -344
View File
@@ -14,7 +14,6 @@
use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
use super::*;
use crate::heal::POOL_META_NAME;
use crate::heal::storage::HealStorageObjectResult;
mod deferred_retry;
@@ -43,7 +42,6 @@ mod canonical_outcome {
#[tokio::test(start_paused = true)]
async fn cluster_retries_only_the_failed_listing_page() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
recoverable_second_page_failures: Mutex::new(Some(1)),
..Default::default()
});
@@ -1327,7 +1325,6 @@ struct MockStorage {
retry_test_events: Mutex<Vec<String>>,
listed: Mutex<bool>,
list_each_bucket: bool,
pool_metadata_required: bool,
fail_second_listing_page: bool,
recoverable_second_page_failures: Mutex<Option<usize>>,
listing_tokens: Mutex<Vec<Option<String>>>,
@@ -1342,10 +1339,6 @@ struct MockStorage {
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
heal_object_receipts: Mutex<HashMap<String, VecDeque<HealObjectReceipt>>>,
bucket_incarnation_id: Mutex<Option<Uuid>>,
bucket_incarnation_calls: AtomicU64,
bucket_incarnation_error: Mutex<Option<Error>>,
block_bucket_incarnation: bool,
bucket_incarnation_started: tokio::sync::Notify,
bucket_incarnation_after_object_heal: Mutex<Option<Uuid>>,
bucket_incarnation_unavailable: Mutex<bool>,
format_no_heal_required: Mutex<bool>,
@@ -1489,7 +1482,7 @@ async fn object_heal_records_matching_positive_storage_receipt() {
});
let task = HealTask::from_request(
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
storage.clone(),
storage,
);
task.execute().await.expect("mock object heal should complete");
@@ -1502,114 +1495,6 @@ async fn object_heal_records_matching_positive_storage_receipt() {
assert_eq!(object.identity.version_id.as_deref(), Some("version-a"));
assert!(object.identity.bucket_incarnation_id.is_some());
assert_eq!(object.disposition, HealObjectDisposition::Repaired);
assert_eq!(
storage.bucket_incarnation_calls.load(Ordering::Relaxed),
1,
"latch the owner exactly once before repair"
);
}
#[tokio::test]
async fn object_heal_owner_lookup_failure_preserves_unverified_repair() {
let storage = Arc::new(MockStorage {
bucket_incarnation_error: Mutex::new(Some(Error::other("owner metadata unavailable"))),
heal_object_receipts: Mutex::new(HashMap::from([(
"object-a".to_string(),
VecDeque::from([object_receipt(
"object-a",
None,
HealObjectDisposition::Repaired,
Uuid::new_v4(),
)]),
)])),
..Default::default()
});
let task = HealTask::from_request(HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None), storage.clone());
task.execute().await.expect("missing receipt owner must not prevent repair");
assert_eq!(storage.heal_object_calls.lock().expect("heal calls").as_slice(), ["object-a"]);
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.healed, 0);
assert_eq!(outcome.counters.unknown, 1);
assert_eq!(
outcome.objects.front().expect("unverified outcome").disposition,
HealObjectDisposition::Unknown
);
}
#[tokio::test]
async fn object_heal_dry_run_skips_owner_lookup_and_positive_receipts() {
let storage = Arc::new(MockStorage {
bucket_incarnation_error: Mutex::new(Some(Error::other("dry-run must not query the receipt owner"))),
heal_object_receipts: Mutex::new(HashMap::from([(
"object-a".to_string(),
VecDeque::from([object_receipt(
"object-a",
None,
HealObjectDisposition::Repaired,
Uuid::new_v4(),
)]),
)])),
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
request.options.dry_run = true;
let task = HealTask::from_request(request, storage.clone());
task.execute().await.expect("dry-run should complete without owner metadata");
assert!(storage.object_heal_opts.lock().expect("heal options")[0].dry_run);
assert_eq!(storage.bucket_incarnation_calls.load(Ordering::Relaxed), 0);
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.healed, 0);
assert_eq!(outcome.counters.unknown, 0);
assert_eq!(outcome.counters.skipped, 1);
assert_eq!(
outcome.objects.front().expect("dry-run outcome").disposition,
HealObjectDisposition::DryRunObserved
);
}
#[tokio::test(start_paused = true)]
async fn object_heal_owner_lookup_obeys_task_timeout() {
let storage = Arc::new(MockStorage {
block_bucket_incarnation: true,
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
request.options.timeout = Some(Duration::from_secs(5));
let task = HealTask::from_request(request, storage.clone());
let result = tokio::time::timeout(Duration::from_secs(60), task.execute())
.await
.expect("owner lookup must honor the task deadline");
assert!(matches!(result, Err(Error::TaskTimeout)));
assert!(storage.heal_object_calls.lock().expect("heal calls").is_empty());
}
#[tokio::test]
async fn object_heal_owner_lookup_obeys_cancellation() {
let storage = Arc::new(MockStorage {
block_bucket_incarnation: true,
..Default::default()
});
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
request.options.timeout = None;
let task = HealTask::from_request(request, storage.clone());
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(task.execute(), async {
storage.bucket_incarnation_started.notified().await;
task.cancel().await.expect("cancel pending owner lookup");
})
})
.await
.expect("cancellation must interrupt owner lookup");
assert!(matches!(result, Err(Error::TaskCancelled)));
assert!(storage.heal_object_calls.lock().expect("heal calls").is_empty());
}
#[tokio::test]
@@ -1947,34 +1832,6 @@ impl HealStorageAPI for MockStorage {
.collect())
}
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
if !self.pool_metadata_required {
return Ok(Vec::new());
}
let scopes = self.erasure_set_scopes.lock().expect("metadata scopes").clone();
let scopes = if scopes.is_empty() {
vec![(opts.pool.unwrap_or(0), opts.set.unwrap_or(0))]
} else {
scopes
};
let mut results = Vec::new();
for (pool, set) in scopes {
let scoped_opts = HealOpts {
pool: Some(pool),
set: Some(set),
..*opts
};
let (result, error) = self
.heal_object(RUSTFS_META_BUCKET, crate::heal::POOL_META_NAME, None, &scoped_opts)
.await?;
if let Some(error) = error {
return Err(error);
}
results.push(result);
}
Ok(results)
}
async fn object_exists(&self, _bucket: &str, object: &str) -> Result<bool> {
if let Some(result) = self.object_exists_by_name.lock().unwrap().get(object).copied() {
return match result {
@@ -1987,14 +1844,6 @@ impl HealStorageAPI for MockStorage {
}
async fn bucket_incarnation_id(&self, _bucket: &str) -> Result<Option<Uuid>> {
self.bucket_incarnation_calls.fetch_add(1, Ordering::Relaxed);
self.bucket_incarnation_started.notify_one();
if self.block_bucket_incarnation {
std::future::pending::<()>().await;
}
if let Some(error) = self.bucket_incarnation_error.lock().expect("owner lookup error").take() {
return Err(error);
}
if *self.bucket_incarnation_unavailable.lock().unwrap() {
return Err(Error::Other("bucket incarnation unavailable".to_string()));
}
@@ -2715,187 +2564,9 @@ async fn test_recursive_bucket_heal_treats_missing_continuation_token_as_end() {
);
}
#[tokio::test]
async fn root_heal_restores_pool_metadata_without_user_buckets() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
..Default::default()
});
assert!(storage.pool_metadata_required);
let task = HealTask::from_request(
HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal),
storage.clone(),
);
task.execute().await.expect("root heal should restore required pool metadata");
assert_eq!(
storage.heal_object_calls.lock().expect("heal calls").as_slice(),
[crate::heal::POOL_META_NAME]
);
assert!(matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn root_heal_pool_metadata_cannot_hide_a_later_owner_failure() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
erasure_set_scopes: Mutex::new(vec![(0, 0), (1, 1)]),
..Default::default()
});
storage.heal_object_outcomes.lock().expect("metadata outcomes").insert(
crate::heal::POOL_META_NAME.to_string(),
VecDeque::from([
MockHealObjectOutcome::UnavailableDrive(DriveState::Ok),
MockHealObjectOutcome::OkWithReadQuorum,
]),
);
let task = HealTask::from_request(
HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal),
storage.clone(),
);
let error = task
.execute()
.await
.expect_err("one healthy owner cannot satisfy another owner's recovery");
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
{
let opts = storage.object_heal_opts.lock().expect("owner options");
assert_eq!(
opts.iter().map(|opts| (opts.pool, opts.set)).collect::<Vec<_>>(),
vec![(Some(0), Some(0)), (Some(1), Some(1))]
);
}
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn root_heal_pool_metadata_does_not_inherit_remove_or_no_lock() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
..Default::default()
});
let task = HealTask::from_request(
HealRequest::new(
HealType::Cluster,
HealOptions {
remove_corrupted: true,
no_lock: true,
..Default::default()
},
HealPriority::Normal,
),
storage.clone(),
);
task.execute()
.await
.expect("metadata repair should retain its write fence and reject destructive options");
let opts = storage.object_heal_opts.lock().expect("metadata options");
assert_eq!(opts.len(), 1, "an empty user namespace must still inspect metadata");
assert!(!opts[0].dry_run);
assert!(!opts[0].remove);
assert!(!opts[0].no_lock);
}
#[tokio::test]
async fn root_heal_pool_metadata_failure_does_not_prevent_user_repairs() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
storage.heal_object_outcomes.lock().expect("metadata outcome").insert(
crate::heal::POOL_META_NAME.to_string(),
VecDeque::from([MockHealObjectOutcome::OkWithReadQuorum]),
);
let task = HealTask::from_request(
HealRequest::new(
HealType::Cluster,
HealOptions {
recursive: true,
..Default::default()
},
HealPriority::Normal,
),
storage.clone(),
);
let error = task
.execute()
.await
.expect_err("unrecovered metadata must still fail root completion");
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
assert_eq!(
storage.heal_object_calls.lock().expect("heal calls").as_slice(),
["object-a", "object-b", crate::heal::POOL_META_NAME]
);
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn root_heal_pool_metadata_preserves_typed_quorum_failure() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
..Default::default()
});
storage.heal_object_outcomes.lock().expect("metadata outcome").insert(
crate::heal::POOL_META_NAME.to_string(),
VecDeque::from([MockHealObjectOutcome::OkWithReadQuorum]),
);
let task = HealTask::from_request(HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal), storage);
let error = task
.execute()
.await
.expect_err("metadata quorum failure must prevent root completion");
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test(start_paused = true)]
async fn root_heal_pool_metadata_obeys_task_timeout() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
listed_buckets: Mutex::new(Some(Vec::new())),
retry_test_delays: HashMap::from([(crate::heal::POOL_META_NAME.to_string(), Duration::from_secs(10))]),
..Default::default()
});
let task = HealTask::from_request(
HealRequest::new(
HealType::Cluster,
HealOptions {
timeout: Some(Duration::from_millis(10)),
..Default::default()
},
HealPriority::Normal,
),
storage,
);
let error = task
.execute()
.await
.expect_err("metadata work must stay inside the root task budget");
assert!(matches!(error, Error::TaskTimeout));
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
}
#[tokio::test]
async fn test_cluster_heal_visits_bucket_objects() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
let storage = Arc::new(MockStorage::default());
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
@@ -2918,10 +2589,7 @@ async fn test_cluster_heal_visits_bucket_objects() {
#[tokio::test]
async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
let storage = Arc::new(MockStorage::default());
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
@@ -2948,10 +2616,7 @@ async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
#[tokio::test]
async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
let storage = Arc::new(MockStorage::default());
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
POOL_META_NAME.to_string(),
VecDeque::from([MockHealObjectOutcome::ErrOther("pool metadata missing")]),
@@ -2973,7 +2638,7 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
.await
.expect_err("cluster recreate heal must not hide pool metadata failure");
assert!(matches!(err, Error::Other(message) if message == "pool metadata missing"));
assert!(matches!(err, Error::TaskExecutionFailed { .. }));
assert_eq!(
storage.heal_object_calls.lock().expect("object calls").as_slice(),
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
@@ -2982,10 +2647,7 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
#[tokio::test]
async fn cluster_dry_run_does_not_heal_pool_metadata() {
let storage = Arc::new(MockStorage {
pool_metadata_required: true,
..Default::default()
});
let storage = Arc::new(MockStorage::default());
let request = HealRequest::new(
HealType::Cluster,
HealOptions {
+5 -1
View File
@@ -61,10 +61,14 @@ pub fn create_ahm_services_cancel_token() -> CancellationToken {
}
/// Shutdown all heal services gracefully
pub fn shutdown_ahm_services() {
pub async fn shutdown_ahm_services() -> Result<()> {
if let Some(manager) = get_heal_manager() {
manager.stop().await?;
}
if let Some(cancel_token) = GLOBAL_AHM_SERVICES_CANCEL_TOKEN.get() {
cancel_token.cancel();
}
Ok(())
}
struct HealRuntime {
+118 -29
View File
@@ -22,7 +22,7 @@
//! Under `cargo nextest` each test runs in its own process, which keeps the
//! process-global MRF channel singleton safe.
use rustfs_common::mrf_channel::{self, MrfKind};
use rustfs_common::mrf_channel::{self, MrfIngressResult, MrfKind, MrfScope};
use rustfs_heal::heal::{
manager::{HealConfig, HealManager},
mrf_queue,
@@ -237,6 +237,33 @@ fn journal_matches_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, exp
.all(|path| std::fs::read(path.join(META_BUCKET).join(relative_path)).is_ok_and(|actual| actual == expected))
}
fn journal_contains_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, needle: &[u8]) -> bool {
disk_paths.iter().all(|path| {
std::fs::read(path.join(META_BUCKET).join(relative_path))
.is_ok_and(|actual| actual.windows(needle.len()).any(|window| window == needle))
})
}
fn journal_contains_on_any_disk(disk_paths: &[PathBuf], relative_path: &str, needle: &[u8]) -> bool {
disk_paths.iter().any(|path| {
std::fs::read(path.join(META_BUCKET).join(relative_path))
.is_ok_and(|actual| actual.windows(needle.len()).any(|window| window == needle))
})
}
fn committed_payload_contains_on_all_disks(disk_paths: &[PathBuf], needles: &[&[u8]]) -> bool {
disk_paths.iter().all(|path| {
let root = path.join(META_BUCKET);
COMMITTED_PAYLOAD_RELS.into_iter().any(|payload_rel| {
std::fs::read(root.join(payload_rel)).is_ok_and(|payload| {
needles
.iter()
.all(|needle| payload.windows(needle.len()).any(|window| window == *needle))
})
})
})
}
fn committed_checkpoint_matches_on_all_disks(disk_paths: &[PathBuf], sequence: u64, expected_payload: &[u8]) -> bool {
disk_paths.iter().all(|path| {
let root = path.join(META_BUCKET);
@@ -615,6 +642,73 @@ async fn journal_replay_retains_file_when_manager_is_full() {
);
}
/// Rollback mirrors are for v1 readers only: the committed and scoped
/// snapshots remain authoritative, while the legacy journal omits scoped-only
/// records that an older binary cannot represent safely.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn rollback_legacy_mirror_persists_only_v1_compatible_records() {
let (disk_paths, storage) = heal_env().await;
register_local_disks(&disk_paths, "mrf-rollback-mirror-test").await;
let manager = Arc::new(HealManager::new(
storage,
Some(HealConfig {
queue_size: 0,
heal_interval: Duration::from_secs(3600),
enable_auto_heal: false,
..Default::default()
}),
));
mrf_queue::spawn_mrf_consumer(manager.clone());
let scoped_only = b"rollback-scoped-only-object";
let v1_compatible = b"rollback-v1-compatible-object";
assert_eq!(
mrf_channel::try_send_mrf_intent_typed(
MrfKind::PartialWrite,
"rollback-bucket",
std::str::from_utf8(scoped_only).expect("fixture object is UTF-8"),
None,
Some(MrfScope {
pool_index: 3,
set_index: 7,
}),
),
MrfIngressResult::Enqueued,
"scoped-only intent should be accepted by the live consumer"
);
assert_eq!(
mrf_channel::try_send_mrf_intent_typed(
MrfKind::PartialWrite,
"rollback-bucket",
std::str::from_utf8(v1_compatible).expect("fixture object is UTF-8"),
None,
None,
),
MrfIngressResult::Enqueued,
"v1-compatible intent should be accepted by the live consumer"
);
let flushed = wait_until(Duration::from_secs(10), || async {
committed_payload_contains_on_all_disks(&disk_paths, &[scoped_only, v1_compatible])
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, scoped_only)
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, v1_compatible)
&& journal_contains_on_all_disks(&disk_paths, JOURNAL_REL, v1_compatible)
&& !journal_contains_on_any_disk(&disk_paths, JOURNAL_REL, scoped_only)
})
.await;
assert!(
flushed,
"runtime flush must persist rollback-safe mirrors without leaking scoped-only records into the legacy journal"
);
assert_eq!(
manager.operations_snapshot().await.queued_by_source.mrf,
0,
"zero-capacity manager keeps both intents in the MRF runtime so the persisted snapshot is observable"
);
}
#[test]
fn mrf_journal_child_process_fixture() {
let Ok(root) = std::env::var("RUSTFS_MRF_REPLAY_CHILD_ROOT") else {
@@ -666,8 +760,7 @@ fn mrf_successor_flush_child_process_fixture() {
}),
));
mrf_queue::spawn_mrf_consumer(manager.clone());
let mut expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "successor-bucket", "first-object", None, 0));
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
let flushed = wait_until(Duration::from_secs(10), || async {
manager.operations_snapshot().await.queued_by_source.mrf == 1
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
@@ -715,8 +808,7 @@ fn mrf_successor_flush_waiting_child_process_fixture() {
}),
));
mrf_queue::spawn_mrf_consumer(manager.clone());
let mut expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "service-kill-bucket", "first-object", None, 0));
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
let flushed = wait_until(Duration::from_secs(10), || async {
manager.operations_snapshot().await.queued_by_source.mrf == 1
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
@@ -757,8 +849,7 @@ fn mrf_authoritative_fsync_waiting_child_process_fixture() {
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &startup);
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &startup);
let mut successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
successor.extend(journal_record(1, "fsync-kill-bucket", "first-object", None, 0));
let successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
write_journal_path_to_disks_synced(&disk_paths, SCOPED_JOURNAL_REL, &successor);
assert!(
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &successor)
@@ -815,8 +906,9 @@ async fn journal_replay_retains_child_process_anchor_when_manager_is_full() {
);
}
/// A successor flush must preserve both pending work and accepted work whose
/// repair has not been proven when the process restarts.
/// If a process crashes after flushing a smaller successor snapshot but before
/// deleting the startup anchor, the restarted process must replay the
/// successor tail rather than losing it or merging it with stale records.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn journal_replay_survives_successor_flush_before_delete() {
@@ -831,8 +923,7 @@ async fn journal_replay_survives_successor_flush_before_delete() {
assert_eq!(status.code(), Some(78), "child process did not reach the successor flush boundary");
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
let mut expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "successor-bucket", "first-object", None, 0));
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
assert!(
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
"restarted process must see the pending successor snapshot"
@@ -840,11 +931,11 @@ async fn journal_replay_survives_successor_flush_before_delete() {
let restarted = make_manager(storage);
let replayed = mrf_queue::replay_journal_once(&restarted).await;
assert_eq!(replayed, 2, "restart must replay both the admitted and pending responsibilities");
assert_eq!(replayed, 1, "restart after successor flush must replay only the still-pending tail");
assert_eq!(
restarted.operations_snapshot().await.queued_by_source.mrf,
2,
"both unproven successor responsibilities must be accepted after restart"
1,
"the successor tail must be accepted after restart"
);
assert!(
disk_paths.iter().all(|path| {
@@ -856,8 +947,8 @@ async fn journal_replay_survives_successor_flush_before_delete() {
}
/// A service-style hard kill after successor flush must be equivalent to a
/// crash at the flush-before-delete boundary: restart must recover every
/// unproven responsibility from the successor snapshot.
/// crash at the flush-before-delete boundary: restart may replay the smaller
/// successor snapshot, but must not lose or merge stale startup records.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[cfg(unix)]
@@ -885,8 +976,7 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
let mut expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "service-kill-bucket", "first-object", None, 0));
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
assert!(
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
"restarted process must see the successor snapshot produced before the kill"
@@ -894,11 +984,11 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
let restarted = make_manager(storage);
let replayed = mrf_queue::replay_journal_once(&restarted).await;
assert_eq!(replayed, 2, "service-kill restart must preserve every unproven responsibility");
assert_eq!(replayed, 1, "restart after service kill must replay only the still-pending tail");
assert_eq!(
restarted.operations_snapshot().await.queued_by_source.mrf,
2,
"both unproven responsibilities must be accepted after service kill restart"
1,
"the successor tail must be accepted after service kill restart"
);
assert!(
disk_paths.iter().all(|path| {
@@ -910,9 +1000,9 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
}
/// A hard kill between the authoritative successor fsync and the legacy mirror
/// rewrite must prefer the canonical successor over the stale legacy startup
/// epoch while retaining every unproven responsibility. New readers must not
/// merge epochs, while the old mirror remains crash-visible.
/// rewrite must prefer the canonical successor tail over the stale legacy
/// startup epoch. This models the mixed-version boundary conservatively: new
/// readers must not merge epochs, while the old mirror remains crash-visible.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
#[cfg(unix)]
@@ -940,8 +1030,7 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
let mut expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
expected_successor.extend(journal_record(1, "fsync-kill-bucket", "first-object", None, 0));
let expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
let stale_startup = {
let mut startup = journal_record(1, "fsync-kill-bucket", "first-object", None, 0);
startup.extend(journal_record(1, "fsync-kill-bucket", "second-object", None, 0));
@@ -958,11 +1047,11 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
let restarted = make_manager(storage);
let replayed = mrf_queue::replay_journal_once(&restarted).await;
assert_eq!(replayed, 2, "new reader must recover every responsibility in the authoritative successor");
assert_eq!(replayed, 1, "new reader must replay only the authoritative successor tail");
assert_eq!(
restarted.operations_snapshot().await.queued_by_source.mrf,
2,
"both responsibilities must be accepted after the fsync-boundary restart"
1,
"the successor tail must be accepted after the fsync-boundary restart"
);
assert!(
disk_paths.iter().all(|path| {
+3 -1
View File
@@ -819,7 +819,9 @@ impl KmsBackend for AwsKmsBackend {
.with_rotate(true)
.with_enable_disable(true)
.with_schedule_deletion(true)
.with_versioning(true)
// AWS KMS exposes rotation state but does not enumerate key
// versions through this backend's API contract.
.with_versioning(false)
.with_physical_delete(false)
.with_production_supported(true)
}
+10 -107
View File
@@ -2098,7 +2098,11 @@ impl KmsBackend for LocalKmsBackend {
let _write_guard = self.client.lock_key_for_write(key_id).await;
// First, load the key from disk to get the master key
let mut master_key = self.client.load_master_key(key_id).await?;
let mut master_key = self
.client
.load_master_key(key_id)
.await
.map_err(|_| KmsError::key_not_found(format!("Key {key_id} not found")))?;
let (deletion_date_str, deletion_date_dt) = if request.force_immediate.unwrap_or(false) {
// Tombstone first: mark the record Deleted before removing the
@@ -2201,7 +2205,11 @@ impl KmsBackend for LocalKmsBackend {
let _write_guard = self.client.lock_key_for_write(key_id).await;
// Load the key from disk to get the master key
let mut master_key = self.client.load_master_key(key_id).await?;
let mut master_key = self
.client
.load_master_key(key_id)
.await
.map_err(|_| KmsError::key_not_found(format!("Key {key_id} not found")))?;
if master_key.status != KeyStatus::PendingDeletion {
return Err(KmsError::invalid_key_state(format!("Key {key_id} is not pending deletion")));
@@ -3030,111 +3038,6 @@ mod tests {
assert!(matches!(error, KmsError::InvalidKey { .. }));
}
#[tokio::test]
async fn delete_key_preserves_directory_io_error() {
let (client, temp_dir) = create_dev_mode_client().await;
client.create_key("existing-key", "AES_256", None).await.expect("create key");
let backend = LocalKmsBackend { client };
let offline_dir = TempDir::new().expect("create offline directory");
let offline_key_dir = offline_dir.path().join("keys");
fs::rename(temp_dir.path(), &offline_key_dir)
.await
.expect("move key directory offline");
fs::write(temp_dir.path(), b"not a directory")
.await
.expect("replace key directory with a file");
let error = backend
.delete_key(DeleteKeyRequest {
key_id: "existing-key".to_string(),
..Default::default()
})
.await
.expect_err("unreadable storage must prevent scheduling deletion");
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
fs::rename(&offline_key_dir, temp_dir.path())
.await
.expect("restore key directory");
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
let key = backend
.client
.load_master_key("existing-key")
.await
.expect("read retained key");
assert_eq!(key.status, KeyStatus::Active, "failed deletion must not mutate key state");
}
#[tokio::test]
async fn cancel_key_deletion_preserves_directory_io_error() {
let (client, temp_dir) = create_dev_mode_client().await;
client.create_key("existing-key", "AES_256", None).await.expect("create key");
let backend = LocalKmsBackend { client };
backend
.delete_key(DeleteKeyRequest {
key_id: "existing-key".to_string(),
..Default::default()
})
.await
.expect("schedule key deletion");
let offline_dir = TempDir::new().expect("create offline directory");
let offline_key_dir = offline_dir.path().join("keys");
fs::rename(temp_dir.path(), &offline_key_dir)
.await
.expect("move key directory offline");
fs::write(temp_dir.path(), b"not a directory")
.await
.expect("replace key directory with a file");
let error = backend
.cancel_key_deletion(CancelKeyDeletionRequest {
key_id: "existing-key".to_string(),
})
.await
.expect_err("unreadable storage must prevent cancelling deletion");
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
fs::rename(&offline_key_dir, temp_dir.path())
.await
.expect("restore key directory");
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
let key = backend
.client
.load_master_key("existing-key")
.await
.expect("read retained key");
assert_eq!(
key.status,
KeyStatus::PendingDeletion,
"failed cancellation must retain the deletion state"
);
}
#[tokio::test]
async fn test_load_master_key_directory_replaced_by_file_is_io_error() {
let (client, temp_dir) = create_dev_mode_client().await;
client.create_key("existing-key", "AES_256", None).await.expect("create key");
let offline_dir = TempDir::new().expect("create offline directory");
let offline_key_dir = offline_dir.path().join("keys");
fs::rename(temp_dir.path(), &offline_key_dir)
.await
.expect("move key directory offline");
fs::write(temp_dir.path(), b"not a directory")
.await
.expect("replace key directory with a file");
let error = client
.load_master_key("existing-key")
.await
.expect_err("a file in place of the key directory must fail");
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
fs::rename(&offline_key_dir, temp_dir.path())
.await
.expect("restore key directory");
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
}
#[tokio::test]
async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() {
let (client, _temp_dir) = create_dev_mode_client().await;
+59
View File
@@ -74,6 +74,39 @@ impl ScriptedResponse {
}
}
/// The `auth/token/lookup-self` answer every scripted Vault serves for free.
///
/// A Vault client now probes its token's remaining lifetime at login
/// (backlog#2369 P3), which is credential plumbing rather than the protocol any
/// of these tests is scripting. Answering it out of band keeps every existing
/// script meaningful: `ttl` 0 is Vault's "this token does not expire", so the
/// probe changes nothing about how a scripted test behaves.
pub(crate) fn token_lookup_self_response() -> String {
serde_json::json!({
"data": {
"accessor": "scripted-accessor",
"creation_time": 1_700_000_000u64,
"creation_ttl": 0,
"display_name": "token",
"entity_id": "",
"explicit_max_ttl": 0,
"id": "scripted-token",
"num_uses": 0,
"orphan": true,
"path": "auth/token/create",
"policies": ["default"],
"renewable": false,
"ttl": 0
}
})
.to_string()
}
/// Whether a recorded request line addresses the token self-lookup.
pub(crate) fn is_token_lookup_self(request_line: &str) -> bool {
request_line.contains("/v1/auth/token/lookup-self")
}
/// A scripted stand-in Vault listening on a loopback port.
pub(crate) struct ScriptedVault {
/// Base address (`http://127.0.0.1:port`) to point a Vault client at.
@@ -102,6 +135,19 @@ impl ScriptedVault {
let Some((request_line, body, mut stream)) = read_request(stream).await else {
continue;
};
if is_token_lookup_self(&request_line) {
// Served out of band so the credential probe does not
// consume a scripted response meant for the protocol under
// test, and is not recorded as one of its requests.
let body = token_lookup_self_response();
let payload = format!(
"HTTP/1.1 200 Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len(),
);
let _ = stream.write_all(payload.as_bytes()).await;
let _ = stream.shutdown().await;
continue;
}
recorded
.lock()
.expect("scripted vault request log poisoned")
@@ -155,6 +201,19 @@ impl ScriptedVault {
let Some((request_line, body, stream)) = read_request(stream).await else {
return;
};
if is_token_lookup_self(&request_line) {
// Credential plumbing, not part of the KV2 protocol
// this responder models; see token_lookup_self_response.
write_response(
stream,
ScriptedResponse::Http {
status: 200,
body: token_lookup_self_response(),
},
)
.await;
return;
}
recorded
.lock()
.expect("scripted vault request log poisoned")
@@ -13,5 +13,5 @@ expression: capabilities_snapshot(backend.capabilities())
"rotate": true,
"schedule_deletion": true,
"update_key_metadata": false,
"versioning": true
"versioning": false
}
+137 -21
View File
@@ -114,7 +114,7 @@ impl fmt::Debug for SecretString {
}
/// Expiry attributes of a lease-bound token.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LeaseInfo {
/// Time-to-live granted at issue or renewal.
pub(crate) ttl: Duration,
@@ -207,31 +207,102 @@ pub(crate) trait TokenSource: fmt::Debug + Send + Sync {
}
/// Token source for [`VaultAuthMethod::Token`]: always yields the token fixed
/// at configuration time. The token carries no lease, so it is never renewed
/// and never expires from the provider's point of view.
/// at configuration time.
///
/// The token itself is never re-issued, but it usually still expires:
/// `vault token create` defaults to a 768-hour TTL. Hard-coding "no lease"
/// here left the renewal task unstarted and published no remaining-TTL gauge,
/// so a healthy-looking cluster turned every KMS call into a 403 a month later
/// and could only be recovered by a restart or a reconfigure (backlog#2369 P3).
/// The source therefore asks Vault what it is holding, once per client
/// generation, and lets the existing renewal loop take over whenever the answer
/// carries a TTL.
/// Map a `lookup-self` answer onto a lease.
///
/// A zero TTL is Vault's answer for a token that never expires (root and
/// periodic-root tokens), which keeps the pre-probe behaviour exactly: no
/// lease, no renewal task, no expiry gate. A response that omits `renewable`
/// is treated as not renewable, so the renewal loop falls back to re-reading
/// the remaining TTL instead of assuming it can extend it.
fn static_token_lease(ttl_secs: u64, renewable: Option<bool>) -> Option<LeaseInfo> {
(ttl_secs > 0).then_some(LeaseInfo {
ttl: Duration::from_secs(ttl_secs),
renewable: renewable.unwrap_or(false),
})
}
pub(crate) struct StaticToken {
token: TokenLease,
/// Client authenticated with the configured token, used only for
/// `lookup-self`. Per-generation renewals use the generation's own client.
lookup_client: VaultClient,
}
impl StaticToken {
pub(crate) fn new(token: String) -> Self {
Self {
pub(crate) fn new(settings: &VaultConnectionSettings, token: String) -> Result<Self> {
let lookup_client = settings.build_client(&token)?;
Ok(Self {
token: TokenLease::new(token, None),
}
lookup_client,
})
}
}
#[async_trait]
impl TokenSource for StaticToken {
async fn acquire(&self) -> AttemptResult<TokenLease> {
Ok(self.token.clone())
// A lookup failure must not fail the login. The token itself may well
// be valid: a policy can omit `lookup-self`, and Vault may simply be
// unreachable for the moment. Failing here would take down deployments
// that work today, so the probe degrades to the pre-probe behaviour —
// no lease, no renewal — and says so loudly instead.
let lease = match vaultrs::token::lookup_self(&self.lookup_client).await {
Ok(lookup) => static_token_lease(lookup.ttl, lookup.renewable),
Err(error) => {
warn!(
event = "vault_static_token_lookup_failed",
error = %error,
"Could not read the configured Vault token's remaining lifetime, so it will not be \
renewed and its expiry will not be tracked. Grant the token `lookup-self` (Vault's \
default policy does) or switch to AppRole, Kubernetes or an agent-managed token file"
);
None
}
};
if let Some(lease) = lease
&& !lease.renewable
{
warn!(
event = "vault_static_token_not_renewable",
ttl_secs = lease.ttl.as_secs(),
"The configured Vault token expires and cannot be renewed; RustFS will fail closed as it \
approaches expiry. Switch to AppRole, Kubernetes or an agent-managed token file, or \
reconfigure with a fresh token before it lapses"
);
}
Ok(TokenLease::new(self.token.expose().to_string(), lease))
}
async fn renew(&self, client: &VaultClient) -> AttemptResult<TokenLease> {
// Vault refuses renew-self on a non-renewable token; the renewal loop
// then falls back to `acquire`, which re-reads the remaining TTL and
// keeps the gauge honest until the fail-closed window is reached.
let auth = vaultrs::token::renew_self(client, None)
.await
.map_err(|error| attempt_error("token renewal", error))?;
Ok(TokenLease::from_auth(auth))
}
}
impl fmt::Debug for StaticToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// TokenLease::fmt already redacts the token value.
f.debug_struct("StaticToken").field("token", &self.token).finish()
// TokenLease::fmt already redacts the token value; VaultClient embeds
// its settings, including the token, so it must stay out of Debug.
f.debug_struct("StaticToken")
.field("token", &self.token)
.finish_non_exhaustive()
}
}
@@ -541,7 +612,7 @@ pub(crate) fn token_source_for(
settings: &VaultConnectionSettings,
) -> Result<Box<dyn TokenSource>> {
match auth_method {
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(token.clone()))),
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(settings, token.clone())?)),
VaultAuthMethod::AppRole {
role_id,
secret_id,
@@ -1201,14 +1272,22 @@ mod tests {
(Arc::new(provider), state)
}
/// A provider whose token reports no expiry, which is what `lookup-self`
/// answers for a root or periodic-root token. Scripted rather than backed
/// by [`StaticToken`] because the real source now asks Vault what it holds.
async fn static_provider() -> VaultCredentialProvider {
VaultCredentialProvider::new(
test_settings(),
Box::new(StaticToken::new(TEST_TOKEN.to_string())),
Box::new(ScriptedSource {
state: Arc::new(ScriptedState::default()),
ttl: Duration::ZERO,
renewable: false,
login_delay: Duration::ZERO,
}),
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
)
.await
.expect("static provider must build without a live Vault")
.expect("a token without an expiry must build without a live Vault")
}
#[tokio::test]
@@ -1231,20 +1310,57 @@ mod tests {
assert!(provider.spawn_renewal_task().is_none(), "a token without a lease has nothing to renew");
}
#[tokio::test]
async fn test_static_token_source_yields_configured_token() {
let settings = test_settings();
let source = token_source_for(
#[test]
fn test_static_token_source_builds_without_contacting_vault() {
token_source_for(
&VaultAuthMethod::Token {
token: TEST_TOKEN.to_string(),
},
&settings,
&test_settings(),
)
.expect("token auth must map to a source");
}
let lease = source.acquire().await.expect("static acquire cannot fail");
assert_eq!(lease.expose(), TEST_TOKEN);
assert!(lease.lease_info().is_none(), "static tokens must not carry a lease");
/// backlog#2369 P3: `vault token create` defaults to a 768-hour TTL, so
/// hard-coding "no lease" for token auth left the renewal task unstarted
/// and turned a healthy cluster into one that answers 403 a month later.
/// The lease now comes from what Vault reports.
#[test]
fn static_token_lease_follows_what_vault_reports() {
assert_eq!(
static_token_lease(0, Some(true)),
None,
"a token Vault reports as non-expiring must keep behaving as one"
);
assert_eq!(
static_token_lease(0, None),
None,
"a non-expiring token stays non-expiring whatever renewable says"
);
assert_eq!(
static_token_lease(2_764_800, Some(true)),
Some(LeaseInfo {
ttl: Duration::from_secs(2_764_800),
renewable: true,
}),
"the default 768-hour token must be tracked and renewed"
);
assert_eq!(
static_token_lease(3_600, Some(false)),
Some(LeaseInfo {
ttl: Duration::from_secs(3_600),
renewable: false,
}),
"an expiring token that cannot be renewed still needs its expiry tracked"
);
assert_eq!(
static_token_lease(3_600, None),
Some(LeaseInfo {
ttl: Duration::from_secs(3_600),
renewable: false,
}),
"an omitted renewable flag must not be read as renewable"
);
}
#[tokio::test]
@@ -1759,7 +1875,7 @@ mod tests {
renewable: true,
}),
);
let static_source = StaticToken::new(TEST_TOKEN.to_string());
let static_source = StaticToken::new(&test_settings(), TEST_TOKEN.to_string()).expect("static source");
let approle_source = AppRoleLogin::new(
&test_settings(),
"approle".to_string(),
+60 -1
View File
@@ -166,6 +166,24 @@ fn rule_replicates(rule: &ReplicationRule, obj: &ObjectOpts) -> bool {
}
}
fn replication_filter_tags_match(filter: &s3s::dto::ReplicationRuleFilter, object_tags: &HashMap<String, String>) -> bool {
let tag_matches = |tag: &s3s::dto::Tag| match (&tag.key, &tag.value) {
(None, None) => true,
(Some(key), _) if key.is_empty() => true,
(Some(key), Some(value)) => object_tags.get(key) == Some(value),
_ => false,
};
filter
.and
.as_ref()
.and_then(|and| and.tags.as_deref())
.into_iter()
.flatten()
.chain(filter.tag.iter())
.all(tag_matches)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplicationTargetValidationError {
RoleWithMultipleDestinations,
@@ -704,7 +722,7 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
if let Some(filter) = &rule.filter {
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
if filter.test_tags(&object_tags) {
if replication_filter_tags_match(filter, &object_tags) {
rules.push(rule.clone());
}
} else {
@@ -1139,6 +1157,47 @@ mod tests {
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
}
#[test]
fn actionable_rules_require_every_and_tag_to_match() {
let mut rule = replication_rule("rule-1", "arn:target:a");
rule.filter = Some(s3s::dto::ReplicationRuleFilter {
and: Some(s3s::dto::ReplicationRuleAndOperator {
prefix: None,
tags: Some(vec![
s3s::dto::Tag {
key: Some("env".to_string()),
value: Some("prod".to_string()),
},
s3s::dto::Tag {
key: Some("tier".to_string()),
value: Some("gold".to_string()),
},
]),
}),
..Default::default()
});
let config = structure_config(vec![rule]);
let object = |user_tags: &str| ObjectOpts {
name: "object".to_string(),
user_tags: user_tags.to_string(),
..Default::default()
};
assert!(config.filter_target_arns(&object("env=prod")).is_empty());
assert_eq!(config.filter_target_arns(&object("env=prod&tier=gold")), vec!["arn:target:a"]);
assert!(config.filter_target_arns(&object("")).is_empty());
let mut malformed = config;
malformed.rules[0].filter.as_mut().unwrap().and.as_mut().unwrap().tags = Some(vec![s3s::dto::Tag {
key: Some("env".to_string()),
value: None,
}]);
assert!(
malformed.filter_target_arns(&object("env=prod")).is_empty(),
"a malformed tag filter must fail closed"
);
}
#[test]
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
let mut rule = replication_rule("rule-1", "arn:target:a");
+44
View File
@@ -580,6 +580,30 @@ impl FailStats {
FailedMetric { count, size }
}
/// Both rolling windows from one walk of the samples. `short` must be the
/// narrower window; the walk stops at `long`. Callers that need both (the
/// per-node site snapshot) would otherwise scan the deque twice while
/// holding the bucket-stats read lock, and the deque is only bounded by
/// the one-hour window - an unreachable target under load fills it.
pub fn recent_windows(&self, short: Duration, long: Duration) -> (FailedMetric, FailedMetric) {
let now = Instant::now();
let mut short_metric = FailedMetric::default();
let mut long_metric = FailedMetric::default();
for sample in self.recent.iter().rev() {
let age = now.duration_since(sample.observed_at);
if age > long {
break;
}
if age <= short {
short_metric.count += 1;
short_metric.size += sample.size;
}
long_metric.count += 1;
long_metric.size += sample.size;
}
(short_metric, long_metric)
}
pub fn merge(&self, other: &FailStats) -> Self {
Self {
count: self.count.saturating_add(other.count),
@@ -912,6 +936,26 @@ mod tests {
assert_eq!(last_hour.size, 96);
}
#[test]
fn fail_stats_recent_windows_matches_two_separate_scans() {
let mut stats = FailStats::default();
stats.add_size(64, None::<&()>);
stats.add_size(32, None::<&()>);
let (minute, hour) = stats.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
let expected_minute = stats.recent_since(Duration::from_secs(60));
let expected_hour = stats.recent_since(Duration::from_secs(60 * 60));
assert_eq!((minute.count, minute.size), (expected_minute.count, expected_minute.size));
assert_eq!((hour.count, hour.size), (expected_hour.count, expected_hour.size));
assert_eq!(minute.count, 2);
assert_eq!(hour.size, 96);
let empty = FailStats::default();
let (minute, hour) = empty.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
assert_eq!((minute.count, minute.size, hour.count, hour.size), (0, 0, 0, 0));
}
#[test]
fn fail_stats_saturate_instead_of_wrapping() {
let mut stats = FailStats {
+1
View File
@@ -90,6 +90,7 @@ s3s = { workspace = true, features = ["minio"] }
hex-simd.workspace = true
[dev-dependencies]
temp-env = { workspace = true, features = ["async_closure"] }
tokio = { workspace = true, features = ["test-util"] }
tokio-test = { workspace = true }
criterion = { workspace = true, features = ["html_reports"] }
+241 -17
View File
@@ -327,6 +327,55 @@ where
}
}
/// Read-side switch for the pre-`1.0.0-alpha.91` nonce layout, in which a whole
/// v1 segment reused the part nonce for every block.
///
/// On by default, because turning it off refuses to decrypt objects written
/// before that release. Block zero's derived nonce equals that base nonce, so
/// the layout also lets a frame encrypted at index zero authenticate anywhere
/// in its segment; the in-segment layout lock catches that as soon as a later
/// frame disagrees, but a stream that is nothing but repeats of frame zero has
/// no such later frame. A deployment with no pre-alpha.91 objects should set
/// this to `false` to remove that surface outright (backlog#2369 P2).
///
// RUSTFS_COMPAT_TODO(backlog-2369-legacy-nonce-fallback): Remove after the
// minimum supported direct-upgrade release and after migration tooling has
// rewritten every pre-alpha.91 encrypted object.
pub const ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK: &str = "RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK";
const DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK: bool = true;
fn legacy_nonce_fallback_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(
ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
)
}
#[cfg(not(test))]
{
static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
)
})
}
}
/// The nonce layout selected while decoding a legacy v1 segment.
///
/// A historical writer used one of these layouts consistently for every
/// block in a segment. Once a non-zero block identifies that layout, accepting
/// another layout would let an attacker replay a block encrypted at index zero.
#[derive(Clone, Copy, PartialEq, Eq)]
enum V1NonceLayout {
Current,
LegacyBlock,
ReusedPart,
}
pin_project! {
/// A reader wrapper that decrypts data on the fly using AES-256-GCM.
/// This is a demonstration. For production, use a secure and audited crypto library.
@@ -358,6 +407,8 @@ pin_project! {
segment_frames: usize,
stream_saw_v2: bool,
segments_completed: usize,
v1_nonce_layout: Option<V1NonceLayout>,
legacy_nonce_fallback: bool,
}
}
@@ -391,6 +442,8 @@ where
segment_frames: 0,
stream_saw_v2: false,
segments_completed: 0,
v1_nonce_layout: None,
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
}
}
@@ -441,6 +494,8 @@ where
segment_frames: 0,
stream_saw_v2: false,
segments_completed: 0,
v1_nonce_layout: None,
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
}
}
}
@@ -547,6 +602,7 @@ where
*this.segment_frame_version = None;
*this.saw_final_frame = false;
*this.segment_frames = 0;
*this.v1_nonce_layout = None;
if *this.multipart_mode {
let next_part = if *this.current_part_index + 1 < this.multipart_parts.len() {
@@ -696,26 +752,46 @@ where
*this.base_nonce
};
let legacy_block_nonce = derive_block_nonce(&legacy_part_nonce, *this.block_index);
match this.cipher.decrypt(&nonce, ciphertext) {
Ok(plaintext) => plaintext,
Err(primary_err) => {
let legacy_nonce =
Nonce::try_from(legacy_block_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
match this.cipher.decrypt(&legacy_nonce, ciphertext) {
Ok(plaintext) => plaintext,
Err(_) => {
// Accept previously written streams that reused the part nonce
// for every block inside a segment.
let legacy_part_nonce = Nonce::try_from(legacy_part_nonce.as_slice())
.map_err(|_| Error::other("invalid nonce length"))?;
this.cipher
.decrypt(&legacy_part_nonce, ciphertext)
.map_err(|_| Error::other(format!("decrypt error: {primary_err}")))?
}
let legacy_part_nonce =
Nonce::try_from(legacy_part_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
let legacy_block_nonce =
Nonce::try_from(legacy_block_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
let layouts = [
(V1NonceLayout::Current, &nonce),
(V1NonceLayout::LegacyBlock, &legacy_block_nonce),
(V1NonceLayout::ReusedPart, &legacy_part_nonce),
];
let selected = if *this.block_index == 0 { None } else { *this.v1_nonce_layout };
let mut plaintext = None;
let mut last_error = None;
for (layout, candidate_nonce) in layouts {
if selected.is_some_and(|expected| expected != layout) {
continue;
}
if layout == V1NonceLayout::ReusedPart && !*this.legacy_nonce_fallback {
continue;
}
match this.cipher.decrypt(candidate_nonce, ciphertext) {
Ok(value) => {
plaintext = Some((value, layout));
break;
}
Err(error) => last_error = Some(error),
}
}
let (plaintext, layout) = plaintext.ok_or_else(|| {
Error::new(
std::io::ErrorKind::InvalidData,
format!(
"decrypt error: {}",
last_error.map_or_else(|| "nonce layout rejected".to_string(), |error| error.to_string())
),
)
})?;
if *this.block_index > 0 && this.v1_nonce_layout.is_none() {
*this.v1_nonce_layout = Some(layout);
}
plaintext
};
if *this.current_frame_type == FRAME_TYPE_V2_FINAL {
*this.saw_final_frame = true;
@@ -1003,6 +1079,154 @@ mod tests {
assert_eq!(&decrypted, data);
}
/// Encrypts `block_count` full v1 blocks, then overwrites frame one with a
/// verbatim copy of frame zero. Every frame is the same length, so the
/// stream keeps its original size and the forgery is invisible to any
/// length check.
async fn v1_stream_with_frame_zero_replayed_at_index_one(key: [u8; 32], nonce: [u8; 12], block_count: usize) -> Vec<u8> {
assert!(block_count >= 2, "a replay needs at least two frames");
let mut data = Vec::with_capacity(ENCRYPTION_BLOCK_SIZE * block_count);
for index in 0..block_count {
data.extend(std::iter::repeat_n(0xA1u8.wrapping_add(index as u8 * 17), ENCRYPTION_BLOCK_SIZE));
}
let mut encrypt_reader = EncryptReader::new(Cursor::new(data), key, nonce);
let mut encrypted = Vec::new();
encrypt_reader.read_to_end(&mut encrypted).await.expect("encrypt v1 frames");
// Header layout: [type][len:24][crc:32]; `len` counts the payload plus
// its own 4-byte CRC field, so the frame occupies 8 + (len - 4) bytes.
let declared_len = (encrypted[1] as usize) | ((encrypted[2] as usize) << 8) | ((encrypted[3] as usize) << 16);
let frame_len = 8 + declared_len - 4;
let replayed_first = encrypted[..frame_len].to_vec();
encrypted[frame_len..frame_len * 2].copy_from_slice(&replayed_first);
encrypted
}
#[tokio::test]
async fn decrypt_reader_rejects_a_replayed_first_v1_frame() {
let key = [0x11; 32];
let nonce = [0x22; 12];
let encrypted = v1_stream_with_frame_zero_replayed_at_index_one(key, nonce, 3).await;
let mut decrypt_reader = DecryptReader::new(Cursor::new(encrypted), key, nonce);
let error = decrypt_reader
.read_to_end(&mut Vec::new())
.await
.expect_err("a repeated index-zero frame must not authenticate at index one");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
/// The in-segment layout lock must not cost compatibility: every legacy v1
/// shape the fallback chain exists for still decrypts under the default.
#[tokio::test]
async fn legacy_v1_streams_still_decrypt_under_the_default_fallback() {
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, None::<&str>)], async {
assert!(legacy_nonce_fallback_enabled(), "the legacy nonce fallback must stay on by default");
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut nonce);
let mut data = vec![0u8; ENCRYPTION_BLOCK_SIZE * 3 + 17];
rand::rng().fill(&mut data[..]);
// Modern single-part stream.
let mut encrypted = Vec::new();
EncryptReader::new(Cursor::new(data.clone()), key, nonce)
.read_to_end(&mut encrypted)
.await
.expect("modern v1 stream should encrypt");
let mut decrypted = Vec::new();
DecryptReader::new(Cursor::new(encrypted), key, nonce)
.read_to_end(&mut decrypted)
.await
.expect("modern v1 stream should decrypt");
assert_eq!(decrypted, data);
// Pre-alpha.91 stream that reused the part nonce for every block.
let legacy = encrypt_with_legacy_nonce_reuse(&data, key, nonce);
let mut decrypted = Vec::new();
DecryptReader::new(Cursor::new(legacy), key, nonce)
.read_to_end(&mut decrypted)
.await
.expect("a reused-nonce legacy stream should still decrypt");
assert_eq!(decrypted, data);
})
.await;
}
/// The residual after the layout lock: a stream that is nothing but repeats
/// of frame zero has no later frame to disagree with the reused-part
/// layout, so only turning the fallback off rejects it.
#[tokio::test]
async fn a_two_frame_replay_is_closed_only_by_disabling_the_legacy_fallback() {
let key = [0x33; 32];
let nonce = [0x44; 12];
let encrypted = v1_stream_with_frame_zero_replayed_at_index_one(key, nonce, 2).await;
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, None::<&str>)], async {
let mut forged = Vec::new();
DecryptReader::new(Cursor::new(encrypted.clone()), key, nonce)
.read_to_end(&mut forged)
.await
.expect("with the fallback on this forgery is still accepted");
assert_eq!(forged.len(), ENCRYPTION_BLOCK_SIZE * 2);
assert_eq!(
&forged[..ENCRYPTION_BLOCK_SIZE],
&forged[ENCRYPTION_BLOCK_SIZE..],
"the accepted forgery is frame zero's plaintext twice over"
);
})
.await;
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, Some("false"))], async {
let error = DecryptReader::new(Cursor::new(encrypted.clone()), key, nonce)
.read_to_end(&mut Vec::new())
.await
.expect_err("with the fallback off the replayed frame must not authenticate");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
})
.await;
}
/// Turning the fallback off removes exactly the third layout: modern v1
/// streams keep decrypting, pre-alpha.91 reused-nonce streams stop.
#[tokio::test]
async fn disabling_the_legacy_nonce_fallback_refuses_only_reused_part_nonces() {
let mut key = [0u8; 32];
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut key);
rand::rng().fill_bytes(&mut nonce);
let mut data = vec![0u8; ENCRYPTION_BLOCK_SIZE * 3 + 17];
rand::rng().fill(&mut data[..]);
let mut modern = Vec::new();
EncryptReader::new(Cursor::new(data.clone()), key, nonce)
.read_to_end(&mut modern)
.await
.expect("modern v1 stream should encrypt");
let legacy = encrypt_with_legacy_nonce_reuse(&data, key, nonce);
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, Some("false"))], async {
assert!(!legacy_nonce_fallback_enabled(), "the switch must be observed");
let mut decrypted = Vec::new();
DecryptReader::new(Cursor::new(modern), key, nonce)
.read_to_end(&mut decrypted)
.await
.expect("modern v1 streams must keep decrypting with the fallback off");
assert_eq!(decrypted, data);
let error = DecryptReader::new(Cursor::new(legacy), key, nonce)
.read_to_end(&mut Vec::new())
.await
.expect_err("the third layout must be gone when the fallback is off");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
})
.await;
}
#[tokio::test]
async fn test_decrypt_reader_only() {
// Encrypt some data first
+432
View File
@@ -0,0 +1,432 @@
// 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 bytes::Bytes;
use datafusion::object_store::{Error, Result};
use futures::{Stream, StreamExt, stream::BoxStream};
use transform_stream::AsyncTryStream;
use crate::SelectError;
/// Arrow accepts byte-sized CSV controls. Unicode quotes need streaming normalization.
pub fn csv_input_requires_normalization(quote: Option<&str>, escape: Option<&str>) -> bool {
quote.is_some_and(|quote| quote.len() > 1) || escape.is_some_and(|escape| escape.len() > 1)
}
/// CSV syntax independent of request headers, serialization formats, or S3 DTOs.
#[derive(Default)]
pub(crate) struct CsvSyntax<'a> {
pub quote: Option<&'a str>,
pub escape: Option<&'a str>,
pub field: Option<&'a str>,
pub record: Option<&'a str>,
pub comment: Option<u8>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
FieldStart,
Unquoted,
Quoted,
AfterQuote,
Escaped,
Comment,
}
/// Emits ordinary CSV with every field quoted. This avoids reserving a sentinel
/// byte that might also appear in a UTF-8 field. Only a partial control token is
/// retained between chunks; neither records nor objects are buffered.
struct CsvInputNormalizer {
quote: Vec<u8>,
escape: Vec<u8>,
field: Vec<u8>,
record: Vec<u8>,
comment: Option<u8>,
default_records: bool,
state: State,
record_start: bool,
carry: Vec<u8>,
token_size: usize,
}
impl CsvInputNormalizer {
fn new(csv: &CsvSyntax<'_>) -> Self {
let quote = csv
.quote
.filter(|value| !value.is_empty())
.unwrap_or("\"")
.as_bytes()
.to_vec();
let escape = csv
.escape
.filter(|value| !value.is_empty())
.unwrap_or("\"")
.as_bytes()
.to_vec();
let field = csv.field.filter(|value| !value.is_empty()).unwrap_or(",").as_bytes().to_vec();
let record = csv
.record
.filter(|value| !value.is_empty())
.unwrap_or("\n")
.as_bytes()
.to_vec();
let token_size = quote.len().max(escape.len()).max(field.len()).max(record.len()).max(2);
Self {
quote,
escape,
field,
record,
comment: csv.comment,
default_records: csv.record.is_none(),
state: State::FieldStart,
record_start: true,
carry: Vec::new(),
token_size,
}
}
fn record_len(&self, bytes: &[u8]) -> usize {
if self.default_records && bytes.starts_with(b"\r\n") {
2
} else if self.default_records && bytes.starts_with(b"\r") {
1
} else if bytes.starts_with(&self.record) {
self.record.len()
} else {
0
}
}
fn push_value(output: &mut Vec<u8>, bytes: &[u8]) {
for byte in bytes {
if *byte == b'"' {
output.push(b'"');
}
output.push(*byte);
}
}
fn convert(&mut self, chunk: &[u8], last: bool) -> std::result::Result<Vec<u8>, SelectError> {
let mut bytes = std::mem::take(&mut self.carry);
bytes.extend_from_slice(chunk);
let end = if last {
bytes.len()
} else {
bytes.len().saturating_sub(self.token_size - 1)
};
let mut output = Vec::with_capacity(bytes.len());
let mut pos = 0;
while pos < end {
let rest = &bytes[pos..];
let record_len = self.record_len(rest);
let field = rest.starts_with(&self.field) && self.field.len() > record_len;
match self.state {
State::Comment => {
if record_len > 0 {
self.state = State::FieldStart;
pos += record_len;
} else {
pos += 1;
}
}
State::Escaped => {
if record_len > 0 {
return Err(SelectError::CsvParsingError);
}
Self::push_value(&mut output, &rest[..1]);
self.state = State::Quoted;
pos += 1;
}
State::Quoted if rest.starts_with(&self.quote) => {
self.state = State::AfterQuote;
pos += self.quote.len();
}
State::Quoted if rest.starts_with(&self.escape) => {
self.state = State::Escaped;
pos += self.escape.len();
}
State::Quoted => {
if record_len > 0 {
return Err(SelectError::CsvParsingError);
}
Self::push_value(&mut output, &rest[..1]);
pos += 1;
}
State::AfterQuote if rest.starts_with(&self.quote) => {
Self::push_value(&mut output, &self.quote);
self.state = State::Quoted;
pos += self.quote.len();
}
State::FieldStart if self.record_start && self.comment == Some(rest[0]) => {
self.state = State::Comment;
pos += 1;
}
State::FieldStart if rest.starts_with(&self.quote) => {
output.push(b'"');
self.state = State::Quoted;
self.record_start = false;
pos += self.quote.len();
}
_ if field || record_len > 0 => {
if self.state == State::FieldStart {
if field || !self.record_start {
output.extend_from_slice(b"\"\"");
}
} else {
output.push(b'"');
}
output.push(if field { b',' } else { b'\n' });
self.state = State::FieldStart;
self.record_start = !field;
pos += if field { self.field.len() } else { record_len };
}
_ => {
if self.state == State::FieldStart {
output.push(b'"');
}
self.state = State::Unquoted;
self.record_start = false;
Self::push_value(&mut output, &rest[..1]);
pos += 1;
}
}
}
self.carry.extend_from_slice(&bytes[pos..]);
if last {
match self.state {
State::Quoted | State::Escaped => return Err(SelectError::CsvParsingError),
State::Unquoted | State::AfterQuote => output.push(b'"'),
State::FieldStart if !self.record_start => output.extend_from_slice(b"\"\""),
State::FieldStart | State::Comment => {}
}
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn normalize_chunks(csv: &CsvSyntax<'_>, input: &[u8], chunk_size: usize) -> Vec<u8> {
let mut normalizer = CsvInputNormalizer::new(csv);
let mut output = Vec::new();
for chunk in input.chunks(chunk_size) {
output.extend(normalizer.convert(chunk, false).expect("normalize complete CSV input"));
assert!(normalizer.carry.len() < normalizer.token_size, "only a partial token may be retained");
}
output.extend(normalizer.convert(&[], true).expect("finish complete CSV input"));
output
}
#[test]
fn unicode_csv_quotes_preserve_values_at_every_chunk_boundary() {
let cases = [
("ع", "\"", "عcol1ع,عcol2ع,عcol3ع\n", "\"col1\",\"col2\",\"col3\"\n"),
("ع", "\"", "\"left\",tail\n", "\"\"\"left\"\"\",\"tail\"\n"),
("ع", "\"", "عA,Bع,plain\n", "\"A,B\",\"plain\"\n"),
("ع", "\"", "عAععBع,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\"", "عA\"عBع,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\\", "عA\\\"Bع,tail\n", "\"A\"\"B\",\"tail\"\n"),
("\"", "", "\"A界\"B\",\"C\"\n", "\"A\"\"B\",\"C\"\n"),
("🦀", "🦀", "🦀A🦀🦀B🦀,C\n", "\"A🦀B\",\"C\"\n"),
("ع", "\"", "a\0b,عc\0\n", "\"a\0b\",\"c\0d\"\n"),
("ع", "\"", "AعB,tail\n", "\"AعB\",\"tail\"\n"),
("ع", "\"", "عaعsuffix,tail\n", "\"asuffix\",\"tail\"\n"),
("ع", "\"", ",\n", "\"\",\"\"\n"),
("ع", "\"", "a,", "\"a\",\"\""),
("ع", "\"", "عع", "\"\""),
("ع", "\"", "\n", "\n"),
("ع", "\"", "", ""),
];
for (quote, escape, input, expected) in cases {
let csv = CsvSyntax {
quote: Some(quote),
escape: Some(escape),
record: Some("\n"),
..Default::default()
};
for chunk_size in 1..=input.len().max(1) {
assert_eq!(
normalize_chunks(&csv, input.as_bytes(), chunk_size),
expected.as_bytes(),
"input={input:?}, chunk_size={chunk_size}"
);
}
}
}
#[test]
fn unicode_csv_quotes_keep_custom_delimiters_and_comments_out_of_values() {
let csv = CsvSyntax {
quote: Some("ع"),
escape: Some("\\"),
field: Some(""),
record: Some("^Y"),
comment: Some(b'#'),
};
let input = "#skipع界^Yعa界bع界\"literal\"^Yعline\nbreakع界end^Y";
let expected = "\"a界b\",\"\"\"literal\"\"\"\n\"line\nbreak\",\"end\"\n";
for chunk_size in 1..=input.len() {
assert_eq!(normalize_chunks(&csv, input.as_bytes(), chunk_size), expected.as_bytes());
}
}
#[test]
fn unicode_csv_quotes_reject_unterminated_fields_and_quoted_record_delimiters() {
for input in ["عunfinished", "عescape\\", "عline\nbreakع\n", "عline\\\nbreakع\n"] {
let csv = CsvSyntax {
quote: Some("ع"),
escape: Some("\\"),
..Default::default()
};
let mut normalizer = CsvInputNormalizer::new(&csv);
assert_eq!(normalizer.convert(input.as_bytes(), true), Err(SelectError::CsvParsingError));
}
}
#[test]
fn unicode_csv_quotes_preserve_omitted_syntax_defaults() {
assert!(!csv_input_requires_normalization(None, None));
assert!(!csv_input_requires_normalization(Some("\""), Some("\\")));
assert!(csv_input_requires_normalization(Some("ع"), None));
assert!(csv_input_requires_normalization(None, Some("")));
let quote_only = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
assert_eq!(
normalize_chunks(&quote_only, "عA\"عBع,tail\r\n".as_bytes(), 1),
"\"AعB\",\"tail\"\n".as_bytes()
);
let escape_only = CsvSyntax {
escape: Some(""),
..Default::default()
};
assert_eq!(
normalize_chunks(&escape_only, "\"A界\"B\",tail\r\n".as_bytes(), 1),
b"\"A\"\"B\",\"tail\"\n"
);
}
#[test]
fn unicode_csv_quotes_stream_large_fields_without_retaining_records() {
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut normalizer = CsvInputNormalizer::new(&csv);
let chunk = vec![b'x'; 64 * 1024];
let mut output_len = normalizer.convert("ع".as_bytes(), false).expect("opening quote").len();
for _ in 0..64 {
let output = normalizer.convert(&chunk, false).expect("stream field chunk");
assert!(output.len() >= chunk.len() - 3, "field data must be emitted before its closing quote");
assert!(normalizer.carry.len() < 4);
output_len += output.len();
}
output_len += normalizer.convert("ع\n".as_bytes(), true).expect("close field").len();
assert_eq!(output_len, chunk.len() * 64 + 3);
}
}
pub(crate) fn normalize_csv_stream<S>(stream: S, csv: &CsvSyntax<'_>) -> BoxStream<'static, Result<Bytes>>
where
S: Stream<Item = Result<Bytes>> + Send + 'static,
{
let mut normalizer = CsvInputNormalizer::new(csv);
AsyncTryStream::<Bytes, Error, _>::new(|mut y| async move {
futures::pin_mut!(stream);
while let Some(chunk) = stream.next().await {
let converted = normalizer.convert(&chunk?, false).map_err(|source| Error::Generic {
store: "EcObjectStore",
source: Box::new(source),
})?;
if !converted.is_empty() {
y.yield_ok(Bytes::from(converted)).await;
}
}
let converted = normalizer.convert(&[], true).map_err(|source| Error::Generic {
store: "EcObjectStore",
source: Box::new(source),
})?;
if !converted.is_empty() {
y.yield_ok(Bytes::from(converted)).await;
}
Ok(())
})
.boxed()
}
#[cfg(test)]
mod stream_tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
struct DropProbe(Arc<AtomicBool>);
impl Drop for DropProbe {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
#[tokio::test]
async fn unicode_csv_quotes_drop_the_source_without_reading_ahead() {
let polls = Arc::new(AtomicUsize::new(0));
let dropped = Arc::new(AtomicBool::new(false));
let source =
futures::stream::unfold((DropProbe(Arc::clone(&dropped)), Arc::clone(&polls)), |(guard, polls)| async move {
polls.fetch_add(1, Ordering::SeqCst);
Some((Ok(Bytes::from_static("عvalueع\n".as_bytes())), (guard, polls)))
});
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut stream = normalize_csv_stream(source, &csv);
assert!(!stream.next().await.expect("first output").expect("valid CSV").is_empty());
assert_eq!(polls.load(Ordering::SeqCst), 1);
drop(stream);
assert!(dropped.load(Ordering::SeqCst), "cancellation must release the source reader");
assert_eq!(polls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn unicode_csv_quotes_preserve_source_errors_after_partial_output() {
let source = futures::stream::iter([
Ok(Bytes::from_static("عvalueع\n".as_bytes())),
Err(Error::Generic {
store: "fixture",
source: std::io::Error::other("source read failed").into(),
}),
]);
let csv = CsvSyntax {
quote: Some("ع"),
..Default::default()
};
let mut stream = normalize_csv_stream(source, &csv);
assert!(!stream.next().await.expect("partial output").expect("valid prefix").is_empty());
let error = stream
.next()
.await
.expect("source failure must remain visible")
.expect_err("must not return a successful tail");
assert!(error.to_string().contains("source read failed"));
assert!(stream.next().await.is_none());
}
}
+2
View File
@@ -23,12 +23,14 @@ use datafusion::{
use std::{error::Error as StdError, fmt::Display};
use thiserror::Error;
mod csv_input;
mod input_stream;
mod metrics;
pub mod object_store;
pub mod query;
pub mod server;
mod storage_api;
pub use csv_input::csv_input_requires_normalization;
pub use metrics::{SelectInputMetrics, SelectInputMetricsSnapshot};
pub use storage_api::SelectObjectSnapshot;
+109 -7
View File
@@ -64,6 +64,7 @@ use tokio::{io::AsyncReadExt, sync::OnceCell};
use tokio_util::io::ReaderStream;
use transform_stream::AsyncTryStream;
use crate::csv_input::{CsvSyntax, csv_input_requires_normalization, normalize_csv_stream};
use crate::storage_api::object_store::HTTPRangeSpec;
mod json_document;
@@ -345,6 +346,29 @@ impl EcObjectStore {
(self.need_convert || (delimiter.len() == 2 && delimiter != NORMALIZED_RECORD_DELIMITER)).then_some(delimiter)
}
fn convert_csv_stream<S>(&self, stream: S) -> BoxStream<'static, Result<Bytes>>
where
S: Stream<Item = Result<Bytes>> + Send + 'static,
{
if let Some(csv) = self.input.request.input_serialization.csv.as_ref()
&& csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
{
let syntax = CsvSyntax {
quote: csv.quote_character.as_deref(),
escape: csv.quote_escape_character.as_deref(),
field: csv.field_delimiter.as_deref(),
record: csv.record_delimiter.as_deref(),
comment: csv.comments.as_ref().and_then(|comment| comment.as_bytes().first().copied()),
};
return normalize_csv_stream(stream, &syntax);
}
convert_csv_delimiter_stream(
stream,
self.record_delimiter_for_conversion(),
self.need_convert.then(|| self.delimiter.clone()),
)
}
fn csv_has_header(&self) -> bool {
self.input
.request
@@ -820,7 +844,6 @@ impl ObjectStore for EcObjectStore {
});
}
let record_delimiter = self.record_delimiter_for_conversion();
let needs_scan_context = options.range.is_none() && has_effective_request_range;
let scan_context = if needs_scan_context {
if let Some(scan_range) = self.scan_range(original_size)? {
@@ -883,8 +906,7 @@ impl ObjectStore for EcObjectStore {
max_processed_bytes,
query_guard,
)?;
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
let stream = self.convert_csv_stream(stream);
GetResultPayload::Stream(stream)
}
} else if options.range.is_some() {
@@ -937,8 +959,7 @@ impl ObjectStore for EcObjectStore {
} else {
stream
};
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
let stream = self.convert_csv_stream(stream);
GetResultPayload::Stream(stream)
} else {
let stream_size = usize::try_from(original_size).map_err(|err| o_Error::Generic {
@@ -948,8 +969,7 @@ impl ObjectStore for EcObjectStore {
let stream = bytes_stream(ReaderStream::with_capacity(reader.stream, SELECT_DEFAULT_READ_BUFFER_SIZE), stream_size);
if meter_input {
let stream = meter_uncompressed_input_stream(stream, Arc::clone(&self.input_metrics));
let stream =
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
let stream = self.convert_csv_stream(stream);
GetResultPayload::Stream(stream)
} else {
GetResultPayload::Stream(stream.boxed())
@@ -2866,6 +2886,88 @@ mod test {
assert_eq!(input_metrics.snapshot().bytes_processed, 2);
}
#[tokio::test]
async fn unicode_csv_quotes_preserve_raw_offsets_and_metrics() {
const BUCKET: &str = "s3select-unicode-csv-stream";
const HEADER: &str = "عnameع,عkindع\n";
const SKIP: &str = "عskipع,عzeroع\n";
const ROW: &str = "عA,Bع,عAععBع\n";
let data = format!("{HEADER}{SKIP}{ROW}");
let env = crate::storage_api::select_test_ecstore_env().await;
env.make_bucket(BUCKET, false).await;
for (object, compression, range_offset) in [
("plain.csv", None, None),
("range.csv", None, Some(0)),
("range-mid-character.csv", None, Some(1)),
("gzip.csv", Some(CompressionFormat::Gzip), None),
("bzip.csv", Some(CompressionFormat::Bzip2), None),
] {
let bytes = match compression {
Some(format) => encode_compressed_fixture(format, data.as_bytes()).await,
None => data.as_bytes().to_vec(),
};
let raw_size = bytes.len();
let mut reader = SelectPutObjReader::from_vec(bytes);
env.ecstore
.put_object(BUCKET, object, &mut reader, &Default::default())
.await
.expect("write Unicode CSV fixture");
let mut input = (*csv_input(BUCKET, object)).clone();
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::USE));
csv.quote_character = Some("ع".to_owned());
csv.quote_escape_character = Some("\\".to_owned());
csv.record_delimiter = Some("\n".to_owned());
input.request.input_serialization.compression_type = compression.map(|format| {
CompressionType::from_static(match format {
CompressionFormat::Gzip => CompressionType::GZIP,
CompressionFormat::Bzip2 => CompressionType::BZIP2,
})
});
let start = HEADER.len() + SKIP.len();
if let Some(range_offset) = range_offset {
let offset = i64::try_from(start + range_offset).expect("fixture offset");
input.request.scan_range = Some(ScanRange {
start: Some(offset),
end: Some(offset),
});
}
let metrics = Arc::new(SelectInputMetrics::default());
let store = EcObjectStore::build_with_snapshot(
Arc::new(input),
Arc::new(GreedyMemoryPool::new(1024 * 1024)),
None,
Arc::clone(&metrics),
prepare_test_snapshot(BUCKET, object).await,
JsonSource::default(),
)
.expect("snapshot store");
let result = store
.get_opts(&Path::from(object), GetOptions::default())
.await
.expect("open Unicode CSV stream");
let GetResultPayload::Stream(stream) = result.payload else { panic!("CSV must remain streaming") };
let output = stream.try_collect::<Vec<_>>().await.expect("normalize CSV stream").concat();
let expected = match range_offset {
Some(0) => "\"name\",\"kind\"\n\"A,B\",\"AعB\"\n",
Some(_) => "\"name\",\"kind\"\n",
None => "\"name\",\"kind\"\n\"skip\",\"zero\"\n\"A,B\",\"AعB\"\n",
};
assert_eq!(output, expected.as_bytes(), "object={object}");
let measured = metrics.snapshot();
if let Some(range_offset) = range_offset {
// The range reader includes one byte of delimiter context and a
// separate header read; offsets always refer to the original CSV.
let processed = u64::try_from(ROW.len() + 1 - range_offset + HEADER.len()).expect("raw range length");
assert_eq!(measured.bytes_scanned, processed);
assert_eq!(measured.bytes_processed, processed);
} else {
assert_eq!(measured.bytes_scanned, u64::try_from(raw_size).expect("raw length"));
assert_eq!(measured.bytes_processed, u64::try_from(data.len()).expect("decoded length"));
}
}
}
#[tokio::test]
async fn compressed_object_uses_one_full_stream_and_rejects_internal_ranges() {
const BUCKET: &str = "s3select-compressed-object";
+25 -1
View File
@@ -456,7 +456,12 @@ impl SessionCtxFactory {
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
let metered_input_requires_single_file_scan =
input_metrics.is_some() && context.input.request.input_serialization.parquet.is_none();
let config = if custom_two_byte_record_delimiter
let normalized_csv_requires_single_file_scan =
context.input.request.input_serialization.csv.as_ref().is_some_and(|csv| {
crate::csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
});
let config = if normalized_csv_requires_single_file_scan
|| custom_two_byte_record_delimiter
|| scan_range_requires_single_file_scan
|| json_document_requires_single_file_scan
|| compressed_input_requires_single_file_scan
@@ -906,6 +911,25 @@ mod tests {
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
}
#[tokio::test]
async fn unicode_csv_quotes_disable_file_scan_repartition() {
let mut context = test_context();
Arc::get_mut(&mut context.input)
.expect("unique context")
.request
.input_serialization
.csv
.as_mut()
.expect("CSV input")
.quote_character = Some("ع".to_owned());
let session = SessionCtxFactory::new(true)
.with_target_partitions(4)
.create_session_ctx(&context)
.await
.expect("Unicode CSV session");
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
}
#[tokio::test]
async fn two_byte_csv_record_delimiter_disables_file_scan_repartition() {
let mut context = test_context();
+139 -23
View File
@@ -53,7 +53,7 @@ use rustfs_s3select_api::{
},
},
};
use s3s::dto::{CompressionType, FileHeaderInfo, JSONType, SelectObjectContentInput};
use s3s::dto::{FileHeaderInfo, JSONType, SelectObjectContentInput};
use std::sync::LazyLock;
use tokio::{
sync::Semaphore,
@@ -430,13 +430,6 @@ impl SimpleQueryDispatcher {
let path = format!("s3://{}/{}", self.input.bucket, self.input.key);
let table_path = ListingTableUrl::parse(path)?;
let compressed_input = self
.input
.request
.input_serialization
.compression_type
.as_ref()
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
let (listing_options, need_rename_volume_name, need_ignore_volume_name) =
if let Some(csv) = self.input.request.input_serialization.csv.as_ref() {
let mut need_rename_volume_name = false;
@@ -485,28 +478,27 @@ impl SimpleQueryDispatcher {
if let Some(quote) = csv.quote_character.as_ref() {
file_format = file_format.with_quote(quote.as_bytes().first().copied().unwrap_or_default());
}
if rustfs_s3select_api::csv_input_requires_normalization(
csv.quote_character.as_deref(),
csv.quote_escape_character.as_deref(),
) {
file_format = file_format
.with_quote(b'"')
.with_escape(None)
.with_delimiter(b',')
.with_terminator(Some(b'\n'))
.with_comment(None)
.with_newlines_in_values(true);
}
(
ListingOptions::new(Arc::new(file_format)).with_file_extension(if compressed_input {
EXACT_OBJECT_FILE_EXTENSION
} else {
".csv"
}),
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
need_rename_volume_name,
need_ignore_volume_name,
)
} else if self.input.request.input_serialization.json.is_some() {
let file_format = JsonFormat::default();
let file_extension = if compressed_input {
EXACT_OBJECT_FILE_EXTENSION.to_string()
} else {
std::path::Path::new(&self.input.key)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| format!(".{extension}"))
.unwrap_or_else(|| ".json".to_string())
};
(
ListingOptions::new(Arc::new(file_format)).with_file_extension(file_extension),
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
false,
false,
)
@@ -1531,6 +1523,130 @@ mod tests {
assert_eq!(error.select_error(), SelectError::InvalidDataSource);
}
#[tokio::test]
async fn unicode_csv_quotes_reach_arrow_without_changing_field_values() {
let cases = [
("ع", "\"", ",", "\n", "عcol1ع,عcol2ع,عcol3ع\n", vec![vec!["col1", "col2", "col3"]]),
(
"ع",
"\\",
",",
"\n",
"\"literal\",عA\\\"Bع,عAععBع\n",
vec![vec!["\"literal\"", "A\"B", "AعB"]],
),
("\"", "", ",", "\n", "\"A界\"B\",🦀\n", vec![vec!["A\"B", "🦀"]]),
("ع", "\\", "", "^Y", "عa界bع界عline\nbreakع^Y", vec![vec!["a界b", "line\nbreak"]]),
];
let env = snapshot_test_env().await;
for (index, (quote, escape, field, record, data, expected)) in cases.into_iter().enumerate() {
let mut input = test_input();
input.bucket = format!("select-unicode-quotes-{index}");
input.key = "records".to_owned();
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::NONE));
csv.quote_character = Some(quote.to_owned());
csv.quote_escape_character = Some(escape.to_owned());
csv.field_delimiter = Some(field.to_owned());
csv.record_delimiter = Some(record.to_owned());
env.make_bucket(&input.bucket, false).await;
env.put_object_bytes(&input.bucket, &input.key, data.as_bytes().to_vec())
.await;
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
let input = Arc::new(input);
let dispatcher = production_dispatcher(Arc::clone(&input));
let query = Query::new_with_snapshot(QueryContext { input }, "SELECT * FROM S3Object".to_owned(), snapshot);
let output = dispatcher.execute_query(&query).await.expect("execute Unicode CSV query");
let mut stream = output.into_record_batch_stream().expect("record stream");
let mut rows = Vec::new();
while let Some(batch) = stream.next().await {
let batch = batch.expect("Arrow must receive valid UTF-8 fields");
for row in 0..batch.num_rows() {
rows.push(
batch
.columns()
.iter()
.map(|column| {
column
.as_any()
.downcast_ref::<StringArray>()
.expect("CSV string column")
.value(row)
.to_owned()
})
.collect::<Vec<_>>(),
);
}
}
assert_eq!(rows, expected, "fixture={index}");
}
}
#[tokio::test]
async fn select_uses_input_serialization_independently_of_object_extension() {
for (key, json) in [
("records", false),
("records.bin", false),
("records", true),
("records.csv", true),
] {
let mut input = test_input();
input.key = key.to_owned();
let data = if json {
input.request.input_serialization.csv = None;
input.request.input_serialization.json = Some(s3s::dto::JSONInput {
type_: Some(JSONType::from_static(JSONType::LINES)),
});
b"{\"value\":\"selected\"}\n".as_slice()
} else {
b"value\nselected\n".as_slice()
};
let input = Arc::new(input);
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
let dispatcher = test_dispatcher_for_input(
Arc::clone(&input),
Arc::new(Semaphore::new(1)),
Duration::from_secs(30),
Arc::new(SqlQueryExecutionFactory::new(optimizer, Arc::new(LocalScheduler {}))),
);
let query = Query::new(QueryContext { input }, "SELECT * FROM S3Object".to_owned());
let machine = dispatcher.build_query_state_machine(query).await.expect("build query state");
let store_url = ObjectStoreUrl::parse("s3://test-bucket").expect("test store URL");
let store = machine
.session
.inner()
.runtime_env()
.object_store(&store_url)
.expect("test store");
store.put(&Path::from(key), data.into()).await.expect("write selected object");
store
.put(&Path::from(format!("{key}.other")), b"unrelated\nwrong\n".as_slice().into())
.await
.expect("write neighboring object");
let plan = dispatcher
.build_logical_plan(Arc::clone(&machine))
.await
.expect("infer schema without an extension filter")
.expect("select plan");
let output = dispatcher
.execute_logical_plan(plan, machine)
.await
.expect("execute selected object");
let mut stream = output.into_record_batch_stream().expect("record stream");
let mut values = Vec::new();
while let Some(batch) = stream.next().await {
let batch = batch.expect("selected batch");
let column = batch
.column(0)
.as_any()
.downcast_ref::<datafusion::arrow::array::StringArray>()
.expect("string column");
values.extend(column.iter().map(|value| value.expect("selected value").to_owned()));
}
assert_eq!(values, ["selected"], "key={key}, json={json}");
}
}
#[tokio::test]
async fn csv_query_uses_custom_record_delimiter_across_file_partitions() {
const ROW_COUNT: usize = 200_000;
+13 -4
View File
@@ -28,10 +28,10 @@ use metrics::{counter, describe_counter, describe_histogram, histogram};
use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS;
pub use rustfs_data_usage::{
AllTierStats, BucketTargetUsageInfo, BucketUsageInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSnapshotSetState, LEGACY_DATA_USAGE_OBJECT_NAME,
PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary, SizeReconciliationEntry,
SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER, UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP,
UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSegmentInvalidationProof, DataUsageSnapshotSetState,
LEGACY_DATA_USAGE_OBJECT_NAME, PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary,
SizeReconciliationEntry, SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER,
UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP, UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
};
use rustfs_heal_contracts::heal_channel::HealScanMode;
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
@@ -657,6 +657,11 @@ pub struct DataUsageCacheInfo {
/// structural plan remains reusable across ordinary bucket writes.
#[serde(default)]
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
/// Process-epoch and generation window that produced a complete set cache
/// with all known segment invalidation producers wired. This proof is
/// additive compatibility metadata; absence keeps segment reuse disabled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
/// Durable bucket incarnations captured for a complete set aggregate.
/// Missing or nil entries are legacy/unproven and cannot authorize
/// skipping an unselected bucket in a later scoped set scan.
@@ -686,6 +691,7 @@ impl Serialize for DataUsageCacheInfo {
+ usize::from(self.lkg_leader_epoch.is_some())
+ usize::from(self.lkg_scan_plan_digest.is_some())
+ usize::from(self.scan_execution_digest.is_some())
+ usize::from(self.segment_invalidation_proof.is_some())
+ usize::from(!self.scan_bucket_incarnations.is_empty());
let mut state = serializer.serialize_map(Some(field_count))?;
state.serialize_entry("name", &self.name)?;
@@ -746,6 +752,9 @@ impl Serialize for DataUsageCacheInfo {
if let Some(scan_execution_digest) = self.scan_execution_digest {
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
}
if let Some(proof) = &self.segment_invalidation_proof {
state.serialize_entry("segment_invalidation_proof", proof)?;
}
if !self.scan_bucket_incarnations.is_empty() {
state.serialize_entry("scan_bucket_incarnations", &self.scan_bucket_incarnations)?;
}
@@ -1095,6 +1095,7 @@ fn test_data_usage_cache_info_deserialize_defaults_scan_resume_after() {
assert!(!decoded.snapshot_complete);
assert!(decoded.scan_plan_digest.is_none());
assert!(decoded.scan_execution_digest.is_none());
assert!(decoded.segment_invalidation_proof.is_none());
assert_eq!(decoded.cache_key_format, 0);
}
@@ -1183,6 +1184,13 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
snapshot_complete: true,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
segment_invalidation_proof: Some(DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 7,
generation_end: 9,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
}),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
..Default::default()
},
@@ -1212,6 +1220,16 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
assert!(current.info.snapshot_complete);
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
assert_eq!(
current.info.segment_invalidation_proof,
Some(DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 7,
generation_end: 9,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
})
);
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
+3 -3
View File
@@ -97,9 +97,9 @@ pub use scanner::{
pub use scanner_io::{
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
acknowledge_dirty_usage_generation, acknowledge_scoped_dirty_usage, clear_dirty_usage_bucket, record_dirty_usage_bucket,
record_dirty_usage_bucket_from_producer, record_dirty_usage_object, record_dirty_usage_object_from_producer,
record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_snapshot, scanner_dirty_usage_state,
scanner_maintenance_generation,
record_dirty_usage_bucket_from_producer, record_dirty_usage_bucket_from_producers, record_dirty_usage_object,
record_dirty_usage_object_from_producer, record_scanner_maintenance_change, scanner_activity_epoch,
scanner_dirty_usage_snapshot, scanner_dirty_usage_state, scanner_maintenance_generation,
};
pub use segment_invalidation::SegmentInvalidationProducerIdentity;
pub use sleeper::{DynamicSleeper, SCANNER_IDLE_MODE, SCANNER_SLEEPER};
+14 -2
View File
@@ -964,9 +964,10 @@ async fn run_scanner_usage_recovery_intents_for_startup(
Ok(attempted)
}
/// Start normal scanning when enabled, or one resume-only cleanup attempt.
/// Start normal scanning when enabled, or one bounded recovery attempt.
/// The disabled branch returns a finite task for the startup owner to join;
/// it never enables ordinary namespace scanning or accepts a new reset intent.
/// it never enables ordinary namespace scanning while it replays durable reset
/// intents and cleanup markers.
pub async fn init_scanner_with_recovery(
ctx: CancellationToken,
storeapi: Arc<ECStore>,
@@ -988,6 +989,17 @@ pub async fn init_scanner_with_recovery(
return None;
}
Some(tokio::spawn(async move {
if let Err(error) = run_scanner_usage_recovery_intents_for_startup(ctx.clone(), storeapi.clone()).await {
warn!(
target: "rustfs::scanner",
event = EVENT_SCANNER_PERSIST_STATE,
component = LOG_COMPONENT_SCANNER,
subsystem = LOG_SUBSYSTEM_RUNTIME,
state = "recovery_intent_startup_discovery_failed",
error = %error,
"Scanner recovery intent startup discovery failed"
);
}
if let Err(error) = resume_scanner_cycle_cleanup(ctx, storeapi).await {
warn!(
target: "rustfs::scanner",
+98 -1
View File
@@ -128,6 +128,79 @@ pub(super) mod cleanup_io_fault {
}
}
#[cfg(test)]
pub(super) mod recovery_intent_accept_fault {
use super::*;
enum Fault {
Corrupt,
Running,
}
static NEXT_ACCEPT_READBACK_FAULT: StdMutex<Option<Fault>> = StdMutex::new(None);
pub(in crate::scanner) struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
*NEXT_ACCEPT_READBACK_FAULT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
}
pub(in crate::scanner) fn corrupt_next_accept_readback() -> Guard {
install(Fault::Corrupt)
}
pub(in crate::scanner) fn advance_next_accept_readback_to_running() -> Guard {
install(Fault::Running)
}
fn install(fault: Fault) -> Guard {
let mut slot = NEXT_ACCEPT_READBACK_FAULT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert!(slot.is_none(), "only one recovery intent accept readback fault may be installed");
*slot = Some(fault);
Guard
}
pub(super) async fn maybe_apply<S>(storeapi: Arc<S>, path: &str) -> Result<(), ScannerError>
where
S: ScannerObjectIO,
{
let Some(fault) = NEXT_ACCEPT_READBACK_FAULT
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.take()
else {
return Ok(());
};
match fault {
Fault::Corrupt => save_config(storeapi, path, b"{corrupt".to_vec()).await.map_err(|err| {
ScannerError::Other(format!("failed to inject scanner recovery intent accept readback fault: {err}"))
}),
Fault::Running => {
let mut record = read_recovery_intent_record(storeapi.clone(), path).await?.ok_or_else(|| {
ScannerError::Other("scanner recovery intent disappeared before fault injection".to_string())
})?;
record.state = SCANNER_RECOVERY_INTENT_STATE_RUNNING.to_string();
save_config(
storeapi,
path,
serde_json::to_vec(&record)
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent fault: {err}")))?,
)
.await
.map_err(|err| {
ScannerError::Other(format!("failed to inject scanner recovery intent accept readback fault: {err}"))
})
}
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ScannerCycleRecoveryStatus {
/// The immutable primary object whose revision is being guarded.
@@ -564,6 +637,17 @@ fn compare_recovery_intent(
}
}
fn confirm_recovery_intent_acceptance(
expected: ScannerRecoveryIntentRecord,
persisted: ScannerRecoveryIntentRecord,
) -> ScannerRecoveryIntentAcceptResult {
if persisted == expected {
ScannerRecoveryIntentAcceptResult::Accepted { record: persisted }
} else {
compare_recovery_intent(&expected, persisted)
}
}
async fn read_recovery_intent_record(
storeapi: Arc<impl ScannerObjectIO>,
path: &str,
@@ -757,7 +841,20 @@ pub async fn accept_scanner_usage_recovery_intent(
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent: {err}")))?;
match save_config_with_preconditions(storeapi.clone(), &path, encoded, DataUsageCacheRevision::Missing.preconditions()).await
{
Ok(_) => Ok(ScannerRecoveryIntentAcceptResult::Accepted { record: candidate }),
Ok(_) => {
#[cfg(test)]
recovery_intent_accept_fault::maybe_apply(storeapi.clone(), &path).await?;
let persisted = match read_recovery_intent_record(storeapi.clone(), &path).await {
Ok(Some(record)) => record,
Ok(None) => {
return Err(ScannerError::Other(
"scanner recovery intent disappeared before acceptance confirmation".to_string(),
));
}
Err(error) => return Err(error),
};
Ok(confirm_recovery_intent_acceptance(candidate, persisted))
}
Err(EcstoreError::PreconditionFailed) => {
let existing = read_recovery_intent_record(storeapi, &path).await?;
let Some(existing) = existing else {
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::super::cycle_state::cleanup_io_fault;
use super::super::cycle_state::{cleanup_io_fault, recovery_intent_accept_fault};
use super::*;
use crate::storage_api::owner::{EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats};
@@ -256,6 +256,100 @@ async fn scanner_recovery_intent_accept_is_durable_and_idempotent() {
assert_eq!(replay, ScannerRecoveryIntentAcceptResult::Replayed { record });
}
#[tokio::test]
#[serial]
async fn scanner_recovery_intent_accept_requires_confirmed_readback() {
let (_dir, store) = setup_scanner_cycle_store().await;
let _fault = recovery_intent_accept_fault::corrupt_next_accept_readback();
let error =
accept_scanner_usage_recovery_intent(store.clone(), recovery_intent_request("intent-key-0001-readback", "operator-a"))
.await
.expect_err("accept must fail when the just-written intent cannot be confirmed");
assert!(error.to_string().contains("scanner recovery intent is invalid"), "{error}");
let restarted = restart_scanner_cycle_store_from(&store).await;
let error = scanner_usage_recovery_intents_for_startup(&CancellationToken::new(), restarted)
.await
.expect_err("unconfirmed corrupt intent must remain a fail-closed startup error");
assert!(error.to_string().contains("scanner recovery intent is invalid"), "{error}");
}
#[tokio::test]
#[serial]
async fn scanner_recovery_intent_accept_replays_if_execution_advances_before_readback() {
let (_dir, store) = setup_scanner_cycle_store().await;
let request = recovery_intent_request("intent-key-0001-running", "operator-a");
let _fault = recovery_intent_accept_fault::advance_next_accept_readback_to_running();
let replay = accept_scanner_usage_recovery_intent(store.clone(), request.clone())
.await
.expect("same request advanced by execution remains idempotent");
let record = match replay {
ScannerRecoveryIntentAcceptResult::Replayed { record } => record,
other => panic!("advanced same-request record must replay instead of conflict: {other:?}"),
};
assert_eq!(record.state, "running");
let retry = accept_scanner_usage_recovery_intent(store, request)
.await
.expect("lost response retry observes the running durable record");
assert_eq!(retry, ScannerRecoveryIntentAcceptResult::Replayed { record });
}
#[tokio::test]
#[serial]
async fn concurrent_scanner_recovery_intent_acceptance_uses_one_durable_record() {
let (_dir, store) = setup_scanner_cycle_store().await;
let request = recovery_intent_request("intent-key-0001-concurrent", "operator-a");
let mut tasks = Vec::new();
for _ in 0..12 {
let store = store.clone();
let request = request.clone();
tasks.push(tokio::spawn(async move {
accept_scanner_usage_recovery_intent(store, request)
.await
.expect("concurrent same-key accept should converge")
}));
}
let mut accepted = 0usize;
let mut replayed = 0usize;
let mut records = Vec::new();
for task in tasks {
match task.await.expect("accept task should not panic") {
ScannerRecoveryIntentAcceptResult::Accepted { record } => {
accepted += 1;
records.push(record);
}
ScannerRecoveryIntentAcceptResult::Replayed { record } => {
replayed += 1;
records.push(record);
}
other => panic!("same-key accepts must not conflict: {other:?}"),
}
}
assert_eq!(accepted, 1, "exactly one request may win the missing-record CAS");
assert_eq!(replayed, 11, "all other same-key requests must replay the durable winner");
assert!(
records.windows(2).all(|pair| pair[0] == pair[1]),
"all accepts must return the same durable identity"
);
let restarted = restart_scanner_cycle_store_from(&store).await;
let replayable = scanner_usage_recovery_intents_for_startup(&CancellationToken::new(), restarted.clone())
.await
.expect("startup should rediscover the single indexed intent");
assert_eq!(replayable, vec![records[0].intent_id.clone()]);
let replay = accept_scanner_usage_recovery_intent(restarted, request)
.await
.expect("lost response after restart should replay the same record");
assert_eq!(
replay,
ScannerRecoveryIntentAcceptResult::Replayed {
record: records[0].clone()
}
);
}
#[tokio::test]
#[serial]
async fn scanner_recovery_intent_executor_persists_completed_progress() {
@@ -457,7 +551,7 @@ async fn scanner_recovery_intent_startup_rejects_corrupt_pending_record() {
#[tokio::test]
#[serial]
async fn scanner_recovery_intent_disabled_startup_preserves_non_terminal_intent() {
async fn scanner_recovery_intent_disabled_startup_replays_non_terminal_intent() {
let (_dir, store) = setup_scanner_cycle_store().await;
let record = match accept_scanner_usage_recovery_intent(
store.clone(),
@@ -473,12 +567,12 @@ async fn scanner_recovery_intent_disabled_startup_preserves_non_terminal_intent(
let restarted = restart_scanner_cycle_store_from(&store).await;
run_disabled_startup(CancellationToken::new(), restarted.clone()).await;
let preserved = get_scanner_usage_recovery_intent(restarted, &record.intent_id)
let completed = get_scanner_usage_recovery_intent(restarted, &record.intent_id)
.await
.expect("startup-skipped intent should read")
.expect("startup-skipped intent should remain durable");
assert_eq!(preserved.state, "accepted");
assert_eq!(preserved.intent_id, record.intent_id);
.expect("startup-replayed intent should read")
.expect("startup-replayed intent should remain durable");
assert_eq!(completed.state, "completed");
assert_eq!(completed.intent_id, record.intent_id);
}
#[tokio::test]
@@ -547,6 +641,17 @@ async fn scanner_recovery_intent_query_rejects_corrupt_or_unknown_records() {
.await
.expect_err("corrupt intent must not decode as absent");
assert!(error.to_string().contains("scanner recovery intent is invalid"));
let mut future = serde_json::to_value(&record).expect("record value");
future["future_writer_capability"] = serde_json::json!("durable-accept-v2");
save_config(store.clone(), &path, serde_json::to_vec(&future).expect("future record should encode"))
.await
.expect("future durable record");
let error = get_scanner_usage_recovery_intent(store.clone(), &record.intent_id)
.await
.expect_err("future writer payload must not decode as a known terminal state");
assert!(error.to_string().contains("scanner recovery intent is invalid"));
let unknown = get_scanner_usage_recovery_intent(store, &scanner_recovery_actor_sha256("missing"))
.await
.expect("missing intent should read as absent");
+321 -7
View File
@@ -39,7 +39,7 @@ use s3s::dto::{
BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration, VersioningConfiguration,
};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
@@ -98,6 +98,24 @@ const METRIC_SCANNER_SET_SCANS_QUEUED: &str = "rustfs_scanner_set_scans_queued";
const METRIC_SCANNER_DISK_BUCKET_SCANS_ACTIVE: &str = "rustfs_scanner_disk_bucket_scans_active";
const METRIC_SCANNER_DISK_BUCKET_SCANS_QUEUED: &str = "rustfs_scanner_disk_bucket_scans_queued";
pub(crate) const SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS: [&str; 7] = [
"source",
"bucket_incarnation",
"key_format",
"baseline_scan_plan_digest",
"process_epoch",
"generation_window",
"producer_identities",
];
pub(crate) const SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS: [&str; 6] = [
"missing_producer_identity",
"restart_gap",
"generation_gap",
"overflow",
"missing_cold_zero_walk_oracle",
"distributed_without_peer_invalidation",
];
pub type DirtyUsageBuckets = HashMap<String, u64>;
#[derive(Clone, Debug)]
@@ -163,11 +181,52 @@ struct ScannerPeerDirtyUsageExpectation {
struct VerifiedRemoteDirtyUsage {
dirty_buckets: HashSet<String>,
acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
peer_count: usize,
dirty_peer_count: usize,
}
struct ScannerBucketScopeResolutionResult {
scope: ScannerBucketScanScope,
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct DistributedSegmentInvalidationEvidence {
pub(crate) invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain,
pub(crate) distributed_ec_invalidation: bool,
pub(crate) peer_count: usize,
pub(crate) dirty_peer_count: usize,
pub(crate) same_window_remote_proof: bool,
pub(crate) all_peers_bound_to_generation_window: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct ScannerSegmentReuseActivationProof {
pub(crate) production_activation: bool,
pub(crate) producer_identity_coverage_complete: bool,
pub(crate) durable_producer_identity: bool,
pub(crate) restart_gap_absent: bool,
pub(crate) generation_window_bound: bool,
pub(crate) overflow_absent: bool,
pub(crate) cold_zero_walk_oracle: bool,
pub(crate) distributed_peer_invalidation: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ScannerSegmentReuseActivationPreflight {
pub(crate) production_activation: bool,
pub(crate) scanner_segment_reuse_activated: bool,
pub(crate) proof_inputs: &'static [&'static str],
pub(crate) fail_closed_checks: &'static [&'static str],
pub(crate) fail_closed_blockers: [Option<&'static str>; 6],
}
impl ScannerSegmentReuseActivationPreflight {
#[cfg(test)]
pub(crate) fn fail_closed_blockers(&self) -> impl Iterator<Item = &'static str> + '_ {
self.fail_closed_blockers.iter().filter_map(|blocker| *blocker)
}
}
fn verified_remote_dirty_usage(
@@ -191,6 +250,7 @@ fn verified_remote_dirty_usage(
|| !snapshot.complete
|| snapshot.pending_bucket_count != u64::try_from(snapshot.buckets.len()).unwrap_or(u64::MAX)
|| (expected.pending && snapshot.pending_bucket_count == 0)
|| (!expected.pending && snapshot.pending_bucket_count != 0)
{
return None;
}
@@ -216,9 +276,13 @@ fn verified_remote_dirty_usage(
}
}
let peer_count = received_peers.len();
let dirty_peer_count = acknowledgements.len();
(received_peers.len() == expected_peers.len()).then_some(VerifiedRemoteDirtyUsage {
dirty_buckets,
acknowledgements,
peer_count,
dirty_peer_count,
})
}
@@ -244,8 +308,11 @@ fn resolve_remote_dirty_usage_scope(
let default_result = |scope: ScannerBucketScanScope| ScannerBucketScopeResolutionResult {
scope,
remote_dirty_usage_acknowledgements: Vec::new(),
distributed_segment_invalidation_evidence: None,
};
let peer_count = remote_dirty_usage.peer_count;
let dirty_peer_count = remote_dirty_usage.dirty_peer_count;
dirty_buckets.extend(remote_dirty_usage.dirty_buckets);
// Peer snapshots contribute bucket names only; the local prefix scopes
// would narrow a bucket a peer dirtied elsewhere, so the merged scope
@@ -283,10 +350,21 @@ fn resolve_remote_dirty_usage_scope(
if scanner_scoped_dirty_usage_ack_exceeds_cost_threshold(&scoped_acknowledgements) {
return default_result(ScannerBucketScanScope::default());
}
let has_scoped_acknowledgements = !scoped_acknowledgements.is_empty();
ScannerBucketScopeResolutionResult {
scope,
remote_dirty_usage_acknowledgements: scoped_acknowledgements,
distributed_segment_invalidation_evidence: (dirty_peer_count > 0 && has_scoped_acknowledgements).then_some(
DistributedSegmentInvalidationEvidence {
invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc,
distributed_ec_invalidation: true,
peer_count,
dirty_peer_count,
same_window_remote_proof: true,
all_peers_bound_to_generation_window: true,
},
),
}
}
@@ -358,6 +436,60 @@ fn complete_scanner_cache_baseline_plan_digest(proof: ScannerCacheBaselineProof<
complete_scanner_cache_snapshot_plan_digest(&observed, proof, false)
}
fn scanner_segment_reuse_baseline_producer_evidence(
dirty_usage_snapshot: &DirtyUsageSnapshot,
baseline_proof: ScannerCacheBaselineProof<'_>,
) -> (DirtyUsageProducerEvidence, bool) {
let mut evidence = dirty_usage_producer_evidence(dirty_usage_snapshot);
if !evidence.generation_window_bound || !evidence.producer_identity_coverage_complete {
return (evidence, false);
}
let Some(authoritative_data) = baseline_proof.authoritative_data else {
return (evidence, false);
};
let Ok(authoritative) = serde_json::from_slice::<DataUsageInfo>(authoritative_data) else {
return (evidence, false);
};
if complete_scanner_cache_snapshot_plan_digest(&authoritative, baseline_proof, true).is_none()
|| !scanner_snapshot_set_states_have_segment_reuse_activation_proof(
&authoritative,
&evidence,
baseline_proof.expected_sources,
)
{
return (evidence, false);
}
evidence.durable_producer_identity = true;
evidence.restart_gap_absent = true;
(evidence, true)
}
fn scanner_snapshot_set_states_have_segment_reuse_activation_proof(
snapshot: &DataUsageInfo,
evidence: &DirtyUsageProducerEvidence,
expected_sources: &HashSet<DataUsageCacheSource>,
) -> bool {
let mut covered_sources = HashSet::with_capacity(expected_sources.len());
let all_sets_proved = snapshot.usage_snapshot_set_states.iter().all(|state| {
let (Ok(pool_index), Ok(set_index)) = (usize::try_from(state.pool_index), usize::try_from(state.set_index)) else {
return false;
};
let source = DataUsageCacheSource::new(pool_index, set_index);
state.complete
&& !state.tombstone
&& expected_sources.contains(&source)
&& covered_sources.insert(source)
&& scanner_segment_invalidation_proof_matches(state.segment_invalidation_proof.as_ref(), evidence)
&& state
.segment_invalidation_proof
.as_ref()
.is_some_and(|proof| proof.cold_zero_walk_oracle)
});
all_sets_proved && covered_sources.len() == expected_sources.len()
}
fn scoped_scan_scope_from_dirty_buckets(
requested_scope: ScannerBucketScanScope,
dirty_buckets: HashSet<String>,
@@ -406,10 +538,152 @@ fn scoped_scan_scope_from_dirty_buckets(
ScannerBucketScanScope::from_dirty_buckets(selected_buckets, selected_bucket_prefixes, baseline_scan_plan_digest)
}
fn scanner_segment_reuse_activation_preflight() -> ScannerSegmentReuseActivationPreflight {
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof {
production_activation: true,
..Default::default()
})
}
fn scanner_segment_reuse_activation_preflight_from_proof(
proof: ScannerSegmentReuseActivationProof,
) -> ScannerSegmentReuseActivationPreflight {
ScannerSegmentReuseActivationPreflight {
production_activation: proof.production_activation,
scanner_segment_reuse_activated: proof.production_activation
&& proof.producer_identity_coverage_complete
&& proof.durable_producer_identity
&& proof.restart_gap_absent
&& proof.generation_window_bound
&& proof.overflow_absent
&& proof.cold_zero_walk_oracle
&& proof.distributed_peer_invalidation,
proof_inputs: &SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS,
fail_closed_checks: &SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS,
fail_closed_blockers: [
(!proof.producer_identity_coverage_complete || !proof.durable_producer_identity)
.then_some("missing_producer_identity"),
(!proof.restart_gap_absent).then_some("restart_gap"),
(!proof.generation_window_bound).then_some("generation_gap"),
(!proof.overflow_absent).then_some("overflow"),
(!proof.cold_zero_walk_oracle).then_some("missing_cold_zero_walk_oracle"),
(!proof.distributed_peer_invalidation).then_some("distributed_without_peer_invalidation"),
],
}
}
fn scanner_segment_reuse_activation_preflight_for_cycle(
dirty_usage_snapshot: &DirtyUsageSnapshot,
dirty_usage_producer_evidence: DirtyUsageProducerEvidence,
distributed: bool,
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
cold_zero_walk_oracle: bool,
) -> ScannerSegmentReuseActivationPreflight {
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof {
production_activation: true,
producer_identity_coverage_complete: dirty_usage_producer_evidence.producer_identity_coverage_complete,
durable_producer_identity: dirty_usage_producer_evidence.durable_producer_identity,
restart_gap_absent: dirty_usage_producer_evidence.restart_gap_absent,
generation_window_bound: dirty_usage_snapshot.covers_all_pending
&& dirty_usage_snapshot.generation != 0
&& dirty_usage_snapshot.generation != u64::MAX
&& dirty_usage_producer_evidence.generation_window_bound,
overflow_absent: dirty_usage_snapshot.covers_all_pending,
cold_zero_walk_oracle,
distributed_peer_invalidation: scanner_distributed_segment_invalidation_admitted(
distributed,
distributed_segment_invalidation_evidence,
),
})
}
fn scanner_segment_reuse_activation_preflight_for_baseline(
dirty_usage_snapshot: &DirtyUsageSnapshot,
distributed: bool,
baseline_proof: ScannerCacheBaselineProof<'_>,
) -> ScannerSegmentReuseActivationPreflight {
let (dirty_usage_producer_evidence, cold_zero_walk_oracle) =
scanner_segment_reuse_baseline_producer_evidence(dirty_usage_snapshot, baseline_proof);
scanner_segment_reuse_activation_preflight_for_cycle(
dirty_usage_snapshot,
dirty_usage_producer_evidence,
distributed,
None,
cold_zero_walk_oracle,
)
}
fn scanner_distributed_segment_invalidation_admitted(
distributed: bool,
evidence: Option<DistributedSegmentInvalidationEvidence>,
) -> bool {
if !distributed {
return true;
}
evidence.is_some_and(|evidence| {
evidence.invalidation_domain == crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc
&& evidence.distributed_ec_invalidation
&& evidence.same_window_remote_proof
&& evidence.all_peers_bound_to_generation_window
&& evidence.dirty_peer_count > 0
&& evidence.dirty_peer_count <= evidence.peer_count
})
}
fn scanner_durable_segment_invalidation_evidence(
dirty_usage_snapshot: &DirtyUsageSnapshot,
results: &[DataUsageCache],
expected_sources: &HashSet<DataUsageCacheSource>,
) -> DirtyUsageProducerEvidence {
let mut evidence = dirty_usage_producer_evidence(dirty_usage_snapshot);
if !evidence.generation_window_bound
|| !evidence.producer_identity_coverage_complete
|| !scanner_results_form_complete_snapshot(results, expected_sources)
{
return evidence;
}
let mut covered_sources = HashSet::with_capacity(expected_sources.len());
let all_sets_proved = results.iter().all(|result| {
let Some(source) = result.info.source else {
return false;
};
expected_sources.contains(&source)
&& covered_sources.insert(source)
&& scanner_segment_invalidation_proof_matches(result.info.segment_invalidation_proof.as_ref(), &evidence)
});
if all_sets_proved && covered_sources.len() == expected_sources.len() {
evidence.durable_producer_identity = true;
evidence.restart_gap_absent = true;
}
evidence
}
fn scanner_segment_invalidation_proof_matches(
proof: Option<&crate::DataUsageSegmentInvalidationProof>,
evidence: &DirtyUsageProducerEvidence,
) -> bool {
proof.is_some_and(|proof| {
proof.process_epoch == scanner_activity_epoch()
&& proof.generation_start == evidence.generation_start
&& proof.generation_end == evidence.generation_end
&& proof.producer_identity_coverage_complete
})
}
fn scanner_completed_set_segment_invalidation_proof(
proof: &Option<crate::DataUsageSegmentInvalidationProof>,
cold_zero_walk_reuse_candidate: bool,
) -> Option<crate::DataUsageSegmentInvalidationProof> {
proof.clone().map(|mut proof| {
proof.cold_zero_walk_oracle = cold_zero_walk_reuse_candidate;
proof
})
}
#[cfg(test)]
fn scanner_segment_reuse_activated() -> bool {
// Production segment reuse stays disabled until a durable mutation-stream
// proof satisfies the segment invalidation contract.
false
scanner_segment_reuse_activation_preflight().scanner_segment_reuse_activated
}
pub(crate) fn is_scanner_metadata_corrupt_error(err: &StorageError) -> bool {
@@ -471,6 +745,8 @@ pub struct ScannerBucketScanPlan {
bucket_failures: ScannerBucketFailureState,
pending_maintenance_work: Arc<AtomicBool>,
cache_cycle_floor: Arc<AtomicU64>,
cold_zero_walk_reuse_observed: Arc<AtomicBool>,
segment_invalidation_proof: Option<crate::DataUsageSegmentInvalidationProof>,
}
#[derive(Clone, Default)]
@@ -603,6 +879,25 @@ fn scanner_bucket_scan_status(has_failed: bool, has_partial: bool, has_namespace
}
}
fn scanner_cycle_cold_zero_walk_oracle(
scan_scope: &ScannerBucketScanScope,
all_buckets: &[BucketInfo],
completed_all_sets: bool,
scan_scope_matches: bool,
bucket_scan_status: ScannerBucketScanStatus,
cold_zero_walk_reuse_observed: bool,
) -> bool {
let Some(selected_buckets) = scan_scope.selected_buckets.as_deref() else {
return false;
};
cold_zero_walk_reuse_observed
&& !selected_buckets.is_empty()
&& completed_all_sets
&& scan_scope_matches
&& bucket_scan_status == ScannerBucketScanStatus::Complete
&& all_buckets.iter().any(|bucket| !selected_buckets.contains(&bucket.name))
}
fn classify_nsscanner_cycle(
completed_all_sets: bool,
budget_elapsed: bool,
@@ -1056,6 +1351,8 @@ pub(crate) struct ScannerCycleResult {
observational_snapshot_published: bool,
dirty_usage_clear: Option<DirtyUsageBuckets>,
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
segment_reuse_activation_preflight: ScannerSegmentReuseActivationPreflight,
remote_publication_lease_targets: Vec<(String, String, u64)>,
failed_dirty_usage: bool,
pending_maintenance_work: bool,
@@ -1072,6 +1369,8 @@ impl ScannerCycleResult {
observational_snapshot_published: false,
dirty_usage_clear,
remote_dirty_usage_acknowledgements: Vec::new(),
distributed_segment_invalidation_evidence: None,
segment_reuse_activation_preflight: scanner_segment_reuse_activation_preflight(),
remote_publication_lease_targets: Vec::new(),
failed_dirty_usage: false,
pending_maintenance_work: false,
@@ -1137,6 +1436,21 @@ impl ScannerCycleResult {
self
}
fn with_distributed_segment_invalidation_evidence(
mut self,
evidence: Option<DistributedSegmentInvalidationEvidence>,
) -> Self {
self.publication_expectation = None;
self.distributed_segment_invalidation_evidence = evidence;
self
}
fn with_segment_reuse_activation_preflight(mut self, preflight: ScannerSegmentReuseActivationPreflight) -> Self {
self.publication_expectation = None;
self.segment_reuse_activation_preflight = preflight;
self
}
pub(crate) fn with_remote_publication_lease_targets(mut self, targets: Vec<(String, String, u64)>) -> Self {
self.publication_expectation = None;
self.remote_publication_lease_targets = targets;
@@ -1224,9 +1538,9 @@ pub(crate) use cache::{
pub use dirty_usage::{
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
acknowledge_dirty_usage_generation, acknowledge_scoped_dirty_usage, clear_dirty_usage_bucket, record_dirty_usage_bucket,
record_dirty_usage_bucket_from_producer, record_dirty_usage_object, record_dirty_usage_object_from_producer,
record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_snapshot, scanner_dirty_usage_state,
scanner_maintenance_generation,
record_dirty_usage_bucket_from_producer, record_dirty_usage_bucket_from_producers, record_dirty_usage_object,
record_dirty_usage_object_from_producer, record_scanner_maintenance_change, scanner_activity_epoch,
scanner_dirty_usage_snapshot, scanner_dirty_usage_state, scanner_maintenance_generation,
};
#[cfg(test)]
pub(crate) use dirty_usage::{clear_dirty_usage_buckets_for_tests, dirty_usage_buckets_for_tests};
+6 -1
View File
@@ -464,6 +464,7 @@ pub(super) fn completed_usage_candidate(
scan_plan_digest: Some(result.info.scan_plan_digest?.0),
complete: true,
tombstone: false,
segment_invalidation_proof: result.info.segment_invalidation_proof.clone(),
})
})
.collect::<Option<Vec<_>>>()?;
@@ -642,13 +643,14 @@ pub(super) fn observational_data_usage_info(
let current_snapshot = current.is_some();
let selected = current.or(lkg);
if let Some(selected) = selected {
let (cycle, epoch, digest, last_update, complete) = if current_snapshot {
let (cycle, epoch, digest, last_update, complete, segment_invalidation_proof) = if current_snapshot {
(
Some(selected.info.next_cycle),
Some(selected.info.leader_epoch),
selected.info.scan_plan_digest.map(|digest| digest.0),
selected.info.last_update,
true,
selected.info.segment_invalidation_proof.clone(),
)
} else {
(
@@ -657,6 +659,7 @@ pub(super) fn observational_data_usage_info(
selected.info.lkg_scan_plan_digest.map(|digest| digest.0),
selected.info.lkg_last_update,
false,
None,
)
};
set_states.push(DataUsageSnapshotSetState {
@@ -667,6 +670,7 @@ pub(super) fn observational_data_usage_info(
scan_plan_digest: digest,
complete,
tombstone: false,
segment_invalidation_proof,
});
usable.push((selected, last_update));
} else {
@@ -678,6 +682,7 @@ pub(super) fn observational_data_usage_info(
scan_plan_digest: Some(expected_plan_digest.0),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
});
}
}
+122 -23
View File
@@ -16,17 +16,16 @@ use super::*;
pub(super) static DIRTY_USAGE_BUCKET_GENERATION: AtomicU64 = AtomicU64::new(0);
pub(super) static DIRTY_USAGE_BUCKETS: LazyLock<StdMutex<DirtyUsageBuckets>> = LazyLock::new(|| StdMutex::new(HashMap::new()));
// Lock order when both dirty maps are needed is `DIRTY_USAGE_BUCKETS` followed
// by `DIRTY_USAGE_BUCKET_SCOPES`. Both are held only for synchronous map
// updates, so no scanner task can observe a bucket generation without its
// matching scope.
// Lock order when dirty usage state is updated is `DIRTY_USAGE_BUCKETS`,
// `DIRTY_USAGE_BUCKET_SCOPES`, then `DIRTY_USAGE_PRODUCER_IDENTITIES`. All
// guards are held only for synchronous map updates, so no scanner task can
// observe a bucket generation without its matching scope and producer evidence.
pub(super) static DIRTY_USAGE_BUCKET_SCOPES: LazyLock<StdMutex<DirtyUsageBucketScopes>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
// Non-authoritative process-local producer coverage. Any future segment reuse
// activation must bind this to the exact generation window and durable proof.
pub(super) static DIRTY_USAGE_PRODUCER_IDENTITIES: LazyLock<
StdMutex<BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity>>,
> = LazyLock::new(|| StdMutex::new(BTreeSet::new()));
pub(super) static DIRTY_USAGE_PRODUCER_IDENTITIES: LazyLock<StdMutex<DirtyUsageProducerIdentities>> =
LazyLock::new(|| StdMutex::new(BTreeMap::new()));
pub(super) static DIRTY_USAGE_BUCKET_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
pub(super) static SCANNER_ACTIVITY_EPOCH: LazyLock<String> = LazyLock::new(|| format!("{:032x}", rand::random::<u128>()));
pub(super) static SCANNER_MAINTENANCE_GENERATION: AtomicU64 = AtomicU64::new(0);
@@ -59,6 +58,39 @@ pub(super) type DirtyUsageBucketScopes = HashMap<String, DirtyUsageBucketScope>;
const MAX_DIRTY_USAGE_TOP_LEVEL_ENTRIES_PER_BUCKET: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct DirtyUsageProducerIdentityState {
first_generation: u64,
last_generation: u64,
}
pub(super) type DirtyUsageProducerIdentities =
BTreeMap<crate::segment_invalidation::SegmentInvalidationProducerIdentity, DirtyUsageProducerIdentityState>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct DirtyUsageProducerEvidence {
pub(super) producer_identity_coverage_complete: bool,
pub(super) durable_producer_identity: bool,
pub(super) restart_gap_absent: bool,
pub(super) generation_window_bound: bool,
pub(super) generation_start: u64,
pub(super) generation_end: u64,
}
impl DirtyUsageProducerEvidence {
pub(super) fn segment_invalidation_proof(self) -> Option<crate::DataUsageSegmentInvalidationProof> {
(self.generation_window_bound && self.producer_identity_coverage_complete).then(|| {
crate::DataUsageSegmentInvalidationProof {
process_epoch: scanner_activity_epoch().to_string(),
generation_start: self.generation_start,
generation_end: self.generation_end,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: false,
}
})
}
}
/// A point-in-time view of the local dirty bucket generations.
///
/// `complete == false` is an all-or-nothing overflow signal: `buckets` is
@@ -226,12 +258,21 @@ mod scoped_dirty_usage_tests {
record_dirty_usage_object_from_producer("photos", "hot/object", SegmentInvalidationProducerIdentity::PutObject);
record_dirty_usage_object_from_producer("photos", "archive/object", SegmentInvalidationProducerIdentity::DeleteObject);
record_dirty_usage_bucket_from_producer("photos", SegmentInvalidationProducerIdentity::Unknown);
record_dirty_usage_bucket_from_producers(
"photos",
[
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
],
);
assert_eq!(
dirty_usage_producer_identities_for_tests(),
BTreeSet::from([
SegmentInvalidationProducerIdentity::PutObject,
SegmentInvalidationProducerIdentity::DeleteObject
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::AbortMultipartUpload
])
);
assert_eq!(
@@ -255,8 +296,7 @@ fn dirty_usage_bucket_scopes() -> MutexGuard<'static, DirtyUsageBucketScopes> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn dirty_usage_producer_identities()
-> MutexGuard<'static, BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity>> {
fn dirty_usage_producer_identities() -> MutexGuard<'static, DirtyUsageProducerIdentities> {
DIRTY_USAGE_PRODUCER_IDENTITIES
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
@@ -277,7 +317,7 @@ pub fn record_dirty_usage_bucket(bucket: &str) {
return;
}
record_dirty_usage_bucket_inner(bucket);
record_dirty_usage_bucket_inner(bucket, std::iter::empty());
}
pub fn record_dirty_usage_bucket_from_producer(
@@ -288,17 +328,32 @@ pub fn record_dirty_usage_bucket_from_producer(
return;
}
record_segment_invalidation_producer_identity(producer);
record_dirty_usage_bucket_inner(bucket);
record_dirty_usage_bucket_inner(bucket, [producer]);
}
fn record_dirty_usage_bucket_inner(bucket: &str) {
pub fn record_dirty_usage_bucket_from_producers<I>(bucket: &str, producers: I)
where
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
{
if bucket.is_empty() {
return;
}
record_dirty_usage_bucket_inner(bucket, producers);
}
fn record_dirty_usage_bucket_inner<I>(bucket: &str, producers: I)
where
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
{
let pending_buckets = {
let mut dirty_buckets = dirty_usage_buckets();
let mut dirty_scopes = dirty_usage_bucket_scopes();
let mut producer_identities = dirty_usage_producer_identities();
let generation = advance_generation(&DIRTY_USAGE_BUCKET_GENERATION);
dirty_buckets.insert(bucket.to_string(), generation);
dirty_scopes.insert(bucket.to_string(), DirtyUsageBucketScope::WholeBucket);
record_segment_invalidation_producer_identities_for_generation(&mut producer_identities, generation, producers);
dirty_buckets.len()
};
global_metrics().record_scanner_dirty_usage_pending(usize_to_u64_saturated(pending_buckets));
@@ -316,7 +371,7 @@ fn record_dirty_usage_bucket_inner(bucket: &str) {
/// local: after restart or any unverified distributed path the scanner falls
/// back to its ordinary bucket scan.
pub fn record_dirty_usage_object(bucket: &str, object: &str) {
record_dirty_usage_object_inner(bucket, object);
record_dirty_usage_object_inner(bucket, object, std::iter::empty());
}
pub fn record_dirty_usage_object_from_producer(
@@ -328,13 +383,16 @@ pub fn record_dirty_usage_object_from_producer(
return;
}
record_segment_invalidation_producer_identity(producer);
record_dirty_usage_object_inner(bucket, object);
record_dirty_usage_object_inner(bucket, object, [producer]);
}
fn record_dirty_usage_object_inner(bucket: &str, object: &str) {
fn record_dirty_usage_object_inner<I>(bucket: &str, object: &str, producers: I)
where
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
{
let producers = producers.into_iter().collect::<Vec<_>>();
let Some(top_level_entry) = dirty_usage_top_level_entry(object) else {
record_dirty_usage_bucket(bucket);
record_dirty_usage_bucket_inner(bucket, producers);
return;
};
if bucket.is_empty() {
@@ -344,6 +402,7 @@ fn record_dirty_usage_object_inner(bucket: &str, object: &str) {
let pending_buckets = {
let mut dirty_buckets = dirty_usage_buckets();
let mut dirty_scopes = dirty_usage_bucket_scopes();
let mut producer_identities = dirty_usage_producer_identities();
let generation = advance_generation(&DIRTY_USAGE_BUCKET_GENERATION);
dirty_buckets.insert(bucket.to_string(), generation);
let scope = dirty_scopes
@@ -359,6 +418,7 @@ fn record_dirty_usage_object_inner(bucket: &str, object: &str) {
if overflowed {
*scope = DirtyUsageBucketScope::WholeBucket;
}
record_segment_invalidation_producer_identities_for_generation(&mut producer_identities, generation, producers);
dirty_buckets.len()
};
global_metrics().record_scanner_dirty_usage_pending(usize_to_u64_saturated(pending_buckets));
@@ -366,15 +426,29 @@ fn record_dirty_usage_object_inner(bucket: &str, object: &str) {
DIRTY_USAGE_BUCKET_NOTIFY.notify_one();
}
fn record_segment_invalidation_producer_identity(producer: crate::segment_invalidation::SegmentInvalidationProducerIdentity) {
if producer.producer().is_some() {
dirty_usage_producer_identities().insert(producer);
fn record_segment_invalidation_producer_identities_for_generation<I>(
identities: &mut DirtyUsageProducerIdentities,
generation: u64,
producers: I,
) where
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
{
for producer in producers {
if producer.producer().is_some() {
identities
.entry(producer)
.and_modify(|state| state.last_generation = state.last_generation.max(generation))
.or_insert(DirtyUsageProducerIdentityState {
first_generation: generation,
last_generation: generation,
});
}
}
}
#[cfg(test)]
fn dirty_usage_producer_identities_for_tests() -> BTreeSet<crate::segment_invalidation::SegmentInvalidationProducerIdentity> {
dirty_usage_producer_identities().clone()
dirty_usage_producer_identities().keys().copied().collect()
}
fn dirty_usage_top_level_entry(object: &str) -> Option<String> {
@@ -649,6 +723,31 @@ pub(super) fn dirty_usage_snapshot_status(snapshot: &DirtyUsageSnapshot) -> Dirt
}
}
pub(super) fn dirty_usage_producer_evidence(snapshot: &DirtyUsageSnapshot) -> DirtyUsageProducerEvidence {
let generation_window_bound = dirty_usage_snapshot_status(snapshot) == DirtyUsageSnapshotStatus::Current
&& snapshot.generation != 0
&& snapshot.generation != u64::MAX;
let identities = dirty_usage_producer_identities()
.iter()
.filter(|(_, state)| state.first_generation <= snapshot.generation)
.map(|(identity, _)| *identity)
.collect::<BTreeSet<_>>();
let producer_identity_coverage_complete =
generation_window_bound && crate::segment_invalidation::complete_segment_invalidation_producers(identities).is_ok();
DirtyUsageProducerEvidence {
producer_identity_coverage_complete,
// The current producer journal is still process-local. Keep the
// durable/restart gates closed until the mutation evidence is persisted
// and replayable across scanner restarts.
durable_producer_identity: false,
restart_gap_absent: false,
generation_window_bound,
generation_start: snapshot.generation,
generation_end: snapshot.generation,
}
}
#[cfg(test)]
pub(super) fn dirty_usage_bucket_count() -> usize {
dirty_usage_buckets().len()
+17
View File
@@ -172,6 +172,8 @@ impl ScannerIOCache for SetDisks {
bucket_failures,
pending_maintenance_work,
cache_cycle_floor,
cold_zero_walk_reuse_observed,
segment_invalidation_proof,
} = scan_plan;
let scan_plan_digest = scanner_bucket_work_digest(scan_plan_digest, scan_mode, requires_full_scan);
let bucket_work_digest = scanner_bucket_work_digest(bucket_coverage_digest, scan_mode, requires_full_scan);
@@ -219,6 +221,9 @@ impl ScannerIOCache for SetDisks {
},
current_bucket_incarnations.as_ref(),
);
let cold_zero_walk_reuse_candidate = scoped_scan.as_ref().is_some_and(|prepared| {
old_cache.info.next_cycle < want_cycle && !prepared.buckets.is_empty() && prepared.buckets.len() < all_buckets.len()
});
let mut scoped_cache = scoped_scan.map(|mut prepared| {
buckets = prepared.buckets;
prepared.cache.info.scan_coverage_digest = Some(bucket_coverage_digest);
@@ -226,6 +231,8 @@ impl ScannerIOCache for SetDisks {
});
if buckets.is_empty() {
let now = SystemTime::now();
let completed_segment_invalidation_proof =
scanner_completed_set_segment_invalidation_proof(&segment_invalidation_proof, cold_zero_walk_reuse_candidate);
let mut cache = match scoped_cache.take() {
Some(cache) => cache,
None => {
@@ -239,6 +246,7 @@ impl ScannerIOCache for SetDisks {
scan_plan_digest: Some(scan_plan_digest),
scan_coverage_digest: Some(bucket_coverage_digest),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
segment_invalidation_proof: completed_segment_invalidation_proof.clone(),
scan_bucket_incarnations: current_bucket_incarnations.clone().unwrap_or_default(),
..Default::default()
},
@@ -254,6 +262,7 @@ impl ScannerIOCache for SetDisks {
cache.info.last_update = Some(now);
cache.info.snapshot_complete = true;
cache.info.scan_execution_digest = Some(execution_digest);
cache.info.segment_invalidation_proof = completed_segment_invalidation_proof;
cache.info.lkg_snapshot_complete = false;
cache.info.lkg_next_cycle = None;
cache.info.lkg_last_update = None;
@@ -535,6 +544,7 @@ impl ScannerIOCache for SetDisks {
lkg_last_update: old_cache.info.lkg_last_update,
lkg_leader_epoch: old_cache.info.lkg_leader_epoch,
lkg_scan_plan_digest: old_cache.info.lkg_scan_plan_digest,
segment_invalidation_proof: None,
scan_bucket_incarnations: current_bucket_incarnations.clone().unwrap_or_default(),
..Default::default()
},
@@ -1455,17 +1465,23 @@ impl ScannerIOCache for SetDisks {
let completed_count = completed_bucket_count.load(Ordering::Relaxed);
if should_publish_completed_snapshot(completed_count, buckets.len(), budget.budget_elapsed(), ctx.is_cancelled()) {
let completed_segment_invalidation_proof =
scanner_completed_set_segment_invalidation_proof(&segment_invalidation_proof, cold_zero_walk_reuse_candidate);
let cache_snapshot = {
let mut cache = cache_mutex.lock().await;
cache.info.next_cycle = want_cycle;
cache.info.last_update.get_or_insert_with(SystemTime::now);
cache.info.snapshot_complete = true;
cache.info.scan_execution_digest = Some(execution_digest);
cache.info.segment_invalidation_proof = completed_segment_invalidation_proof;
cache.info.lkg_snapshot_complete = false;
cache.info.lkg_next_cycle = None;
cache.info.lkg_last_update = None;
cache.info.lkg_leader_epoch = None;
cache.info.lkg_scan_plan_digest = None;
if cold_zero_walk_reuse_candidate {
cold_zero_walk_reuse_observed.store(true, Ordering::Release);
}
cache.clone()
};
let _ = persist_and_publish_cache_snapshot(
@@ -1486,6 +1502,7 @@ impl ScannerIOCache for SetDisks {
incomplete_scope.info.tier_registry_generation = Some(tier_registry_generation);
incomplete_scope.info.source = Some(source);
incomplete_scope.info.snapshot_complete = false;
incomplete_scope.info.segment_invalidation_proof = None;
incomplete_scope.info.scan_plan_digest = Some(scan_plan_digest);
incomplete_scope.info.cache_key_format = DATA_USAGE_CACHE_KEY_FORMAT;
if let Err(e) = updates.send(incomplete_scope).await {
+37 -1
View File
@@ -122,6 +122,7 @@ where
let default_result = |scope: ScannerBucketScanScope| ScannerBucketScopeResolutionResult {
scope,
remote_dirty_usage_acknowledgements: Vec::new(),
distributed_segment_invalidation_evidence: None,
};
if resolution.requires_full_scan {
return default_result(ScannerBucketScanScope::default());
@@ -240,12 +241,17 @@ where
return remote_resolution;
}
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_baseline(
resolution.dirty_usage_snapshot,
distributed,
resolution.baseline_proof,
);
default_result(scoped_scan_scope_from_dirty_buckets(
resolution.requested_scope,
dirty_buckets,
(!distributed).then_some(resolution.dirty_usage_snapshot.scopes.as_ref()),
true,
scanner_segment_reuse_activated(),
segment_reuse_activation_preflight.scanner_segment_reuse_activated,
resolution.all_buckets,
resolution.baseline_proof,
))
@@ -408,7 +414,9 @@ where
)
.await;
let remote_dirty_usage_acknowledgements = scope_resolution.remote_dirty_usage_acknowledgements;
let distributed_segment_invalidation_evidence = scope_resolution.distributed_segment_invalidation_evidence;
let scan_scope = scope_resolution.scope;
let segment_invalidation_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot).segment_invalidation_proof();
#[cfg(test)]
if let Some(observer) = resolved_scope_observer {
let _ = observer.send(scan_scope.clone());
@@ -465,12 +473,20 @@ where
} else {
Vec::new()
};
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
dirty_usage_producer_evidence(&dirty_usage_snapshot),
distributed,
None,
false,
);
return Ok(ScannerCycleResult::new(status, dirty_usage_clear)
.with_publication_epoch(publication_epoch)
.with_activity_digest(activity_digest)
.with_observational_snapshot_published(observational_snapshot_published)
.with_remote_publication_lease_targets(remote_publication_lease_targets)
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
.with_segment_reuse_activation_preflight(segment_reuse_activation_preflight)
.with_publication_expectation(publication_expectation));
}
@@ -495,6 +511,7 @@ where
);
let bucket_failures = ScannerBucketFailureState::default();
let pending_maintenance_work = Arc::new(AtomicBool::new(false));
let cold_zero_walk_reuse_observed = Arc::new(AtomicBool::new(false));
record_set_scan_concurrency_limit(set_scan_limit);
debug!(
target: "rustfs::scanner::io",
@@ -588,6 +605,8 @@ where
bucket_failures: bucket_failures.clone(),
pending_maintenance_work: pending_maintenance_work.clone(),
cache_cycle_floor: cache_cycle_floor.clone(),
cold_zero_walk_reuse_observed: cold_zero_walk_reuse_observed.clone(),
segment_invalidation_proof: segment_invalidation_proof.clone(),
};
// Spawn task to run the scanner
let scanner_fut = tokio::spawn(async move {
@@ -691,6 +710,21 @@ where
scan_scope_matches && !partial_buckets.is_empty(),
scan_scope_matches && !namespace_not_found_buckets.is_empty(),
);
let cold_zero_walk_oracle = scanner_cycle_cold_zero_walk_oracle(
&scan_scope,
&all_buckets,
completed_all_sets,
scan_scope_matches,
bucket_scan_status,
cold_zero_walk_reuse_observed.load(Ordering::Acquire),
);
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources),
distributed,
distributed_segment_invalidation_evidence,
cold_zero_walk_oracle,
);
let pending_maintenance_work = pending_maintenance_work_for_cycle(&pending_maintenance_work, &results);
let observed_cycle_floor = cache_cycle_floor.load(Ordering::Acquire);
let required_cycle_floor = (observed_cycle_floor > want_cycle).then_some(observed_cycle_floor);
@@ -783,6 +817,8 @@ where
.with_observational_snapshot_published(observational_snapshot_published)
.with_remote_publication_lease_targets(remote_publication_lease_targets)
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
.with_distributed_segment_invalidation_evidence(distributed_segment_invalidation_evidence)
.with_segment_reuse_activation_preflight(segment_reuse_activation_preflight)
.with_failed_dirty_usage(!failed_buckets.is_empty())
.with_pending_maintenance_work(pending_maintenance_work)
.with_required_cycle_floor(required_cycle_floor)
@@ -13,7 +13,7 @@
// limitations under the License.
use super::*;
use crate::data_usage_define::{UNKNOWN_TIER, UnknownTierStats, hash_path};
use crate::data_usage_define::{DataUsageSegmentInvalidationProof, UNKNOWN_TIER, UnknownTierStats, hash_path};
use rustfs_data_usage::{ReplicationAllStats, ReplicationTargetUsage, TierAccountingProof};
const TEST_PLAN_DIGEST: DataUsageScanPlanDigest = DataUsageScanPlanDigest([7; 32]);
@@ -176,6 +176,27 @@ fn completed_data_usage_info_rejects_duplicate_bucket_inventory() {
assert!(completed_data_usage_info_for_test(&[set], &buckets, false, false).is_none());
}
#[test]
fn completed_data_usage_info_carries_segment_invalidation_proof_to_set_state() {
let source = DataUsageCacheSource::new(0, 0);
let proof = DataUsageSegmentInvalidationProof {
process_epoch: "scanner-process".to_string(),
generation_start: 5,
generation_end: 8,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: true,
};
let mut set = completed_root_cache("bucket", 2, 10, source);
set.info.segment_invalidation_proof = Some(proof.clone());
let (usage, _) =
completed_usage_for_scope(&[set], &HashSet::from([source]), &["bucket".to_string()], &[], true, false, false)
.expect("complete set should publish root usage");
assert_eq!(usage.usage_snapshot_set_states.len(), 1);
assert_eq!(usage.usage_snapshot_set_states[0].segment_invalidation_proof, Some(proof));
}
#[test]
fn completed_data_usage_info_rejects_extra_or_detached_bucket_data() {
let buckets = vec!["bucket".to_string()];
@@ -350,6 +371,7 @@ fn set_membership_add_remove_uses_generation_and_tombstone() {
scan_plan_digest: Some(TEST_PLAN_DIGEST.0),
complete: false,
tombstone: true,
segment_invalidation_proof: None,
};
let encoded = serde_json::to_vec(&state).expect("set state should serialize");
let decoded: DataUsageSnapshotSetState = serde_json::from_slice(&encoded).expect("set state should deserialize");
@@ -371,6 +393,7 @@ fn set_membership_add_remove_uses_generation_and_tombstone() {
scan_plan_digest: Some(TEST_PLAN_DIGEST.0),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
},
state,
],
+424 -1
View File
@@ -85,6 +85,351 @@ fn scanner_activity_preflight_defers_a_temporarily_offline_peer() {
}
}
#[test]
fn scanner_segment_reuse_activation_preflight_reports_release_gate_inputs() {
let preflight = scanner_segment_reuse_activation_preflight();
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert!(!scanner_segment_reuse_activated());
assert_eq!(preflight.proof_inputs, SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS);
assert_eq!(preflight.fail_closed_checks, SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS
);
}
#[test]
fn scanner_segment_reuse_activation_requires_every_preflight_proof() {
let complete_proof = ScannerSegmentReuseActivationProof {
production_activation: true,
producer_identity_coverage_complete: true,
durable_producer_identity: true,
restart_gap_absent: true,
generation_window_bound: true,
overflow_absent: true,
cold_zero_walk_oracle: true,
distributed_peer_invalidation: true,
};
let mut production_disabled = complete_proof;
production_disabled.production_activation = false;
let preflight = scanner_segment_reuse_activation_preflight_from_proof(production_disabled);
assert!(!preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
let preflight = scanner_segment_reuse_activation_preflight_from_proof(complete_proof);
assert!(preflight.production_activation);
assert!(preflight.scanner_segment_reuse_activated);
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
let mut missing_identity = complete_proof;
missing_identity.producer_identity_coverage_complete = false;
assert_segment_reuse_activation_blocked_by(missing_identity, "missing_producer_identity");
let mut non_durable_identity = complete_proof;
non_durable_identity.durable_producer_identity = false;
assert_segment_reuse_activation_blocked_by(non_durable_identity, "missing_producer_identity");
let mut restart_gap = complete_proof;
restart_gap.restart_gap_absent = false;
assert_segment_reuse_activation_blocked_by(restart_gap, "restart_gap");
let mut generation_gap = complete_proof;
generation_gap.generation_window_bound = false;
assert_segment_reuse_activation_blocked_by(generation_gap, "generation_gap");
let mut overflow = complete_proof;
overflow.overflow_absent = false;
assert_segment_reuse_activation_blocked_by(overflow, "overflow");
let mut missing_cold_oracle = complete_proof;
missing_cold_oracle.cold_zero_walk_oracle = false;
assert_segment_reuse_activation_blocked_by(missing_cold_oracle, "missing_cold_zero_walk_oracle");
let mut missing_distributed_invalidation = complete_proof;
missing_distributed_invalidation.distributed_peer_invalidation = false;
assert_segment_reuse_activation_blocked_by(missing_distributed_invalidation, "distributed_without_peer_invalidation");
}
#[test]
fn scanner_segment_reuse_activation_preflight_for_cycle_reports_cycle_inputs_without_activation() {
let dirty_usage_snapshot = DirtyUsageSnapshot {
buckets: Arc::new(DirtyUsageBuckets::from([("photos".to_string(), 7)])),
scopes: Arc::new(DirtyUsageBucketScopes::default()),
generation: 7,
covers_all_pending: true,
};
let distributed_evidence = DistributedSegmentInvalidationEvidence {
invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc,
distributed_ec_invalidation: true,
peer_count: 2,
dirty_peer_count: 1,
same_window_remote_proof: true,
all_peers_bound_to_generation_window: true,
};
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
complete_process_local_producer_evidence(),
true,
Some(distributed_evidence),
true,
);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
vec!["missing_producer_identity", "restart_gap"]
);
}
#[test]
fn scanner_segment_reuse_activation_preflight_for_cycle_blocks_unbounded_inputs() {
let dirty_usage_snapshot = DirtyUsageSnapshot {
buckets: Arc::new(DirtyUsageBuckets::default()),
scopes: Arc::new(DirtyUsageBucketScopes::default()),
generation: u64::MAX,
covers_all_pending: false,
};
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
DirtyUsageProducerEvidence::default(),
true,
None,
false,
);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS
);
}
#[test]
fn scanner_segment_reuse_activation_preflight_for_cycle_skips_distributed_blocker_for_local_scan() {
let dirty_usage_snapshot = DirtyUsageSnapshot {
buckets: Arc::new(DirtyUsageBuckets::from([("photos".to_string(), 7)])),
scopes: Arc::new(DirtyUsageBucketScopes::default()),
generation: 7,
covers_all_pending: true,
};
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(
&dirty_usage_snapshot,
complete_process_local_producer_evidence(),
false,
None,
true,
);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
vec!["missing_producer_identity", "restart_gap"]
);
}
#[test]
#[serial]
fn scanner_durable_segment_invalidation_evidence_requires_matching_complete_set_proofs() {
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
clear_dirty_usage_buckets_for_tests();
record_dirty_usage_bucket_from_producers("photos", SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION);
let dirty_usage_snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], dirty_usage_generation());
let process_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot)
.segment_invalidation_proof()
.expect("complete process-local producer coverage should produce proof metadata");
let expected_sources = HashSet::from([DataUsageCacheSource::new(0, 0), DataUsageCacheSource::new(0, 1)]);
let results = vec![
complete_set_cache_with_segment_proof(DataUsageCacheSource::new(0, 0), process_proof.clone()),
complete_set_cache_with_segment_proof(DataUsageCacheSource::new(0, 1), process_proof),
];
let durable_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources);
assert!(durable_evidence.producer_identity_coverage_complete);
assert!(durable_evidence.durable_producer_identity);
assert!(durable_evidence.restart_gap_absent);
let mut stale_epoch = results.clone();
stale_epoch[0]
.info
.segment_invalidation_proof
.as_mut()
.expect("proof fixture should exist")
.process_epoch = "stale-process".to_string();
let stale_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &stale_epoch, &expected_sources);
assert!(stale_evidence.producer_identity_coverage_complete);
assert!(!stale_evidence.durable_producer_identity);
assert!(!stale_evidence.restart_gap_absent);
record_dirty_usage_bucket("videos");
let changed_evidence = scanner_durable_segment_invalidation_evidence(&dirty_usage_snapshot, &results, &expected_sources);
assert!(!changed_evidence.producer_identity_coverage_complete);
assert!(!changed_evidence.durable_producer_identity);
assert!(!changed_evidence.restart_gap_absent);
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn scanner_segment_reuse_activation_replays_cold_durable_baseline() {
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
clear_dirty_usage_buckets_for_tests();
for producer in SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION {
record_dirty_usage_object_from_producer("photos", "2026/object", producer);
}
let dirty_usage_snapshot =
snapshot_dirty_usage_buckets(&[bucket_info("photos"), bucket_info("archive")], dirty_usage_generation());
let mut segment_proof = dirty_usage_producer_evidence(&dirty_usage_snapshot)
.segment_invalidation_proof()
.expect("complete process-local producer coverage should produce proof metadata");
segment_proof.cold_zero_walk_oracle = true;
let scan_plan_digest = DataUsageScanPlanDigest([6; 32]);
let expected_sources = HashSet::from([DataUsageCacheSource::new(0, 0), DataUsageCacheSource::new(0, 1)]);
let baseline = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(10)),
scanner_cycle: Some(7),
scanner_epoch: Some(11),
buckets_count: 2,
buckets_usage: HashMap::from([
("photos".to_string(), Default::default()),
("archive".to_string(), Default::default()),
]),
usage_snapshot_complete: true,
usage_snapshot_converged: Some(true),
usage_snapshot_set_states: expected_sources
.iter()
.map(|source| DataUsageSnapshotSetState {
pool_index: u64::try_from(source.pool_index).expect("test pool index should fit"),
set_index: u64::try_from(source.set_index).expect("test set index should fit"),
scanner_cycle: Some(7),
scanner_epoch: Some(11),
scan_plan_digest: Some(scan_plan_digest.0),
complete: true,
tombstone: false,
segment_invalidation_proof: Some(segment_proof.clone()),
})
.collect(),
..Default::default()
};
let baseline = Bytes::from(serde_json::to_vec(&baseline).expect("baseline should encode"));
let preflight = scanner_segment_reuse_activation_preflight_for_baseline(
&dirty_usage_snapshot,
false,
ScannerCacheBaselineProof {
authoritative_data: Some(&baseline),
observed_candidate_data: None,
expected_sources: &expected_sources,
leader_epoch: 11,
want_cycle: 8,
scan_plan_digest,
},
);
assert!(preflight.production_activation);
assert!(preflight.scanner_segment_reuse_activated);
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
let mut missing_cold_baseline =
serde_json::from_slice::<DataUsageInfo>(&baseline).expect("baseline should decode for negative case");
missing_cold_baseline.usage_snapshot_set_states[0]
.segment_invalidation_proof
.as_mut()
.expect("proof should exist")
.cold_zero_walk_oracle = false;
let missing_cold_baseline = Bytes::from(serde_json::to_vec(&missing_cold_baseline).expect("negative baseline should encode"));
let preflight = scanner_segment_reuse_activation_preflight_for_baseline(
&dirty_usage_snapshot,
false,
ScannerCacheBaselineProof {
authoritative_data: Some(&missing_cold_baseline),
observed_candidate_data: None,
expected_sources: &expected_sources,
leader_epoch: 11,
want_cycle: 8,
scan_plan_digest,
},
);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(
preflight.fail_closed_blockers().collect::<Vec<_>>(),
vec!["missing_producer_identity", "restart_gap", "missing_cold_zero_walk_oracle"]
);
clear_dirty_usage_buckets_for_tests();
}
#[test]
fn scanner_cycle_result_returns_segment_reuse_activation_preflight() {
let proof = ScannerSegmentReuseActivationProof {
production_activation: true,
producer_identity_coverage_complete: true,
durable_producer_identity: true,
restart_gap_absent: true,
generation_window_bound: true,
overflow_absent: true,
cold_zero_walk_oracle: true,
distributed_peer_invalidation: true,
};
let preflight = scanner_segment_reuse_activation_preflight_from_proof(proof);
let result = ScannerCycleResult::new(ScannerCycleStatus::Complete, None).with_segment_reuse_activation_preflight(preflight);
assert_eq!(result.segment_reuse_activation_preflight, preflight);
}
fn assert_segment_reuse_activation_blocked_by(proof: ScannerSegmentReuseActivationProof, blocker: &'static str) {
let preflight = scanner_segment_reuse_activation_preflight_from_proof(proof);
assert!(preflight.production_activation);
assert!(!preflight.scanner_segment_reuse_activated);
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), vec![blocker]);
}
fn complete_process_local_producer_evidence() -> DirtyUsageProducerEvidence {
DirtyUsageProducerEvidence {
producer_identity_coverage_complete: true,
durable_producer_identity: false,
restart_gap_absent: false,
generation_window_bound: true,
generation_start: 7,
generation_end: 7,
}
}
fn complete_set_cache_with_segment_proof(
source: DataUsageCacheSource,
proof: crate::DataUsageSegmentInvalidationProof,
) -> DataUsageCache {
DataUsageCache {
info: DataUsageCacheInfo {
name: DATA_USAGE_ROOT.to_string(),
next_cycle: 7,
last_update: Some(SystemTime::UNIX_EPOCH),
leader_epoch: 11,
source: Some(source),
snapshot_complete: true,
scan_plan_digest: Some(DataUsageScanPlanDigest([3; 32])),
segment_invalidation_proof: Some(proof),
..Default::default()
},
cache: HashMap::new(),
}
}
async fn setup_two_pool_scanner_store() -> (tempfile::TempDir, Arc<ECStore>) {
init_ecstore_config_for_scanner_tests();
let temp_dir = tempfile::tempdir().expect("multi-pool scanner test directory should be created");
@@ -1039,6 +1384,29 @@ fn dirty_usage_snapshot_detects_uncovered_generation() {
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn dirty_usage_producer_evidence_tracks_process_local_coverage_without_durable_restart_authority() {
use crate::segment_invalidation::SegmentInvalidationProducerIdentity;
clear_dirty_usage_buckets_for_tests();
record_dirty_usage_bucket_from_producers("photos", SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION);
let snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], dirty_usage_generation());
let evidence = dirty_usage_producer_evidence(&snapshot);
assert!(evidence.generation_window_bound);
assert!(evidence.producer_identity_coverage_complete);
assert!(!evidence.durable_producer_identity);
assert!(!evidence.restart_gap_absent);
record_dirty_usage_bucket_from_producer("videos", SegmentInvalidationProducerIdentity::PutObject);
let stale_evidence = dirty_usage_producer_evidence(&snapshot);
assert!(!stale_evidence.generation_window_bound);
assert!(!stale_evidence.producer_identity_coverage_complete);
clear_dirty_usage_buckets_for_tests();
}
#[test]
fn generation_saturates_instead_of_wrapping() {
let generation = AtomicU64::new(u64::MAX - 1);
@@ -1346,6 +1714,13 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
let ctx = CancellationToken::new();
let empty_execution = DataUsageScanPlanDigest([5; 32]);
let segment_invalidation_proof = crate::DataUsageSegmentInvalidationProof {
process_epoch: scanner_activity_epoch().to_string(),
generation_start: 8,
generation_end: 8,
producer_identity_coverage_complete: true,
cold_zero_walk_oracle: false,
};
set.nsscanner_cache(
ctx.clone(),
ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default()),
@@ -1365,6 +1740,8 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
bucket_failures: ScannerBucketFailureState::default(),
pending_maintenance_work: Arc::new(AtomicBool::new(false)),
cache_cycle_floor: Arc::new(AtomicU64::new(8)),
cold_zero_walk_reuse_observed: Arc::new(AtomicBool::new(false)),
segment_invalidation_proof: Some(segment_invalidation_proof.clone()),
},
tx,
8,
@@ -1374,6 +1751,7 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
.expect("empty set scope should replace its prior nonempty cache");
let empty = rx.try_recv().expect("empty set snapshot should be published");
assert_eq!(empty.info.scan_execution_digest, Some(empty_execution));
assert_eq!(empty.info.segment_invalidation_proof, Some(segment_invalidation_proof));
assert!(empty.info.snapshot_complete);
let root = empty.checked_flatten(DATA_USAGE_ROOT).expect("complete empty root");
assert_eq!((root.size, root.objects), (0, 0));
@@ -1401,6 +1779,7 @@ fn complete_usage_baseline(
scan_plan_digest: Some(scan_plan_digest.0),
complete: true,
tombstone: false,
segment_invalidation_proof: None,
}],
..Default::default()
};
@@ -1781,6 +2160,18 @@ fn remote_dirty_usage_invalidates_local_prefix_hints_until_distributed_proof_exi
"peer dirty state is not a distributed segment invalidation proof"
);
assert_eq!(distributed.remote_dirty_usage_acknowledgements.len(), 1);
let evidence = distributed
.distributed_segment_invalidation_evidence
.expect("same-window peer snapshot and scoped ACK capability form distributed evidence");
assert_eq!(evidence.peer_count, 1);
assert_eq!(evidence.dirty_peer_count, 1);
assert_eq!(
evidence.invalidation_domain,
crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc
);
assert!(evidence.distributed_ec_invalidation);
assert!(evidence.same_window_remote_proof);
assert!(evidence.all_peers_bound_to_generation_window);
}
fn peer_dirty_usage_snapshot(
@@ -1827,7 +2218,7 @@ fn verified_remote_dirty_usage_buckets_merges_only_complete_current_snapshots()
ScannerPeerDirtyUsageExpectation {
instance_id: "instance-b".to_string(),
generation: 3,
pending: false,
pending: true,
},
),
]);
@@ -1874,10 +2265,36 @@ fn verified_remote_dirty_usage_buckets_merges_only_complete_current_snapshots()
},
},
],
peer_count: 2,
dirty_peer_count: 2,
})
);
}
#[test]
fn verified_remote_dirty_usage_rejects_peer_snapshot_that_contradicts_activity_pending_state() {
let expected_peers = HashMap::from([(
"node-a:9000".to_string(),
ScannerPeerDirtyUsageExpectation {
instance_id: "instance-a".to_string(),
generation: 7,
pending: false,
},
)]);
assert!(
verified_remote_dirty_usage(
&expected_peers,
vec![(
"node-a:9000".to_string(),
peer_dirty_usage_snapshot("instance-a", 7, true, &[("photos", 7)]),
)],
)
.is_none(),
"a clean activity window cannot authorize a dirty peer snapshot or scoped ACK"
);
}
#[test]
fn scanner_scoped_dirty_usage_ack_cost_threshold_is_single_protocol_batch() {
let acknowledgement = |entry_count: usize| crate::scanner::ScannerDirtyUsageAcknowledgement {
@@ -1951,6 +2368,7 @@ fn remote_dirty_usage_scope_resolution_falls_back_when_ack_batch_exceeds_thresho
result.remote_dirty_usage_acknowledgements.is_empty(),
"full-scan fallback must not send a scoped ACK that peers would reject or split"
);
assert!(result.distributed_segment_invalidation_evidence.is_none());
}
#[test]
@@ -2092,6 +2510,11 @@ async fn distributed_scoped_scan_falls_back_when_remote_scoped_ack_capability_is
assert_eq!(result.scope.selected_buckets.as_deref(), expected_buckets.as_ref());
assert_eq!(result.remote_dirty_usage_acknowledgements.len(), expected_ack_count);
assert_eq!(
result.distributed_segment_invalidation_evidence.is_some(),
capability,
"distributed evidence requires an authenticated scoped ACK capability probe"
);
}
}
@@ -84,7 +84,14 @@ async fn persist_baseline(store: &Arc<ECStore>, baseline: &DataUsageInfo) {
// Every invocation uses the production default scope. Once durable bucket
// incarnations are present, the expected walker set follows the resolved scope.
async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, expect_walks: bool) -> DataUsageInfo {
async fn run_entry(
store: &Arc<ECStore>,
cycle: u64,
selected: Option<&str>,
expect_walks: bool,
expect_activation: bool,
expect_prefix_scope: bool,
) -> DataUsageInfo {
let drives = drive_identities(store).await;
let inventory = store
.list_bucket_for_scanner(&BucketOptions::default())
@@ -138,11 +145,19 @@ async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, exp
.expect("entry cycle should finish within the fixture deadline")
.expect("entry cycle should succeed");
assert_eq!(result.status, ScannerCycleStatus::Complete);
let activation_preflight = result.segment_reuse_activation_preflight;
let scope = observed.await.expect("production resolver should report its decision");
assert_eq!(
scope.selected_buckets.as_deref(),
selected.map(|name| HashSet::from([name.to_string()])).as_ref()
);
if let Some(selected) = selected {
assert_eq!(
scope.prefix_scope_for(selected).is_some(),
expect_prefix_scope,
"resolved prefix scope must match activation replay for cycle {cycle}"
);
}
let usage = receiver.recv().await.expect("one candidate should be delivered");
assert!(receiver.recv().await.is_none(), "there must be exactly one terminal candidate");
assert!(usage.usage_snapshot_complete);
@@ -174,6 +189,22 @@ async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, exp
actual, expected_walks,
"each listed source/bucket must have exactly the expected real walks"
);
assert!(activation_preflight.production_activation);
assert_eq!(activation_preflight.scanner_segment_reuse_activated, expect_activation);
let activation_blockers = activation_preflight.fail_closed_blockers().collect::<Vec<_>>();
if expect_activation {
assert_eq!(activation_blockers, Vec::<&str>::new());
} else if selected.is_some() && expect_walks {
assert!(
!activation_blockers.contains(&"missing_cold_zero_walk_oracle"),
"a complete scoped reuse cycle must carry the cold zero-walk oracle: cycle={cycle} selected={selected:?} blockers={activation_blockers:?}"
);
} else {
assert!(
activation_blockers.contains(&"missing_cold_zero_walk_oracle"),
"unscoped or same-cycle cache reuse must not claim the cold zero-walk oracle: cycle={cycle} selected={selected:?} expect_walks={expect_walks} blockers={activation_blockers:?}"
);
}
assert_eq!(
read_config_with_revision(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
.await
@@ -189,32 +220,78 @@ async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, exp
usage
}
#[tokio::test]
fn record_segment_dirty_usage(bucket: &str) {
for producer in crate::segment_invalidation::SegmentInvalidationProducerIdentity::REQUIRED_PRODUCTION {
record_dirty_usage_object_from_producer(bucket, "hot-segment/object", producer);
}
}
// The scoped fallback fixture keeps two EC pools and several scan futures live
// at once. Run the async cases on a dedicated stack so Linux libtest defaults
// exercise the assertions instead of aborting before the oracle finishes.
fn run_scoped_entry_fallback_test<F, Fut>(thread_name: &'static str, test_fn: F)
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(thread_name.to_string())
.stack_size(32 * 1024 * 1024)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("scoped entry fallback runtime should build");
runtime.block_on(test_fn());
})
.expect("scoped entry fallback test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
#[test]
#[serial]
async fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks() {
fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks() {
run_scoped_entry_fallback_test(
"scanner-scoped-entry-planned-scope",
scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks_case,
);
}
async fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks_case() {
let (_dir, store) = setup_two_pool_scanner_store().await;
clear_dirty_usage_buckets_for_tests();
let hot = format!("hot-{}", Uuid::new_v4().simple());
let cold = format!("cold-{}", Uuid::new_v4().simple());
create_bucket(&store, &hot).await;
create_bucket(&store, &cold).await;
record_dirty_usage_bucket(&hot);
let baseline = run_entry(&store, 1, None, true).await;
record_segment_dirty_usage(&hot);
let baseline = run_entry(&store, 1, None, true, false, false).await;
persist_baseline(&store, &baseline).await;
// Same-cycle Current remains a retry. The later cycle may skip the cold
// bucket only after the prior complete set cache has durable incarnations.
run_entry(&store, 1, Some(&hot), false).await;
let usage = run_entry(&store, 2, Some(&hot), true).await;
run_entry(&store, 1, Some(&hot), false, false, false).await;
let usage = run_entry(&store, 2, Some(&hot), true, true, false).await;
persist_baseline(&store, &usage).await;
let usage = run_entry(&store, 3, Some(&hot), true, true, true).await;
assert_eq!(usage.buckets_usage[&hot].objects_count, 1);
assert_eq!(usage.buckets_usage[&cold].objects_count, 1);
assert_eq!(usage.objects_total_count, 2);
clear_dirty_usage_buckets_for_tests();
}
#[tokio::test]
#[test]
#[serial]
async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker() {
fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker() {
run_scoped_entry_fallback_test(
"scanner-scoped-entry-invalid-baseline",
scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_case,
);
}
async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker_case() {
let (_dir, store) = setup_two_pool_scanner_store().await;
clear_dirty_usage_buckets_for_tests();
let hot = format!("hot-{}", Uuid::new_v4().simple());
@@ -223,7 +300,7 @@ async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker(
create_bucket(&store, &cold).await;
record_dirty_usage_bucket(&hot);
// The first real scan is also the missing persisted-baseline case.
let baseline = run_entry(&store, 1, None, true).await;
let baseline = run_entry(&store, 1, None, true, false, false).await;
for (index, kind) in [
"malformed",
"unconverged",
@@ -256,28 +333,43 @@ async fn scoped_entry_fallback_rejects_invalid_persisted_baseline_at_the_walker(
crate::save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), bytes)
.await
.expect("negative baseline should persist");
let usage = run_entry(&store, u64::try_from(index).expect("fixture cycle index should fit") + 2, None, true).await;
let usage = run_entry(
&store,
u64::try_from(index).expect("fixture cycle index should fit") + 2,
None,
true,
false,
false,
)
.await;
assert_eq!(usage.objects_total_count, 2, "{kind}");
assert_eq!(usage.buckets_usage[&cold].objects_count, 1, "{kind}");
}
clear_dirty_usage_buckets_for_tests();
}
#[tokio::test]
#[test]
#[serial]
async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory() {
fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory() {
run_scoped_entry_fallback_test(
"scanner-scoped-entry-overflow-inventory",
scoped_entry_fallback_covers_overflow_and_new_bucket_inventory_case,
);
}
async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory_case() {
let (_dir, store) = setup_two_pool_scanner_store().await;
clear_dirty_usage_buckets_for_tests();
let hot = format!("hot-{}", Uuid::new_v4().simple());
create_bucket(&store, &hot).await;
record_dirty_usage_bucket(&hot);
let baseline = run_entry(&store, 1, None, true).await;
let baseline = run_entry(&store, 1, None, true, false, false).await;
persist_baseline(&store, &baseline).await;
for index in 0..=crate::SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES {
record_dirty_usage_bucket(&format!("overflow-{index}"));
}
assert!(dirty_usage_buckets_for_tests().len() > crate::SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES);
let usage = run_entry(&store, 2, None, true).await;
let usage = run_entry(&store, 2, None, true, false, false).await;
assert_eq!(usage.objects_total_count, 1);
clear_dirty_usage_buckets_for_tests();
@@ -285,7 +377,7 @@ async fn scoped_entry_fallback_covers_overflow_and_new_bucket_inventory() {
let new_bucket = format!("new-{}", Uuid::new_v4().simple());
create_bucket(&store, &new_bucket).await;
// Even a previously valid baseline cannot cover the changed inventory.
let usage = run_entry(&store, 3, None, true).await;
let usage = run_entry(&store, 3, None, true, false, false).await;
assert_eq!(usage.objects_total_count, 2);
assert_eq!(usage.buckets_usage[&new_bucket].objects_count, 1);
clear_dirty_usage_buckets_for_tests();
+22 -2
View File
@@ -57,6 +57,9 @@ pub enum SegmentInvalidationProducerIdentity {
DeleteObject,
DeleteMarker,
CompleteMultipartUpload,
AbortMultipartUpload,
ObjectMetadata,
BucketMetadata,
Replication,
TierTransition,
TierExpiration,
@@ -66,11 +69,14 @@ pub enum SegmentInvalidationProducerIdentity {
}
impl SegmentInvalidationProducerIdentity {
pub const REQUIRED_PRODUCTION: [Self; 8] = [
pub const REQUIRED_PRODUCTION: [Self; 11] = [
Self::PutObject,
Self::DeleteObject,
Self::DeleteMarker,
Self::CompleteMultipartUpload,
Self::AbortMultipartUpload,
Self::ObjectMetadata,
Self::BucketMetadata,
Self::Replication,
Self::TierTransition,
Self::TierExpiration,
@@ -82,7 +88,9 @@ impl SegmentInvalidationProducerIdentity {
Self::PutObject => Some(SegmentInvalidationProducer::Put),
Self::DeleteObject => Some(SegmentInvalidationProducer::Delete),
Self::DeleteMarker => Some(SegmentInvalidationProducer::DeleteMarker),
Self::CompleteMultipartUpload => Some(SegmentInvalidationProducer::Multipart),
Self::CompleteMultipartUpload | Self::AbortMultipartUpload => Some(SegmentInvalidationProducer::Multipart),
Self::ObjectMetadata => Some(SegmentInvalidationProducer::Put),
Self::BucketMetadata => Some(SegmentInvalidationProducer::DirectoryObject),
Self::Replication => Some(SegmentInvalidationProducer::Replication),
Self::TierTransition | Self::TierExpiration => Some(SegmentInvalidationProducer::Tier),
Self::DirectoryObject => Some(SegmentInvalidationProducer::DirectoryObject),
@@ -402,8 +410,12 @@ mod tests {
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
SegmentInvalidationProducerIdentity::ObjectMetadata,
SegmentInvalidationProducerIdentity::BucketMetadata,
SegmentInvalidationProducerIdentity::Replication,
SegmentInvalidationProducerIdentity::TierTransition,
SegmentInvalidationProducerIdentity::TierExpiration,
SegmentInvalidationProducerIdentity::DirectoryObject,
SegmentInvalidationProducerIdentity::Unknown,
]),
@@ -415,8 +427,12 @@ mod tests {
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
SegmentInvalidationProducerIdentity::ObjectMetadata,
SegmentInvalidationProducerIdentity::BucketMetadata,
SegmentInvalidationProducerIdentity::Replication,
SegmentInvalidationProducerIdentity::TierTransition,
SegmentInvalidationProducerIdentity::TierExpiration,
SegmentInvalidationProducerIdentity::DirectoryObject,
SegmentInvalidationProducerIdentity::TestFixture,
]),
@@ -427,6 +443,7 @@ mod tests {
SegmentInvalidationProducerIdentity::PutObject,
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
SegmentInvalidationProducerIdentity::Replication,
SegmentInvalidationProducerIdentity::TierTransition,
SegmentInvalidationProducerIdentity::DirectoryObject,
@@ -439,6 +456,9 @@ mod tests {
SegmentInvalidationProducerIdentity::DeleteObject,
SegmentInvalidationProducerIdentity::DeleteMarker,
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
SegmentInvalidationProducerIdentity::ObjectMetadata,
SegmentInvalidationProducerIdentity::BucketMetadata,
SegmentInvalidationProducerIdentity::Replication,
SegmentInvalidationProducerIdentity::TierTransition,
SegmentInvalidationProducerIdentity::DirectoryObject,
+49
View File
@@ -919,6 +919,22 @@ where
}
}
// The filename's item count is untrusted. Reject a payload that contains
// more items than advertised instead of returning success and allowing the
// caller to delete the entry with trailing events still in the file.
match deserializer.next() {
None => {}
Some(Ok(_)) => {
return Err(StoreError::Deserialization(format!(
"Batch for key {key} contains more than {} items",
key.item_count
)));
}
Some(Err(e)) => {
return Err(StoreError::Deserialization(format!("Failed to deserialize trailing batch item: {e}")));
}
}
if items.is_empty() && key.item_count > 0 {
return Err(StoreError::Deserialization("No items found".to_string()));
}
@@ -1381,6 +1397,39 @@ mod tests {
let _ = store.delete();
}
#[test]
fn get_multiple_errors_on_batch_with_trailing_items_instead_of_partial_success() {
let dir = temp_store_dir("trailing-batch-items");
let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
store.open().unwrap();
let items = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
let original_key = store.put_multiple(items).unwrap();
assert_eq!(original_key.item_count, 3);
// Keep the three-item payload but make its filename claim that it contains
// only two items, simulating a corrupt or otherwise untrusted queue key.
let original_path = store.file_path(&original_key);
let advertised_key = Key {
item_count: 2,
..original_key
};
let advertised_path = store.file_path(&advertised_key);
std::fs::rename(&original_path, &advertised_path).unwrap();
let err = store.get_multiple(&advertised_key).unwrap_err();
assert!(
matches!(err, StoreError::Deserialization(_)),
"expected Deserialization error, got {err:?}"
);
// Because get_multiple failed, the batch entry remains available for
// inspection or recovery instead of being silently discarded.
assert!(advertised_path.exists());
let _ = store.delete();
}
#[test]
fn concurrent_put_raw_respects_entry_limit() {
let dir = temp_store_dir("concurrent-limit");
+23
View File
@@ -0,0 +1,23 @@
# Documentation
Use the focused indexes rather than treating this directory as an unordered
collection:
- [Architecture knowledge base](architecture/README.md)
- [Testing references](testing/README.md)
## Operations
Operational runbooks live under [`operations/`](operations/). Replication
operators should start with:
| Runbook | Use it for |
|---|---|
| [Site replication operations](operations/site-replication-operations.md) | Health fields, pending operations, outage recovery, re-pair admission, IAM/SSE boundaries, and upgrades. |
| [Replication target check](operations/replication-check.md) | Validating an S3 destination and version fidelity before enabling replication. |
| [Replication object size limits](operations/replication-object-size-limits.md) | Multipart routing, large-object limits, and retry characteristics. |
| [Replication outbound transport](operations/replication-outbound-transport.md) | Integrity headers, generic target behavior, and transport knobs. |
Other runbooks remain grouped by filename in [`operations/`](operations/);
architecture pages link to the relevant runbook where a cross-boundary
procedure is required.
+3 -1
View File
@@ -60,6 +60,8 @@ Required headings and strings in these files are asserted by `scripts/check_arch
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
Operations runbooks live in [../operations/](../operations/) and testing references in [../testing/README.md](../testing/README.md).
Operations runbooks are registered in the [documentation operations index](../README.md#operations), and testing references live in [../testing/README.md](../testing/README.md).
For replication operations, start with [site replication operations](../operations/site-replication-operations.md), [replication target check](../operations/replication-check.md), [replication object size limits](../operations/replication-object-size-limits.md), and [replication outbound transport](../operations/replication-outbound-transport.md).
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).

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