Compare commits

..

40 Commits

Author SHA1 Message Date
Hauser Zhi babfeaed00 fix(io-core): preserve pooled capacity and accounting (#9171)
* fix(io-core): preserve pooled capacity and accounting

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

* fix(io-core): preserve pool allocation admission order

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

* docs(io-core): clarify pool return locking

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

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-04 22:07:58 +00:00
Jose Meira 736bf777b4 feat(container): add declarative bucket bootstrap (#9162)
* feat(container): add declarative bucket bootstrap

* fix(container): handle false bucket versioning setting

* Update Rust base image to version 1.99.1-trixie

Signed-off-by: Hauser Zhi <housemecn@gmail.com>

* Update Rust base image version to 1.99.1

Signed-off-by: Hauser Zhi <housemecn@gmail.com>

---------

Signed-off-by: Hauser Zhi <housemecn@gmail.com>
Co-authored-by: Hauser Zhi <housemecn@gmail.com>
2026-11-04 16:59:50 +00:00
KarlEmm 7babc7d621 fix(s3): report malformed multipart upload IDs as NoSuchUpload (#9147)
UploadPart, UploadPartCopy, ListParts and CompleteMultipartUpload returned 400 InvalidArgument for a malformed upload ID, while AbortMultipartUpload returned 404 NoSuchUpload. AWS S3 documents NoSuchUpload for an upload ID that might be invalid.

Report MalformedUploadID as NoSuchUpload through one helper used by all operations that address an existing upload, replacing the Abort-only special case. ListMultipartUploads keeps InvalidArgument for a malformed upload-id-marker.

Add the multipart_upload_id_test e2e module to the e2e-smoke profile. It asserts 404 NoSuchUpload from every operation that addresses an existing upload, for a malformed upload ID and for an aborted one, and updates the e2e-smoke and Linux e2e-full selection digests.

Co-authored-by: Karl Bastarache <bastarachek@google.com>
Co-authored-by: Hauser Zhi <housemecn@gmail.com>
2026-11-04 16:25:00 +00:00
Hauser Zhi 4d46bd25f8 perf(ecstore): parallelize bulk-delete lock and metadata fan-out (#9155)
* perf(ecstore): probe bulk-delete write locks in parallel waves

Bulk delete acquired one distributed write lock per key in serialized
order, paying a full quorum round-trip per key. Probe the unique keys in
bounded parallel waves with a short timeout instead; any contention drops
the whole wave and the canonical serial order resolves it as before, so
two overlapping batches can never deadlock.

* perf(ecstore): probe bulk-delete transaction locks in parallel

The set-layer delete-transaction locks were also acquired one quorum
round-trip per key after the raw write locks. Apply the same optimistic
all-or-nothing probe wave with a canonical serial fallback, and keep the
per-key error reporting the fallback already provided.

* perf(ecstore): pre-fetch bulk-delete source metadata in parallel

The admission loop ran one metadata quorum fan-out per key in sequence
on top of the per-key lock round-trips. Pre-fetch the source reads the
loop needs with bounded parallelism under the locks already held, and
derive the per-key read inputs from one shared helper so the pre-fetch
and the loop cannot disagree.

* test(ecstore): drop unused import in batch-delete probe test

* refactor(ecstore,lock): await wave releases and unify probe waves

Adversarial review of the bulk-delete lock waves found three real
issues. Distributed guard release is fire-and-forget on drop, so the
serial fallback could re-acquire keys while unlock RPCs were still in
flight and block on locks it already owned: guards are now released
with an awaited unlock before the fallback runs. The wave also probed
every key after the first failure, adding up to one probe timeout per
chunk of pure overhead under contention: probing now stops at the
first failing chunk. Finally the two copy-pasted wave implementations
are replaced by one shared probe_lock_wave, and the per-key delete
read inputs are resolved once instead of being recomputed.

Tests now assert the wave/fallback counters so they prove which path
ran, and an overlapping-batch test pins the no-deadlock property.

* refactor(ecstore,lock): batch bulk-delete lock rounds per key

Replace the optimistic probe waves with the bulk lock primitive the lock
layer already supports on the wire: one lock_batch RPC per lock client
for a whole DeleteObjects key set, per-key quorum guards with the usual
TTL heartbeat, and per-key outcomes. The batched round is deadlock-free
by construction (every lock server orders concurrent batches by the
same global (shard, key) order), so the probe timeout, the wave
release-and-wait dance, and the canonical serial fallback all go away.

Per-key lock outcomes are now reported per key end to end: a locked-out
key fails alone in the response while the rest of the batch proceeds,
matching the S3 DeleteObjects contract and the set layer's existing
behavior. The set layer's raw-write-lock and delete-transaction-lock
waves fold into the same primitive via one shared resource-folding
helper, and the fast-lock batch fast path now counts acquisitions so
shard metrics keep their meaning.

* perf(ecstore): move journal publish off the one-worker namespace lane

A bulk delete publishes one recovery transaction per key, and every
journal inspection and settle-plan closure ran on the per-disk
foreground namespace lane (one worker), serializing the whole publish
phase per drive behind every other mutation. Route those closures onto
the durable-mutation lane the fused delete prepare already uses (eight
workers per disk, fsyncs coalesce in the filesystem journal), and keep
32 journal publications in flight so a 16-drive set can feed those
lanes.

* test(ecstore): flatten lock-error iteration in overlap test

* fix(ecstore,lock): refresh duplicate keys after recovery, bound unlock

Review findings on the batched lock rounds. A batch entry whose recovery
rolls back an interrupted prepared delete can bring a retained version
back; a later duplicate of the same key still held its pre-fetched
source-missing read, skipped the object-lock check, and deleted the
restored version. Recovery now evicts the pre-fetched reads of every
other entry naming the same object, so those entries take the loop's
live re-read and see the restored version.

The batch rollback release awaited the full unlock retry schedule,
including the deferred background pass, parking the request behind an
unreachable peer. The synchronous phase is now a single release pass
with the deferred retries spawned in the background.

* fix(ecstore): release local batch locks before deferred recovery

A set-layer batch delete that runs deferred key recovery re-acquires a
write lock on the same keys through recover_delete_object, but the raw
local batch guards were only ever released at function exit — the
distributed path releases its batch ids before the deferred loop and the
local path missed the mirror release, so the recovery deadlocked against
the batch's own locks and surfaced as a spurious Lock(Timeout) for every
deferred key.

A regression test plants the interrupted prepared delete this needs and
pins the end-to-end contract: a batch naming a GOVERNANCE-retained
version twice must deny both entries after the first entry's recovery
rolls the prepared delete back, and the restored version must survive.

* test(lock): cover batch acquire partial-grant rollback

The distributed acquire_guard_batch rollback path — a key granted by
fewer than quorum clients must fail alone and release its partial grants
before the call returns — was only reachable in distributed mode and had
no coverage. A scripted per-object LockClient pins both halves: the
below-quorum key reports QuorumNotReached with its partial vote, and the
orphaned grant is released synchronously while the quorate key's guard
stays held.
2026-11-04 13:20:13 +00:00
Ramakrishna Chilaka ff5f491c5a perf(ecstore): skip unmatched FdCache scans and maintenance wakeups (#9165)
Track active descriptor volumes, exact paths, and component prefixes in FdCacheTracker so unmatched invalidate_under, invalidate_volume, and invalidate_exact calls return without scanning Moka or waking the background maintenance task.
2026-11-04 13:12:11 +00:00
Hauser Zhi a8be876fd9 fix(s3): honor presigned x-amz-meta-* query parameters (#9146)
* fix(s3): honor presigned x-amz-meta-* query parameters

The AWS SDK presigners hoist signable x-amz-* headers into the query
string (smithy moveHeadersToQuery), and AWS S3 accepts the hoisted form
as the header it came from. RustFS read user metadata from headers only,
so a presigned PutObject carrying ?x-amz-meta-user-id=u1 returned 200 and
stored an object with empty metadata (rustfs/rustfs#9142).

Add one shared extractor for user-metadata query parameters and wire it
into every metadata-accepting write path (PutObject including snowball
members, CreateMultipartUpload, CopyObject REPLACE) plus the snowball
extract trigger, instead of normalizing per operation the way the
presigned UploadPart checksum fix did. The stored key form is produced by
the same helper as the header path, so the hoisted and header forms land
on disk identically.

Only a signature-covered query is honored: SigV4 signs the canonical
query string, while SigV2 signs headers plus a fixed subresource list
only, so honoring its query would let a presigned-URL holder add object
properties the presigner never authorized. Keys and values that could not
have arrived as a header are rejected rather than stored, since the read
path turns them into header fields and a control character would fail
every Head/Get of that object.

* refactor(s3): hoist signed x-amz query params at the request boundary

Replace the per-write-path query metadata extractor with one boundary
mechanism: restore the `x-amz-*` request properties the SigV4 presigners
hoist into the query (`@smithy/signature-v4` `moveHeadersToQuery`) as
request headers before s3s routes and deserializes the request.

One hook now covers every reader at once — s3s input parsing,
`extract_metadata_from_mime`, the snowball extract trigger and its
transport-key filter, tagging, storage class, object lock and copy
source — so PutObject, CreateMultipartUpload and CopyObject need no
changes at all. It also replaces repeated query parsing along the write
path with a single pass at the boundary.

An existing header always wins, so the hoist is purely additive and the
unsigned-header guard keeps rejecting anything the signer did not sign;
that guard now also accepts a header whose single value equals a
same-named query parameter, which is what the hoist produces and what
the signature covers.

`query_string_is_signed` rules out SigV2 before it trusts any SigV4
marker. SigV2's string-to-sign covers headers plus a fixed subresource
list rather than arbitrary query parameters, so a SigV2 presigned URL
stays valid after `X-Amz-Algorithm`/`X-Amz-Signature` markers are
appended to it; without this rule a URL holder could turn on
`x-amz-meta-snowball-auto-extract` and have a plain upload untarred
(review of #9146).

* fix(s3): hoist signed query params after s3s signature verification

The request-boundary hoist ran before s3s, so the restored `x-amz-*`
headers reached s3s's own verifier, which rejects any `x-amz-*` header
outside `X-Amz-SignedHeaders` (s3s `ops/signature.rs`,
`reject_unsigned_amz_headers`). A valid presigned PUT with
`SignedHeaders=host` and `?x-amz-meta-user-id=u1` therefore came back
with 403 AccessDenied before any handler ran (review of #9146).

Move the hoist to `authorize_request`, RustFS's shared authorization
entry: s3s has already verified the signature there, and policy
evaluation plus the handler read request properties after it, so the
restored headers reach every operation without ever appearing unsigned
to the verifier. The mechanism is unchanged — one gated, validated pass
over the signature-covered query.

CopyObject's replacement metadata now also reads the headers, because
s3s parsed `input.metadata` before the hoist ran.

The hoist itself and its tests stay in `server::layer`; the tower layer
is gone.

* perf(s3): single-pass signature-aware query hoist in auth

Move the query hoist into `auth.rs`, next to the unsigned-header rules it
is part of, so `storage` no longer reaches into `server` for it. Its
documented contract — which request properties the signature covers, and
why SigV2 must never be widened — now lives with the rule it protects.

Make the hot path allocation-free and single-pass. The caller no longer
clones the query just to satisfy the borrow checker, the function no
longer clones it again, and one `form_urlencoded::parse` pass both
recognizes the presigned-SigV4 marker set and stages the hoistable
parameters. Nothing is validated or applied until the gate passes, so a
request whose query is not signature-covered is never rejected here. A
single ASCII scan skips the whole thing when the query names no `x-amz-`
parameter at all, which is every ordinary `?versionId=`/`?uploadId=`
request and every queryless one.

The duplicate-parameter and header-value checks stay strict; only when
they moved, not how they behave.

* fix(s3): keep verifying a query-carried payload hash after the hoist

The hoist restores `?x-amz-content-sha256=<digest>` as a header, but it
runs after s3s has already built the request body. s3s reads its
payload-hash input from the headers only, so it installs no hash
validator for a digest that arrived in the query, and the restored header
then made `get_content_sha256_with_query` conclude the auth layer was
enforcing it. Neither layer verified the body, so a presigned PUT whose
digest exists only in the query accepted a modified payload (review of
#9146).

The question is not whether a digest header is present but whether the
digest reached the request as a query parameter: that one is always the
storage layer's to verify. A digest sent as a header is still left to the
auth layer, which wraps the body in a hash-validating reader for it, so
no payload is hashed twice.

* fix(auth): limit late query hoisting to user metadata

* refactor(s3): drop the stale payload-hash compensation, scope the pre-filter

Limiting the hoist to `x-amz-meta-*` user metadata means it never
manufactures an `x-amz-content-sha256` header, so the payload-hash
predicate's original signal — a digest header present at handler time is
one s3s is enforcing — is exact again. Restore it and drop the
query-aware form: that change existed only to undo a header the hoist is
no longer allowed to create, and it would have hashed a payload twice
for a presigned request that declared a real digest both in the query and
as a header.

Match the pre-filter to the same scope so a query carrying only a typed
property (`x-amz-tagging`, `x-amz-storage-class`, `versionId`) skips the
parse entirely instead of scanning past it.

* test(access): use explicit imports in the signed-query lock regression

The architecture migration rule requires RustFS storage-owner modules to
name their imports instead of pulling the parent module in wholesale, and
`signed_query_lock_permission_regression` was still on `use super::*`.
Import the two symbols it actually uses and drop the unused one.

* test(access): reference FS through the parent import

The layer dependency guard tracks `use crate::...` edges, and naming
`crate::storage::ecfs::FS` from the signed-query regression looked like a
new `infra -> interface` reverse dependency. `access.rs` already reaches
the same type through `use super::ecfs::FS`, so reuse that import instead
of introducing a second, tracked spelling of an edge the module has
always had. No baseline entry, no new edge.

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-11-04 10:41:52 +00:00
Hauser Zhi fe3ada0689 chore(deps): refresh Cargo dependencies (#9156)
* chore(deps): refresh Cargo dependencies

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

* chore(deps): update rustfs kafka to 1.4.1

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-04 09:03:37 +00:00
Hauser Zhi 926355ae1f fix(api): keep SlowDownRead off write-path quorum failures (#9149)
* fix(api): keep SlowDownRead off write-path quorum failures

* refactor(api): write-safe quorum default, GET opts into SlowDownRead

* fix(api): keep object-read mapping behind error.rs architecture boundary

* refactor(api): drop redundant object-read mapper wrapper
2026-11-04 08:08:23 +00:00
Chris An 7f5fa4b8f6 fix(protocols,kms,keystone): enforce client CA, protect the KMS probe (#9168)
Scan findings in the alternate-protocol front ends and the KMS and
Keystone integrations:

- WebDAV and FTPS read RUSTFS_WEBDAV_CA_FILE / RUSTFS_FTPS_CA_FILE,
  checked that the file existed, and then built their TLS listeners
  with with_no_client_auth(), so mTLS was never enforced. A shared
  protocols::common::tls::server_config builds the rustls config with a
  client-certificate verifier from the configured CA, startup fails on
  an invalid CA, and a CA cannot be combined with cleartext
  (backlog#3069).
- The KMS readiness probe used a fixed key id that the key-lifecycle
  admin routes accepted like any customer key, so kms:DisableKey on it
  failed readiness on every node. The probe key is reserved from key
  management and a disabled probe key no longer counts as a backend
  failure (backlog#3115).
- The Keystone middleware answered anonymous requests carrying a junk
  X-Auth-Token with the configured Keystone URL in the body while
  Keystone was unreachable; failures no longer disclose upstream
  locations, and the login cache is bypassed when a subscription token
  refresh is required (backlog#3116).
2026-11-04 20:25:55 +08:00
Chris An a97c65895c fix(proxies,targets,obs): bound untrusted input in leaf crates (#9166)
Scan findings in crates whose behavior is driven by untrusted input and
that stand alone from the storage core:

- trusted-proxies: the simple implementation takes the client address
  from the right-most untrusted X-Forwarded-For hop instead of the
  left-most (backlog#3082); the legacy validator never falls back to the
  proxy identity when the chain is rejected and reads X-Forwarded-For
  unless RFC 7239 is explicitly selected (backlog#3051).
- targets: webhook and similar outbound clients drain responses up to a
  byte limit instead of buffering arbitrary bodies (backlog#3062); the
  on-disk notification queue has a shared byte budget in addition to the
  entry count, configurable via RUSTFS_TARGET_STORE_MAX_BYTES
  (backlog#3122); AMQP and Redis manifests declare the broker url as a
  secret and bind credentials to their connection fields, and the
  plugin-instance PUT refuses to restore a redacted secret when the
  connection fields changed (backlog#3065, backlog#3066).
- obs: the debug-level s3s response dump is filtered so STS and
  service-account credentials never reach the log (backlog#3029).
2026-11-04 19:49:53 +08:00
Chris An 73e7afa01a fix(iam): close identity, session and admin-authorization gaps (#9163)
Scan findings in the IAM crate and the admin identity handlers, grouped
because they share the IamCache mutation lock, the STS session sweep and
the admin client-address plumbing:

- Admin and STS authorization used the socket peer as aws:SourceIp
  behind a trusted proxy; they now read the verified client address the
  S3 path already uses (backlog#3025), and the admin usage route passes
  the bucket named in the path to the s3:ListBucket check (backlog#3033).
- AssumeRoleWithWebIdentity accepted HS256 ID tokens MACed with the OIDC
  client secret; only provider-signed tokens verify (backlog#3028), and
  JWKS refresh is single-flight with a failure cool-down (backlog#3103).
- A disabled group emptied the member's whole policy list, dropping
  every Deny; direct and other-group policies are preserved (backlog#3042).
- Identity rewrites (secret change, add-user, status) ran without a lock
  and without re-reading the record, so a user's password change could
  undo an admin disable or reset (backlog#3048). add-user on an existing
  user and in-flight AssumeRole both left sessions minted under the old
  secret valid (backlog#3071, backlog#3079); rotation now revokes under
  the per-key mutation lock and derived-credential publication is ordered
  with it.
- aws:groups never carried a built-in user's IAM group membership and
  jwt:iss was always the local marker, so group- and issuer-scoped
  Denies never matched; both now come from live IAM state
  (backlog#3095, backlog#3096).
- OPA mode skipped the account-status gates for service accounts and STS
  sessions of a disabled user (backlog#3057).
- The periodic IAM reload published nothing when any cache write raced
  it and kept stale partitions when a prefix listed empty, so a missed
  revocation never applied (backlog#3064, backlog#3114).
- update-group-members never evaluated admin:RemoveUserFromGroup
  (backlog#3098); ExportIAM compared the site-replicator account against
  a name it never has (backlog#3045); add-user accepted the access key of
  a live STS session or service account (backlog#3134); a service account
  could create or extend a lifetime beyond its own (backlog#3112);
  sts:DurationSeconds and svc:DurationSeconds were never populated
  (backlog#3136); temporary-account-info returned service accounts
  (backlog#3137); an access key the object layer refuses no longer holds
  the service-account lock through the write retries (backlog#3133).
2026-11-04 18:44:50 +08:00
Chris An e0f26a540d fix(ecstore): gate null-version overwrites under Object Lock (#9157)
A put-like write without a version id to a key that is not
versioning-Enabled replaces the key's null version in place. The
commit-time Object Lock gate in put_object_with_old_current_size_inner
ran only when the write carried an explicit version id, and the
request-layer pre-check reads the key's latest version. When the
retained null version was not the latest entry (a UUID version or a
delete marker sat above it), a user holding only s3:PutObject could
overwrite it together with its COMPLIANCE retention or legal hold, and
CompleteMultipartUpload had no commit-time gate at all.

Name the version a commit replaces (the explicit id, or the nil id when
the write is not versioned and the bucket's Object Lock snapshot is not
ConfirmedAbsent) and run the existing WORM gate against it, tolerating
a null delete marker. complete_multipart_upload gets the same gate at
its commit point, and ECStore::handle_complete_multipart_upload attaches
the bucket's Object Lock snapshot the way a single PUT already does.

Refs rustfs/backlog#3019 (ANT-2026-EH17ZP59).
2026-11-04 17:26:54 +08:00
Chris An 3b3cc018aa fix(policy): close condition, action, principal and resource gaps (#9160)
Eight scan findings in the shared policy engine, each a case where a
Deny did not apply or an Allow was broader than written:

- A bare `*` Action was parsed as `s3:*`, so `Deny *` never matched
  admin:, kms: or sts: actions. `Action::All` now denies every family
  while the historical S3-only Allow is preserved (backlog#3041).
- A bare IPv6 address in aws:SourceIp got `/32` appended, matching a
  2^96-address network; a bare address now means one host, and IPv4
  clients on a dual-stack listener are matched through their mapped
  form (backlog#3030).
- Date conditions on aws:EpochTime parsed the epoch seconds as RFC 3339
  and never matched, so a time-bounded Deny failed open (backlog#3036).
- NumericNotEquals and DateNotEquals evaluated to false when the key was
  absent; negated operators now follow the AWS rule and match
  (backlog#3123).
- Every trailing IfExists on a condition operator added a level of
  recursion without bound; the chain is rejected before parsing
  (backlog#3091).
- Policy variable substitution re-expanded its own output for up to ten
  passes, so an access key holding k placeholders grew to k^10; values
  are now substituted once as literal data (backlog#3067).
- A bucket-policy Service principal was matched against the signing
  access key, so a service account named after the service received its
  grants; Service principals round-trip but never match (backlog#3081).
- Resource matching cleaned the trailing slash off a directory object,
  so a Deny on `prefix/*` missed `prefix/` itself (backlog#3125).

Also deduplicate large Resource sets through a HashSet instead of a
quadratic scan.
2026-11-04 17:16:17 +08:00
Chris An f4f5635aab fix(auth): build policy conditions from the value a write stores (#9159)
PutObjectLegalHold and PutObjectTagging apply the value in their XML
body, and a POST upload applies its form fields, while the
s3:object-lock-legal-hold, s3:RequestObjectTag/<key> and
s3:RequestObjectTagKeys condition keys (and every other header-derived
key for POST) were built from the HTTP headers. The policy engine
therefore judged a value the handler did not store: absent when the
client sent no header, and whatever the caller wrote when it did. A
user limited by a Deny on the requested legal hold or tag value could
release a hold and delete the held version, or retag an object so a
tag-based grant exposed it.

Record the body value in request extensions before authorization, the
way PutObjectRetention already does, and let authorization_conditions
replace the header-derived keys with it. For POST uploads project the
parsed form fields over the headers that feed the condition map, removing
a header whose field is absent.

Refs rustfs/backlog#3018 (ANT-2026-KONBE376).
2026-11-04 17:00:42 +08:00
Chris An 36ad5a6b87 fix(ecstore): judge the null version a versionless delete removes (#9151)
DeleteObjects ran its Object Lock check on one version of a key and then
removed a different one. For an entry without a version id on a key that
is not versioning-Enabled, delete_objects_with_accounting judged the
key's latest entry and then had the disks remove the null version. When
a UUID version or a delete marker sat above a null version under
COMPLIANCE retention or legal hold, that retention was never read and a
user holding only s3:DeleteObject could remove the retained version. The
single-object DELETE on a Suspended key had the same gap.

Add SetDisks::check_null_version_object_lock_delete, which looks up the
null version under the lock the caller already holds and runs the
existing check_object_lock_delete against it when the latest entry
carries a UUID version id. The batch path is not gated on
source_missing because a delete marker on top makes the latest-version
lookup report the key as missing. Plain unversioned keys and
versioning-Enabled keys keep a single lookup and unchanged behavior.

Refs rustfs/backlog#3017 (ANT-2026-6ED2F7II).
2026-11-04 15:59:48 +08:00
Chris An 6c14128072 fix(s3): reserve the __XLDIR__ directory-marker suffix in client keys (#9153)
Storage encodes a directory object `photos/` as `photos__XLDIR__/xl.meta`
and LocalDisk::scan_dir decodes every directory entry ending in the
suffix back into a marker. The S3 write path never refused the suffix in
client keys, so a 1-byte PutObject to the key `__XLDIR__` made the walk
schedule the directory being scanned as its own child and recurse until
the stack overflowed, aborting the server on every restart until the
directory was removed by hand. Keys below a `*__XLDIR__/` segment were
stored but hidden from every listing and from quota accounting.

validate_object_key now rejects any client key containing the suffix,
which covers PutObject, CopyObject, Snowball extraction and the admin
internal put; CreateMultipartUpload and CompleteMultipartUpload now run
the same validation so uploads created before the key was reserved
cannot publish it. scan_dir additionally ignores a marker whose decoded
name is empty, so malformed markers left by older writers or corrupt
disks can no longer recurse into their parent.

Refs rustfs/backlog#3020 (ANT-2026-SBJ1JLC2).
2026-11-04 15:39:48 +08:00
Chris An addc258a24 fix(admin): keep Vault local-file reads on the operator connection (#9152)
POST /rustfs/admin/v3/kms/reconfigure and configure accepted
server-local file paths in the Vault request body (Kubernetes jwt_path,
AppRole secret_id_file, TokenFile path, TLS ca/client cert and key
paths), read the named file with the server's own authority and sent
its content to the Vault address given in the same body. A user holding
only kms:Configure could read any UTF-8 file the process can read,
including /proc/self/environ with the root credentials, and exfiltrate
it to an address of their choosing.

normalize_configure_request_secrets now runs
validate_vault_local_file_access for both handlers: a Vault request
that names a server-local file, or inherits the stored credentials by
sending a blank token, must keep the operator-configured address and
namespace, the same file-based auth method (role, path, mount) and the
same TLS paths and verification setting. Requests with explicit inline
credentials and no file paths are unchanged.

Refs rustfs/backlog#3021 (ANT-2026-F8MRFKCC).
2026-11-04 15:39:43 +08:00
Chris An 73ea527f55 fix(auth): canonicalize s3:VersionId before policy evaluation (#9150)
The object handlers accept several spellings of one version id (upper
case, unhyphenated, braced, urn:uuid:) and the storage layer resolves
all of them to the same version, but the s3:VersionId condition value
was the client string verbatim. A Deny with StringEquals on one version
could therefore be bypassed by re-spelling the id in ?versionId=.

Canonicalize the id once where the condition value is produced so every
hook and policy type (IAM, bucket, session) sees the spelling the server
emits: hyphenated lower-case UUID, with null in any case and the nil UUID
mapped to null. Non-UUID strings pass through unchanged and are rejected
by the handlers as before.

Refs rustfs/backlog#3022 (ANT-2026-O5MHY8VJ).
2026-11-04 15:39:38 +08:00
hector 55da14ad82 ci(nightly): embed the console UI in the GNU nightly build (#9158) 2026-11-04 15:21:41 +08:00
唐小鸭 116a5299b2 fix(replication): replay the MRF journal periodically (#9139)
* fix(replication): replay the MRF journal periodically

The persisted MRF journal was replayed only at startup. Delete-marker
purges whose watch window drained with targets unreachable, and
replications that overflowed the worker queues, reach the journal while
the server runs, so they waited for a restart to converge.

Move the startup pass into replay_mrf_journal and rerun it every five
minutes from a background loop. The existing recovery leader lock keeps
one pass at a time across the cluster, including the startup pass.

* fix(replication): space periodic MRF replay across nodes

Every node runs the MRF replay loop and the recovery leader lock only
serializes passes, so an N-node cluster replayed the same retained
backlog N times per interval while a target stayed down.

Record the start time of a pass that finds entries in a shared replay
stamp and skip a periodic pass when any node started one within the
interval. The startup pass still always runs.
2026-11-03 17:06:14 +00:00
Hauser Zhi fbc54f7212 fix(heal): treat SlowDown as a rate signal and bound page churn (#9144)
* fix(heal): treat SlowDown as a rate signal and bound page churn

* refactor(heal): unify page rate control and drop checkpoint clones

* refactor(heal): one pass-wide rate controller, single SlowDown observe

* fix(heal): address SlowDown backoff and checkpoint baseline review
2026-11-03 17:05:05 +00:00
Ramakrishna Chilaka 3aa492f5ee perf(ecstore,rpc): shard hot-path delete, multipart, and RPC lock tables (#9143)
Replace the single-mutex PREPARED_DELETE_RECEIPTS and ACTIVE_MULTIPART_SOURCE_HEADS maps in rustfs-ecstore with a 64-shard ShardedPathCache backed by SWAR canonical byte keys and xxh3_64 hashing. Replace PUT_FILE_TARGET_LOCKS in rustfs internode RPC with a 64-shard ShardedPutFileTargetLockRegistry using stack-buffered binary keys, in-place dead-Weak slot reuse, and amortized garbage collection.
2026-11-03 15:11:58 +00:00
Hauser Zhi 00a81b4048 chore(deps): refresh crate requirements and lockfile (#9140)
Run cargo update and cargo upgrade twice to bump compatible requirements (jiff, zip, dial9) and refresh transitive lockfile entries.
2026-11-03 15:04:29 +00:00
唐小鸭 20ef9ebf15 fix(replication): skip site-replication rules in replica delete fan-out (#9138)
* fix(replication): skip site-replication rules in replica delete fan-out

A site that receives a replicated delete forwards it to its own targets
so chained replication keeps propagating deletes. The fan-out used the
whole bucket config, including derived site-replication rules, so in a
site-replication mesh a replicated purge was echoed back to its origin
and duplicated to every other peer. The echo could remove the origin's
pending-purge version before its own delivery state was written back.

Ignore derived site-replication rules when computing the replica delete
fan-out. The origin already delivers and retries every delete to each
peer itself. Operator rules still fan out, so chained replication is
unchanged.

* fix(replication): keep operator rules matching the site-repl shape

Rule ids and target ARN shapes are not reserved for site replication:
an ordinary bucket target ARN carries its target id, so an operator rule
named site-repl-<target id> matched the derived shape and was dropped
from replica delete fan-out, breaking an A -> B -> C chain.

Skip a rule only when its deployment id is a current remote
site-replication peer, reading the peer set only when a rule has the
derived shape. If the state cannot be read, skip nothing.
2026-11-03 12:08:08 +00:00
Boyi (Elliot) Li cc08e0d11b feat(fips): route internode RPC HMAC through aws-lc-rs FIPS (#9137)
* feat(fips): route internode RPC HMAC through aws-lc-rs FIPS

* Revise KMS cryptographic compliance details

Updated the KMS cryptographic compliance documentation to reflect changes in HMAC authentication and added details about the internode RPC HMAC authentication.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Hauser Zhi <housemecn@gmail.com>

---------

Signed-off-by: Hauser Zhi <housemecn@gmail.com>
Co-authored-by: Hauser Zhi <housemecn@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-11-03 06:56:56 +00:00
Hauser Zhi 69294523d5 fix(heal): preserve lock, format, and repair identity (#9132)
* fix(heal): preserve lock, format, and repair identity

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

* fix(ecstore): keep pool meta legacy error message stable for ratchet

Co-authored-by: houseme <4829346+houseme@users.noreply.github.com>

* fix(heal): drop redundant clone in MRF dedup test

Co-authored-by: houseme <4829346+houseme@users.noreply.github.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: Chris An <anzhengchao@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: houseme <4829346+houseme@users.noreply.github.com>
2026-11-03 04:59:06 +00:00
Chris An cd4d86f28a fix(replication): finish writeback for already purged versions (#9135) 2026-11-03 02:08:32 +00:00
Hauser Zhi 2f55cf69e1 perf(filemeta): grow metacache read chunks geometrically (#9133)
Large metacache elements (an xl.meta grown past 16 MiB with its version
history) were accumulated in fixed 64 KiB reads, paying hundreds of
read rounds per entry on the listing path. Double the per-round chunk up
to a 4 MiB bound so each reservation stays incremental and bounded
(rustfs/rustfs#2715 corrupt-length-prefix safety is unchanged) while
wide listings and oversized entries decode in far fewer rounds.

Add regression coverage for rustfs/rustfs#9120: a listing whose
aggregate stream exceeds the per-value 16 MiB bound, and an oversized
entry listed among ordinary neighbors with peek/skip interleaving.
2026-11-03 06:07:41 +08:00
Hauser Zhi fff6e94378 fix(metrics): reconcile peer-health gauge on successful RPCs and probes (#9134)
The rustfs_cluster_servers_offline_total gauge was event-driven only:
record_peer_reachable fired solely after a fresh connector.connect()
succeeded. When post-recovery traffic was served by a cached channel
(tonic auto-reconnect, addr-form cache-key split, or delete-recovery
channel), no fresh dial occurred and the gauge stayed stale until the
process restarted.

Add record_peer_reachable_if_offline (fast-path read / slow-path write)
and wire it into every successful peer interaction:
- remote-disk recovery probe success (gRPC DiskInfo)
- periodic TCP connectivity check success (bounded-interval reconcile)
- data-path RPC success (execute_with_timeout_for_op_and_health_action)
- lock-path RPC success (record_rpc_success and detached completion)

Fixes rustfs/rustfs#9119
Tracking: rustfs/backlog#3138
2026-11-02 19:07:37 +00:00
Hauser Zhi 41b13aeb8b fix(tls): unify FIPS provider policy across clients (#9125)
* fix(tls): enforce active FIPS provider at startup

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

* ci: provision FIPS validation runner

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

* fix(tls): validate FIPS before observability startup

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

* perf(tls): reuse the installed crypto provider

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

* fix(ecstore): gate Linux-only commit helpers

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

* refactor(tls): centralize FIPS provider policy

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: Chris An <anzhengchao@gmail.com>
2026-11-02 16:58:42 +00:00
weile321 88587a8f94 fix(s3select-api): clamp overflowing u64 GetRange values to i64::MAX (#9118) 2026-11-02 14:05:07 +00:00
hector 8c8b5da63f ci(nightly): dispatch nightly-candidate-ready after asset publish (#9131)
After the nightly packages are on the auto-testing nightly release and
the schema 2 R2 candidate exists, post repository_dispatch to
rustfs/auto-testing. Vault lanes stay parallel, and the workflow_run
functional chain is left in place.

Co-authored-by: hector <majinghe@users.noreply.github.com>
2026-11-02 23:05:07 +08:00
Henry Guo 00a238f838 fix(table-catalog): resolve superseded staged commits (#9124)
fix(table-catalog): diagnose superseded staged commits

Co-authored-by: Chris An <anzhengchao@gmail.com>
2026-11-02 23:04:22 +08:00
hector 965d805a4f ci: run distributed and upgrade e2e on schedule only (#9126)
Remove pull_request from the distributed and upgrade workflows. Distributed
stays on the nightly cron, and upgrade stays on the Monday cron plus
version tags. Both remain manual-dispatch lanes and stay outside the
required merge checks.

Co-authored-by: majinghe <majinghe@users.noreply.github.com>
2026-11-02 23:03:36 +08:00
Hauser Zhi 9d66e04ae3 test(e2e): honor namespace backpressure in mixed workload (#9122)
Give each mixed-workload request a bounded policy for the server's
explicit namespace lock timeout response and Retry-After advice. Keep
unrelated SDK failures and data mismatches immediately visible.

Preserve 24 workers, four rounds, cross-node checks and all retained
objects. Cover exhausted SDK retries, bodyless HEAD, fatal responses,
persistent contention and incorrect acknowledged bytes, and include the
new cases in the protected distributed profile inventory.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-02 23:02:22 +08:00
dependabot[bot] 12c58d25b5 chore(deps): bump the dependencies group across 1 directory with 5 updates (#9113)
* chore(deps): bump the dependencies group across 1 directory with 5 updates

Bumps the dependencies group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [pulsar](https://github.com/streamnative/pulsar-rs) | `6.9.0` | `6.9.1` |
| [arrow-array](https://github.com/apache/arrow-rs) | `59.3.2` | `60.1.0` |
| [arrow-ipc](https://github.com/apache/arrow-rs) | `59.3.2` | `60.1.0` |
| [arrow-schema](https://github.com/apache/arrow-rs) | `59.3.2` | `60.1.0` |
| [parquet](https://github.com/apache/arrow-rs) | `59.3.2` | `60.1.0` |



Updates `pulsar` from 6.9.0 to 6.9.1
- [Release notes](https://github.com/streamnative/pulsar-rs/releases)
- [Commits](https://github.com/streamnative/pulsar-rs/compare/v6.9.0...v6.9.1)

Updates `arrow-array` from 59.3.2 to 60.1.0
- [Release notes](https://github.com/apache/arrow-rs/releases)
- [Changelog](https://github.com/apache/arrow-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/apache/arrow-rs/compare/59.3.2...60.1.0)

Updates `arrow-ipc` from 59.3.2 to 60.1.0
- [Release notes](https://github.com/apache/arrow-rs/releases)
- [Changelog](https://github.com/apache/arrow-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/apache/arrow-rs/compare/59.3.2...60.1.0)

Updates `arrow-schema` from 59.3.2 to 60.1.0
- [Release notes](https://github.com/apache/arrow-rs/releases)
- [Changelog](https://github.com/apache/arrow-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/apache/arrow-rs/compare/59.3.2...60.1.0)

Updates `parquet` from 59.3.2 to 60.1.0
- [Release notes](https://github.com/apache/arrow-rs/releases)
- [Changelog](https://github.com/apache/arrow-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/apache/arrow-rs/compare/59.3.2...60.1.0)

---
updated-dependencies:
- dependency-name: arrow-array
  dependency-version: 60.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
- dependency-name: arrow-ipc
  dependency-version: 60.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
- dependency-name: arrow-schema
  dependency-version: 60.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
- dependency-name: parquet
  dependency-version: 60.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
- dependency-name: pulsar
  dependency-version: 6.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): update dependencies and pin pulsar 6.9.0

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

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

* chore(deps): ignore pulsar 6.9 updates

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-02 23:01:55 +08:00
Ramakrishna Chilaka 1ac1d883f4 perf(ecstore): shard disk path lock registries with SWAR byte keys (#9127) 2026-11-02 23:00:48 +08:00
GatewayJ 45ba33291a fix(ecstore): release retained file descriptor cache invalidations (#9112)
* fix(ecstore): release retained descriptor cache invalidations

* test(ecstore): await descriptor cleanup after invalidation
2026-11-02 10:56:08 +00:00
hector 636eded277 ci(validation): resolve the newest nightly build on scheduled sweeps (#9114)
* ci(validation): resolve the newest nightly build on scheduled sweeps

The first scheduled sweep failed at candidate resolution: a schedule
carries no workflow inputs and resolve_functional_candidate.py only
derives a build from a workflow_run payload, so the empty
BUILD_RUN_ID crashed int(). Look up the newest successful
nightly-gnu run explicitly when the inputs are empty and feed it to
the resolver. Also downgrade the evidence artifact to
if-no-files-found: warn so an early infra failure does not cascade
into a second red step.

* ci(validation): read the resolver chain envelope correctly

CHAIN_OUTPUT is the chain envelope {schema, run_id, attempt,
workflow_sha, testing_sha, candidate}; the manifest lives under
candidate.manifest and the testing sha at the top level.

* ci(validation): sweep harness fixes from the first live run

- ssh -n in the inter-case cleanup loop: plain ssh consumed the case
  list from the loop's stdin, so the first dispatch executed only one
  of the twelve selected cases.
- Provide the managed Python 3.12 runtime for pool/ilm/replication
  cases, mirroring the chain validation job (pool.status reported
  INFRA_ERROR after its assertions passed; the missing runtime is the
  prime suspect).
- Record the auto-testing sha in the report step environment.
- Retain the last case's evidence tree in the sweep artifact so gate
  verdicts like INFRA_ERROR can be diagnosed from the run itself.

* ci(validation): install sweep Python client dependencies

Scheduled sweeps failed pool.status before any assertion because
pool_behavior.py exits when boto3 is missing. Install the auto-testing
validation requirements into a per-run virtualenv and put it on PATH,
matching the functional chain, so case scripts can import the client.

Co-authored-by: hector <majinghe@users.noreply.github.com>

---------

Co-authored-by: hector <majinghe@users.noreply.github.com>
2026-11-02 08:34:13 +00:00
Hauser Zhi ed4099ebe1 chore(deps): update crates and fix storage CI regressions (#9111)
* chore(deps): refresh crates and adopt openidconnect 4.1

Use published OIDC/OAuth2 releases and reject malformed HMAC signature
lengths before upstream verification. Refresh workspace and fuzz locks,
and advance the local zstd compatibility patch to 0.13.4 while retaining
the fixed 0.14.3 codec backend.

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

* chore(deps): refresh compatible lockfile resolutions

Resolve Prost build and derive dependencies with itertools 0.14 and
prefer windows-sys 0.61.2 within the existing supported version ranges.
Retain all package versions, sources and checksums.

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

* test(connect): separate cleanup checks from short probe windows

Use the functional measurement budget for the cleanup HTTP fixture so
setup and measured I/O can finish before checking delete status handling.
Exercise both normal scheduling and an in-flight PUT clock advance, and
include target classification in unexpected-failure diagnostics.

Retain the cleanup deadline, outer timeout and delete-stage assertions.

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

* test(ecstore): isolate stalled multipart disk timeouts

Apply the short injected timeout only to the deliberately stalled disk.
Healthy disks retain their normal operation budgets, while logical lock,
physical owner, immediate CopyObject and final shard assertions remain.

Keep overrides scoped across cloned and reconnected handles, preserve
shorter caller deadlines and explicit zero timeouts, and report drive
health when the real CopyObject assertion fails.

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

* fix(rebalance): retry metadata write lock quorum admission

Recognize typed write-lock quorum failures for rebalance.bin within the
existing bounded metadata admission retry policy. Each attempt must
acquire quorum before loading or saving metadata; data writes are not
replayed by this retry.

Cover recovered and persistent quorum failures, cancellation after a
failed attempt, and rejection of unrelated resource or read-lock errors.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-11-02 06:49:24 +00:00
224 changed files with 16472 additions and 2325 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=686dcc075e41a75dde3d37e5bd0393d016469f02f83aa3508f92bfc17f26a4d4 sha256-linux=2f97cbbeb2dbf52919b704a3d07336c162a41f95079d941d74251f9279c494e6
sha256-darwin=686dcc075e41a75dde3d37e5bd0393d016469f02f83aa3508f92bfc17f26a4d4 sha256-darwin=2f97cbbeb2dbf52919b704a3d07336c162a41f95079d941d74251f9279c494e6
+1 -1
View File
@@ -1,2 +1,2 @@
sha256-darwin=7c705942d3a9e583ebaa85845daa21c4f81fdbf36def24db931aaef38636c53e sha256-darwin=7c705942d3a9e583ebaa85845daa21c4f81fdbf36def24db931aaef38636c53e
sha256-linux=5c33dc395233671cb77452f8fb12900ecdbeb368632222b96835d310bce1aab8 sha256-linux=01e1f5569be60674e30d9e30a9961c5c53a6cea6ca1f7913218f2adf7291d574
+1 -1
View File
@@ -1 +1 @@
sha256=00f8a56b75990b487a594b97c36610b94476a6944ba00473e37187ea654d42bf sha256=f794c004e5952147c79dadbe6b7c84804bc986f34a8142731fac8864004e5130
+2 -2
View File
@@ -701,7 +701,7 @@ test-group = 'ecstore-serial-flaky'
[profile.e2e-smoke] [profile.e2e-smoke]
default-filter = """ default-filter = """
package(e2e_test) & ( package(e2e_test) & (
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/) test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|multipart_upload_id|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/) | test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/) | test(/^reliant::lifecycle::/)
| test(/^conditional_delete_objects_test::/) | test(/^conditional_delete_objects_test::/)
@@ -827,7 +827,7 @@ slow-timeout = { period = "120s", terminate-after = 12, grace-period = "10s" }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# e2e-distributed profile — 4-node 4-disk Actions suite # e2e-distributed profile — 4-node 4-disk Actions suite
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Storage-sensitive PR / nightly / dispatch lane owned by # Nightly and manual-dispatch lane owned by
# .github/workflows/e2e-distributed.yml. # .github/workflows/e2e-distributed.yml.
# Each case starts four rustfs processes (and for site replication, two # Each case starts four rustfs processes (and for site replication, two
# clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY. # clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY.
+3 -3
View File
@@ -22,9 +22,9 @@ The following files in the project root are essential for Docker operations:
| File | Description | Usage | | File | Description | Usage |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **`docker-buildx.sh`** | **Multi-Arch Build Script**<br>Automates building and pushing Docker images for `amd64` and `arm64`. Supports release and dev channels. | `./docker-buildx.sh --push` | | **`docker-buildx.sh`** | **Multi-Arch Build Script**<br>Automates building and pushing Docker images for `amd64` and `arm64`. Supports release and dev channels. | `./docker-buildx.sh --push` |
| **`Dockerfile`** | **Production Image (Alpine)**<br>Lightweight image using musl libc. Downloads pre-built binaries from GitHub Releases. | `docker build -t rustfs:latest .` | | **`Dockerfile`** | **Production Image (Alpine)**<br>Lightweight image using musl libc. Downloads verified RustFS and `rc` binaries from GitHub Releases and bundles `/etc/rustfs/bootstrap`. | `docker build -t rustfs:latest .` |
| **`Dockerfile.glibc`** | **Production Image (Ubuntu)**<br>Standard image using glibc. Useful if you need specific dynamic libraries. | `docker build -f Dockerfile.glibc .` | | **`Dockerfile.glibc`** | **Production Image (Ubuntu)**<br>Standard image using glibc. Includes the bundled bootstrap configuration. | `docker build -f Dockerfile.glibc .` |
| **`Dockerfile.source`** | **Development Image**<br>Builds RustFS from source code. Includes build tools. Ideal for local development and CI. | `docker build -f Dockerfile.source .` | | **`Dockerfile.source`** | **Development Image**<br>Builds RustFS from source code and bundles `rc` and the default bootstrap configuration. | `docker build -f Dockerfile.source .` |
### Docker Compose Configurations ### Docker Compose Configurations
+2
View File
@@ -32,6 +32,8 @@ updates:
- "overtrue" - "overtrue"
- "majinghe" - "majinghe"
ignore: ignore:
- dependency-name: "pulsar"
versions: [ "6.9.x" ]
- dependency-name: "object_store" - dependency-name: "object_store"
versions: [ "0.13.x" ] versions: [ "0.13.x" ]
- dependency-name: "libunftp" - dependency-name: "libunftp"
+2 -5
View File
@@ -769,12 +769,10 @@
"workflow": ".github/workflows/e2e-distributed.yml", "workflow": ".github/workflows/e2e-distributed.yml",
"name": "e2e-distributed", "name": "e2e-distributed",
"roles": [ "roles": [
"pr",
"periodic", "periodic",
"manual" "manual"
], ],
"events": [ "events": [
"pull_request",
"workflow_dispatch", "workflow_dispatch",
"schedule" "schedule"
], ],
@@ -849,12 +847,11 @@
"workflow": ".github/workflows/e2e-upgrade.yml", "workflow": ".github/workflows/e2e-upgrade.yml",
"name": "Upgrade Compatibility", "name": "Upgrade Compatibility",
"roles": [ "roles": [
"pr",
"periodic", "periodic",
"manual" "manual",
"release"
], ],
"events": [ "events": [
"pull_request",
"push", "push",
"schedule", "schedule",
"workflow_dispatch" "workflow_dispatch"
+60 -1
View File
@@ -113,7 +113,7 @@ jobs:
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT" printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
echo "Base CI policy unavailable; running the full matrix." echo "Base CI policy unavailable; running the full matrix."
fi fi
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=2$' "$GITHUB_OUTPUT"; then if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=3$' "$GITHUB_OUTPUT"; then
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'rehearse_shards=false' 'packages=' 'plan=' >> "$GITHUB_OUTPUT" printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'rehearse_shards=false' 'packages=' 'plan=' >> "$GITHUB_OUTPUT"
echo "Base policy does not cover this workflow contract; retaining the full matrix." echo "Base policy does not cover this workflow contract; retaining the full matrix."
fi fi
@@ -497,6 +497,63 @@ jobs:
- name: Check offline enrollment E2E root boundary - name: Check offline enrollment E2E root boundary
run: ./scripts/check_offline_enrollment_e2e.sh run: ./scripts/check_offline_enrollment_e2e.sh
fips-provider:
name: FIPS provider boundaries
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["schedule", "workflow_dispatch"]'), github.event_name)
needs: [ quick-checks, classify-changes ]
# This builds the RustFS library test target, which exceeds hosted-runner disk capacity.
runs-on: sm-standard-4
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
mode: [default, fips]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
CARGO_BUILD_JOBS: "2"
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-fips-provider
cache-save-if: 'false'
install-build-packaging-tools: 'false'
- name: Install FIPS build tools
if: matrix.mode == 'fips'
run: sudo apt-get install -y cmake golang clang libclang-dev
- name: Verify provider installation
shell: bash
run: |
args=()
if [[ '${{ matrix.mode }}' == 'fips' ]]; then
args+=(--features fips)
fi
cargo nextest run --locked -p rustfs-tls-runtime --lib "${args[@]}" --no-tests fail -E 'test(provider_policy_isolated)'
cargo nextest run --locked -p rustfs-s3-client --lib "${args[@]}" --no-tests fail -E 'test(overflowing_closed_range_is_rejected_before_network_io)'
cargo nextest run --locked -p rustfs-targets --lib "${args[@]}" --no-tests fail -E 'test(validate_pulsar)'
cargo nextest run --locked -p rustfs-targets --lib "${args[@]}" --no-tests fail -E 'test(webhook_provider_policy_isolated)'
if [[ '${{ matrix.mode }}' == 'fips' ]]; then
cargo nextest run --locked -p rustfs-protocols --lib --features ftps,webdav,fips --no-tests fail -E 'test(protocols_reject_incompatible_host_provider)'
cargo nextest run --locked -p rustfs-targets --lib --features fips --no-tests fail -E 'test(fips_postgres_rejects_incompatible_host_provider)'
cargo nextest run --locked -p rustfs-s3-client --lib --features fips --no-tests fail -E 'test(fips_transition_rejects_incompatible_host_provider)'
fi
tests=(provider_installation_isolated prepared_generation_shares_crypto_and_serves_sni_with_mtls)
if [[ '${{ matrix.mode }}' == 'fips' ]]; then
tests+=(fips_provider_builds_fips_tls_configs fips_provider_tls_handshake_accepts_and_rejects_algorithms)
fi
for test_name in "${tests[@]}"; do
cargo nextest run --locked -p rustfs --lib "${args[@]}" --no-tests fail -E "test($test_name)"
done
cargo clippy --locked -p rustfs --lib "${args[@]}" -- -D warnings
test-and-lint-rio-v2: test-and-lint-rio-v2:
name: Test and Lint (rio-v2) name: Test and Lint (rio-v2)
# Specialized boundary, hardware and alternate-feature lanes run only on # Specialized boundary, hardware and alternate-feature lanes run only on
@@ -1397,6 +1454,7 @@ jobs:
- workspace-shard-evidence - workspace-shard-evidence
- core-contract - core-contract
- connect-feature-boundaries - connect-feature-boundaries
- fips-provider
- test-and-lint-rio-v2 - test-and-lint-rio-v2
- test-and-lint-protocols - test-and-lint-protocols
- build-rustfs-debug-binary - build-rustfs-debug-binary
@@ -1424,6 +1482,7 @@ jobs:
needs: needs:
- classify-changes - classify-changes
- connect-feature-boundaries - connect-feature-boundaries
- fips-provider
- required-checks - required-checks
- typos - typos
- quick-checks - quick-checks
+2 -28
View File
@@ -17,8 +17,8 @@
# Each selected test starts a real localhost cluster via # Each selected test starts a real localhost cluster via
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the # `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is # 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, # `[profile.e2e-distributed]` in `.config/nextest.toml`. The nightly schedule
# nightly runs, and manual dispatches all execute the same fail-closed suite. # and manual dispatches execute the same fail-closed suite.
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml. # Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
# #
# Isolated pool filesystems: expand/decommission/rebalance cases require # Isolated pool filesystems: expand/decommission/rebalance cases require
@@ -33,32 +33,6 @@
name: e2e-distributed name: e2e-distributed
on: on:
pull_request:
paths:
- "Cargo.lock"
- "Cargo.toml"
- ".config/nextest.toml"
- ".config/e2e-distributed-selection.txt"
- ".github/workflows/e2e-distributed.yml"
- "crates/audit/**"
- "crates/common/**"
- "crates/config/**"
- "crates/e2e_test/**"
- "crates/ecstore/**"
- "crates/filemeta/**"
- "crates/heal/**"
- "crates/iam/**"
- "crates/lock/**"
- "crates/madmin/**"
- "crates/notify/**"
- "crates/replication/**"
- "crates/s3-client/**"
- "crates/s3-ops/**"
- "crates/s3-types/**"
- "crates/scanner/**"
- "crates/storage-api/**"
- "crates/utils/**"
- "rustfs/**"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
filter: filter:
+7 -18
View File
@@ -12,30 +12,19 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# Single-node upgrade and rollback lane.
#
# The weekly Monday schedule, version tags, and workflow_dispatch run this
# suite. Distributed upgrade cases stay on the nightly e2e-distributed lane.
name: Upgrade Compatibility name: Upgrade Compatibility
on: on:
pull_request:
paths:
- ".github/workflows/e2e-upgrade.yml"
- "crates/e2e_test/src/common.rs"
- "crates/e2e_test/src/distributed/**"
- "crates/e2e_test/src/fake_s3_target/**"
- "crates/e2e_test/src/lib.rs"
- "crates/e2e_test/src/replication_extension_test.rs"
- "crates/e2e_test/src/rpc_fault_proxy.rs"
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
- "crates/ecstore/**"
- "crates/filemeta/**"
- "crates/heal/**"
- "crates/kms/**"
- "crates/storage-api/**"
- "rustfs/**"
- "Cargo.lock"
push: push:
tags: tags:
- "[0-9]*.[0-9]*.[0-9]*" - "[0-9]*.[0-9]*.[0-9]*"
schedule: schedule:
# 03:17 UTC every Monday.
- cron: "17 3 * * 1" - cron: "17 3 * * 1"
workflow_dispatch: workflow_dispatch:
@@ -44,7 +33,7 @@ permissions:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }} cancel-in-progress: false
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
+279 -7
View File
@@ -87,18 +87,206 @@ jobs:
install-build-packaging-tools: 'false' install-build-packaging-tools: 'false'
install-test-tools: 'false' install-test-tools: 'false'
- name: Download static console assets
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
# Packaging tools are off on this lane, so setup does not install unzip.
if ! command -v unzip >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq unzip
fi
if ! command -v unzip >/dev/null 2>&1; then
echo "unzip is required to extract console assets" >&2
exit 1
fi
mkdir -p ./rustfs/static
verify_sha256() {
local expected="$1"
local file="$2"
local actual=""
local actual_line=""
if command -v sha256sum >/dev/null 2>&1; then
actual_line=$(sha256sum -- "$file") || return 1
elif command -v shasum >/dev/null 2>&1; then
actual_line=$(shasum -a 256 -- "$file") || return 1
else
echo "No SHA-256 verification tool found" >&2
return 1
fi
actual="${actual_line%%[[:space:]]*}"
if [ "$actual" = "$expected" ]; then
echo "SHA256 verified OK: $actual"
return 0
else
echo "SHA256 mismatch: expected=$expected actual=$actual" >&2
return 1
fi
}
download_console_assets() {
local curl_bin="curl"
local python_bin="python3"
local console_api="https://api.github.com/repos/rustfs/console/releases/latest"
local console_json="console-release.json"
local console_url
local console_sha256
local curl_auth_args=()
console_tag=""
chmod +w ./rustfs/static/LICENSE || true
if ! command -v "$python_bin" >/dev/null 2>&1; then
python_bin="python"
fi
if ! command -v "$python_bin" >/dev/null 2>&1; then
echo "No Python interpreter found for release metadata parsing" >&2
return 1
fi
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
curl_auth_args=(-H "Authorization: Bearer ${GITHUB_TOKEN}" -H "X-GitHub-Api-Version: 2022-11-28")
fi
"$curl_bin" "${curl_auth_args[@]}" --fail -L "$console_api" \
-o "$console_json" --retry 3 --retry-delay 5 --max-time 300 || return 1
read -r console_tag console_url console_sha256 < <("$python_bin" - "$console_json" <<'PY'
import json
import re
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
release = json.load(handle)
tag = release.get("tag_name", "") or "unknown"
for asset in release.get("assets", []):
name = asset.get("name", "")
digest = asset.get("digest", "")
url = asset.get("browser_download_url", "")
if name.startswith("rustfs-console-") and name.endswith(".zip") and digest.startswith("sha256:") and url:
sha256 = digest.split(":", 1)[1]
if not re.fullmatch(r"[0-9a-fA-F]{64}", sha256):
raise SystemExit(f"console zip asset has invalid sha256 digest: {sha256}")
sys.stdout.buffer.write(f"{tag} {url} {sha256}\n".encode("utf-8"))
break
else:
raise SystemExit("no console zip asset with sha256 digest found")
PY
) || return 1
if [[ -z "$console_url" || -z "$console_sha256" ]]; then
echo "Console release metadata is missing URL or SHA-256 digest" >&2
return 1
fi
echo "Console release: ${console_tag}"
echo "Downloading console asset: ${console_url}"
"$curl_bin" --fail -L "$console_url" -o console.zip --retry 3 --retry-delay 5 --max-time 300 || return 1
verify_sha256 "$console_sha256" console.zip || return 2
unzip -o console.zip -d ./rustfs/static || return 2
}
if download_console_assets; then
rm -f console.zip console-release.json
else
status=$?
rm -f console.zip console-release.json
if [[ "$status" -eq 2 ]]; then
echo "Console asset integrity verification failed" >&2
fi
echo "Failed to download verified console assets" >&2
exit 1
fi
if [[ ! -s ./rustfs/static/index.html ]]; then
echo "Console asset archive is missing static/index.html" >&2
exit 1
fi
asset_count=$(find ./rustfs/static -type f | wc -l | tr -d '[:space:]')
echo "Console assets ready: version=${console_tag:-unknown}, ${asset_count} files extracted to ./rustfs/static"
- name: Build RustFS - name: Build RustFS
# Pin the glibc baseline (`<target>.<glibc>` for cargo-zigbuild) so # Pin the glibc baseline (`<target>.<glibc>` for cargo-zigbuild) so
# nightlies run on older distros such as RHEL/Rocky 9 (glibc 2.34); #8062. # nightlies run on older distros such as RHEL/Rocky 9 (glibc 2.34); #8062.
run: cargo zigbuild --release --locked --target x86_64-unknown-linux-gnu.2.31 -p rustfs --bins shell: bash
run: |
# Force rebuild by touching build.rs so a restored cache cannot
# reuse a rustfs artifact compiled before these console assets existed.
touch rustfs/build.rs
cargo zigbuild --release --locked --target x86_64-unknown-linux-gnu.2.31 -p rustfs --bin rustfs
- name: Verify glibc symbol baseline - name: Verify glibc symbol baseline
run: | run: |
python3 scripts/release/check_glibc.py 2.31 \ python3 scripts/release/check_glibc.py 2.31 \
target/x86_64-unknown-linux-gnu/release/rustfs \ target/x86_64-unknown-linux-gnu/release/rustfs
target/x86_64-unknown-linux-gnu/release/rustfs-cli
target/x86_64-unknown-linux-gnu/release/rustfs --version | grep -Fx 'build target : x86_64-unknown-linux-gnu' target/x86_64-unknown-linux-gnu/release/rustfs --version | grep -Fx 'build target : x86_64-unknown-linux-gnu'
- name: Verify console
shell: bash
run: |
set -euo pipefail
find_free_port() {
python3 - <<'PY'
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
PY
}
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
data_dir="$(mktemp -d)"
log_file="${RUNNER_TEMP}/rustfs-console-smoke.log"
server_pid=""
trap '
if [[ -n "${server_pid:-}" ]]; then
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
fi
rm -rf "$data_dir"
' EXIT
if [[ ! -x "$binary" ]]; then
echo "rustfs binary not found: ${binary}" >&2
exit 1
fi
api_port="$(find_free_port)"
console_port="$(find_free_port)"
console_url="http://127.0.0.1:${console_port}/rustfs/console/"
"$binary" server \
--address "127.0.0.1:${api_port}" \
--console-enable \
--console-address "127.0.0.1:${console_port}" \
--access-key console-smoke \
--secret-key console-smoke-secret \
"$data_dir" >"$log_file" 2>&1 &
server_pid=$!
response=""
for _ in {1..40}; do
response="$(curl --silent --output /dev/null --write-out '%{http_code} %{content_type}' "$console_url" || true)"
if [[ "$response" == 200\ text/html* ]]; then
exit 0
fi
sleep 0.25
done
echo "Console endpoint did not return 200 text/html: $response" >&2
cat "$log_file"
exit 1
- name: Build DEB package - name: Build DEB package
id: deb id: deb
shell: bash shell: bash
@@ -346,12 +534,12 @@ jobs:
path: ${{ steps.publish.outputs.candidate_file }} path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error if-no-files-found: error
# Publish the deb/rpm pair to the auto-testing repo's `assets` branch so
# engineers can download and install the nightly directly. The branch is
# a single-commit orphan rewritten on every build, which keeps the repo
# small while the latest files stay reachable at stable raw URLs.
# Publish the deb/rpm pair as assets of the rolling `nightly` release on # Publish the deb/rpm pair as assets of the rolling `nightly` release on
# rustfs/auto-testing (see scripts/release/publish_nightly_assets.sh). # rustfs/auto-testing (see scripts/release/publish_nightly_assets.sh).
# The dispatch below runs only after this upload and the immutable R2
# candidate both succeed. It does not replace rustfs-functional-chain.yml,
# which still starts from this workflow's workflow_run completion.
# kms-vault-lane and kms-vault-ha-failover stay parallel with this job.
- name: Publish packages to auto-testing release assets - name: Publish packages to auto-testing release assets
env: env:
ASSETS_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} ASSETS_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
@@ -360,6 +548,90 @@ jobs:
DEB_DATE: ${{ steps.deb.outputs.deb_date }} DEB_DATE: ${{ steps.deb.outputs.deb_date }}
BUILD_REF: ${{ env.NIGHTLY_BUILD_REF }} BUILD_REF: ${{ env.NIGHTLY_BUILD_REF }}
run: bash scripts/release/publish_nightly_assets.sh run: bash scripts/release/publish_nightly_assets.sh
# Cross-repo repository_dispatch cannot use GITHUB_TOKEN. The secret is a
# PAT or GitHub App token for rustfs/auto-testing: classic `repo` scope,
# or fine-grained Contents write (the permission GitHub checks for
# POST /repos/{owner}/{repo}/dispatches). A dispatch failure fails this
# step, and therefore this job, so the scheduled failure alert fires.
# GitHub allows at most 10 top-level client_payload properties, so the
# schema 2 candidate object is nested under `candidate` and the release
# fields sit beside it.
- name: Dispatch nightly candidate to auto-testing
if: ${{ steps.publish.outputs.candidate_file != '' }}
env:
DISPATCH_TOKEN: ${{ secrets.AUTO_TESTING_DISPATCH_TOKEN }}
CANDIDATE_FILE: ${{ steps.publish.outputs.candidate_file }}
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
SOURCE_REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
: "${DISPATCH_TOKEN:?AUTO_TESTING_DISPATCH_TOKEN is required to dispatch rustfs/auto-testing}"
: "${CANDIDATE_FILE:?nightly candidate manifest is required}"
: "${DEB_DATE:?DEB_DATE is required}"
[[ "${DEB_DATE}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]
[[ "${SOURCE_REPO}" == "rustfs/rustfs" ]]
payload="${RUNNER_TEMP}/nightly-candidate-dispatch-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --slurpfile candidate "${CANDIDATE_FILE}" \
--arg event_type "nightly-candidate-ready" \
--arg source_repo "${SOURCE_REPO}" \
--arg latest_alias_url "https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb" \
--arg auto_testing_release "https://github.com/rustfs/auto-testing/releases/tag/nightly" \
--arg channel "nightly" \
--arg deb_date "${DEB_DATE}" \
--arg nightly_workflow ".github/workflows/nightly-gnu.yml" \
'{
event_type: $event_type,
source_repo: $source_repo,
latest_alias_url: $latest_alias_url,
auto_testing_release: $auto_testing_release,
channel: $channel,
deb_date: $deb_date,
nightly_workflow: $nightly_workflow,
candidate: $candidate[0]
}' > "${payload}"
if ! jq -e '
.event_type == "nightly-candidate-ready"
and .source_repo == "rustfs/rustfs"
and .channel == "nightly"
and .deb_date == env.DEB_DATE
and .nightly_workflow == ".github/workflows/nightly-gnu.yml"
and .latest_alias_url == "https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb"
and .auto_testing_release == "https://github.com/rustfs/auto-testing/releases/tag/nightly"
and .candidate.schema == 2
and ((.candidate | keys | sort) == ["build_run_attempt","build_run_id","package_sha256","package_url","schema","source_ref","source_sha","workflow_sha"])
and ((keys | sort) == ["auto_testing_release","candidate","channel","deb_date","event_type","latest_alias_url","nightly_workflow","source_repo"])
' "${payload}" >/dev/null; then
echo "nightly dispatch payload does not match the nightly-candidate-ready contract" >&2
exit 1
fi
body="${RUNNER_TEMP}/nightly-candidate-dispatch-body-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --slurpfile client_payload "${payload}" \
'{event_type: "nightly-candidate-ready", client_payload: $client_payload[0]}' > "${body}"
response="${RUNNER_TEMP}/nightly-candidate-dispatch-response-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.txt"
status="$(curl -sS --retry 3 --connect-timeout 15 --max-time 60 \
-o "${response}" -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json" \
--data-binary @"${body}" \
https://api.github.com/repos/rustfs/auto-testing/dispatches)" || {
echo "repository_dispatch request to rustfs/auto-testing failed before an HTTP status" >&2
exit 1
}
if [[ "${status}" != "204" ]]; then
echo "repository_dispatch to rustfs/auto-testing failed: HTTP ${status}" >&2
if [[ -s "${response}" ]]; then
cat "${response}" >&2
fi
exit 1
fi
echo "dispatched nightly-candidate-ready to rustfs/auto-testing (deb_date=${DEB_DATE}, run=${GITHUB_RUN_ID}, attempt=${GITHUB_RUN_ATTEMPT})"
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774). # Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
# #
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and # RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
+41 -5
View File
@@ -92,6 +92,17 @@ jobs:
CHAIN_OUTPUT: ${{ runner.temp }}/validation-sweep-candidate.json CHAIN_OUTPUT: ${{ runner.temp }}/validation-sweep-candidate.json
run: | run: |
set -euo pipefail set -euo pipefail
# Scheduled runs carry no inputs and the resolver only derives a
# build from a workflow_run payload (which a schedule cannot
# provide), so resolve the newest successful nightly build
# explicitly before invoking it.
if [ -z "${BUILD_RUN_ID:-}" ]; then
LATEST="$(gh api "repos/rustfs/rustfs/actions/workflows/nightly-gnu.yml/runs?branch=main&status=success&per_page=1" --jq '.workflow_runs[0]')"
BUILD_RUN_ID="$(printf '%s' "${LATEST}" | jq -r '.id')"
BUILD_RUN_ATTEMPT="$(printf '%s' "${LATEST}" | jq -r '.run_attempt')"
export BUILD_RUN_ID BUILD_RUN_ATTEMPT
echo "scheduled sweep pinned to nightly ${BUILD_RUN_ID} attempt ${BUILD_RUN_ATTEMPT}"
fi
python3 scripts/resolve_functional_candidate.py python3 scripts/resolve_functional_candidate.py
python3 - <<'PY' python3 - <<'PY'
import json import json
@@ -99,15 +110,15 @@ jobs:
from pathlib import Path from pathlib import Path
root = Path(os.environ["RUNNER_TEMP"]) / "validation-sweep" root = Path(os.environ["RUNNER_TEMP"]) / "validation-sweep"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
candidate = json.loads((Path(os.environ["RUNNER_TEMP"]) / "validation-sweep-candidate.json").read_text()) chain = json.loads((Path(os.environ["RUNNER_TEMP"]) / "validation-sweep-candidate.json").read_text())
manifest = candidate["manifest"] manifest = chain["candidate"]["manifest"]
(root / "candidate.json").write_text(json.dumps({ (root / "candidate.json").write_text(json.dumps({
"package_url": manifest["package_url"], "package_url": manifest["package_url"],
"package_sha256": manifest["package_sha256"], "package_sha256": manifest["package_sha256"],
"source_sha": manifest["source_sha"], "source_sha": manifest["source_sha"],
}, sort_keys=True) + "\n") }, sort_keys=True) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output: with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write("testing_sha=" + candidate["testing_sha"] + "\n") output.write("testing_sha=" + chain["testing_sha"] + "\n")
output.write("package_url=" + manifest["package_url"] + "\n") output.write("package_url=" + manifest["package_url"] + "\n")
output.write("source_sha=" + manifest["source_sha"] + "\n") output.write("source_sha=" + manifest["source_sha"] + "\n")
PY PY
@@ -121,6 +132,26 @@ jobs:
path: auto-testing path: auto-testing
persist-credentials: false persist-credentials: false
- name: Prepare validation Python runtime
# The pool / ilm / replication validation runners require a managed
# Python, exactly like the chain's validation job provides.
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install validation Python requirements
# Case scripts call python3 and import boto3 at startup. Install the
# functional chain's client into a per-run env so that interpreter
# sees it without changing the runner's shared Python.
run: |
set -euo pipefail
validation_python="${RUNNER_TEMP}/rustfs-validation-python-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
test ! -e "${validation_python}"
python3 -m venv "${validation_python}"
"${validation_python}/bin/python" -m pip install \
-r auto-testing/scripts/validation_ilm/requirements.txt
printf '%s\n' "${validation_python}/bin" >> "${GITHUB_PATH}"
- name: Select sweep cases - name: Select sweep cases
id: select id: select
env: env:
@@ -224,7 +255,8 @@ jobs:
# topology, this purge guarantees the next case starts clean even # topology, this purge guarantees the next case starts clean even
# when the previous one died mid-run. # when the previous one died mid-run.
for node in ${RUSTFS_NODES}; do for node in ${RUSTFS_NODES}; do
ssh -o BatchMode=yes -o ConnectTimeout=10 "${RUSTFS_SSH_USER}@${node}" ' # -n keeps ssh from consuming the case list this loop reads from stdin
ssh -n -o BatchMode=yes -o ConnectTimeout=10 "${RUSTFS_SSH_USER}@${node}" '
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n" SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
${SUDO} systemctl stop rustfs 2>/dev/null || true ${SUDO} systemctl stop rustfs 2>/dev/null || true
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
@@ -243,6 +275,7 @@ jobs:
SWEEP_ROOT: ${{ runner.temp }}/validation-sweep SWEEP_ROOT: ${{ runner.temp }}/validation-sweep
PACKAGE_URL: ${{ steps.candidate.outputs.package_url }} PACKAGE_URL: ${{ steps.candidate.outputs.package_url }}
SOURCE_SHA: ${{ steps.candidate.outputs.source_sha }} SOURCE_SHA: ${{ steps.candidate.outputs.source_sha }}
AUTO_TESTING_SHA: ${{ steps.candidate.outputs.testing_sha }}
run: | run: |
set -euo pipefail set -euo pipefail
python3 - <<'PY' python3 - <<'PY'
@@ -351,7 +384,10 @@ jobs:
${{ runner.temp }}/validation-sweep/sweep-cases.txt ${{ runner.temp }}/validation-sweep/sweep-cases.txt
${{ runner.temp }}/validation-sweep/candidate.json ${{ runner.temp }}/validation-sweep/candidate.json
${{ runner.temp }}/validation-sweep/results/ ${{ runner.temp }}/validation-sweep/results/
if-no-files-found: error ${{ runner.temp }}/validation-sweep/run/
# warn (not error): an early infra failure legitimately leaves no
# per-case evidence, and the failing step is already red.
if-no-files-found: warn
retention-days: 30 retention-days: 30
- name: Cleanup environment (after) - name: Cleanup environment (after)
-26
View File
@@ -107,32 +107,6 @@ jobs:
shell: pwsh shell: pwsh
run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture
# Native multipart completion publishes its payload from a different
# parent than its metadata, and abort renames under a precondition; both
# need their own Windows handle discipline.
- name: Test native multipart complete and abort publication
shell: pwsh
run: >-
cargo test -p rustfs-ecstore --lib --
native_abort_missing_state_with_actual_owned_records_never_becomes_legacy_or_missing
native_abort_pending_intent_is_not_a_delete_certificate_and_raw_cleanup_preserves_exact_evidence
--nocapture
# Owned-undo reclaim removes a directory it pinned, copy heal persists
# fault bytes through a handle, and metadata reads meet a directory.
- name: Test native multipart reclaim, copy heal, and directory reads
shell: pwsh
run: >-
cargo test -p rustfs-ecstore --lib --
native_multipart_owned_undo
native_multipart_copy_heal
orphan_multipart_cleanup_rejects_metadata_directory_on_one_disk
--nocapture
- name: Test directory removal fallback
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib disk::fs::tests::test_remove -- --nocapture"
- name: Test Windows path conversion - name: Test Windows path conversion
shell: pwsh shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture" run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
+1
View File
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)). - 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)).
- A `set-remote-target?update=true&creds=true` request on a site-replication peer's bucket target is now rejected with `400 InvalidRequest`, naming the target and why it counts as peer-owned. It used to return success while keeping the stored endpoint, credentials, and TLS settings, so an endpoint edit from the Console's bucket replication form appeared to apply but did not. Change a peer's address with a site-replication edit instead; see "Changing a site's address" in `docs/operations/site-replication-operations.md`. The `sync`, `bandwidth`, `proxy`, and `path` update groups are unchanged. - A `set-remote-target?update=true&creds=true` request on a site-replication peer's bucket target is now rejected with `400 InvalidRequest`, naming the target and why it counts as peer-owned. It used to return success while keeping the stored endpoint, credentials, and TLS settings, so an endpoint edit from the Console's bucket replication form appeared to apply but did not. Change a peer's address with a site-replication edit instead; see "Changing a site's address" in `docs/operations/site-replication-operations.md`. The `sync`, `bandwidth`, `proxy`, and `path` update groups are unchanged.
- Site-replication edits that omit the site name, such as `mc admin replicate update --deployment-id <ID> --endpoint <URL>`, now keep the stored name on the editing site and on every receiver instead of renaming the site after the new endpoint host. No persisted format changes. - Site-replication edits that omit the site name, such as `mc admin replicate update --deployment-id <ID> --endpoint <URL>`, now keep the stored name on the editing site and on every receiver instead of renaming the site after the new endpoint host. No persisted format changes.
- The persisted replication MRF journal is now replayed every 5 minutes while the server runs, not only at startup. Delete-marker purges whose targets were unreachable, and replications that overflowed the worker queues, now converge within one replay interval after the target returns instead of waiting for a restart. A target that stays down is re-attempted once per retained entry per interval across the cluster: the start time of the last pass is shared through a small `config/replication/mrf.bin.replay` object, so nodes do not each replay once per interval. Older versions ignore that object; existing persisted formats are unchanged.
- 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`. - 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 ### Security
Generated
+378 -195
View File
File diff suppressed because it is too large Load Diff
+14 -17
View File
@@ -147,7 +147,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
async-channel = "2.5.0" async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" } async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37.1" } mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.51" } async-compression = { version = "0.4.52" }
async-trait = "0.1.92" async-trait = "0.1.92"
async-nats = { version = "0.51.0", default-features = false } async-nats = { version = "0.51.0", default-features = false }
axum = "0.8.9" axum = "0.8.9"
@@ -168,7 +168,7 @@ http-body-util = "0.1.5"
memchr = "2.8.3" memchr = "2.8.3"
minlz = "1.2.3" minlz = "1.2.3"
reqwest = "0.13.5" reqwest = "0.13.5"
rustfs-kafka-async = { version = "1.4.0" } rustfs-kafka-async = { version = "1.4.1" }
socket2 = { version = "0.6.5" } socket2 = { version = "0.6.5" }
tokio = { version = "1.54.1" } tokio = { version = "1.54.1" }
tokio-rustls = { default-features = false, version = "0.26.6" } tokio-rustls = { default-features = false, version = "0.26.6" }
@@ -212,12 +212,13 @@ serde_urlencoded = "0.7.1"
# releases. # releases.
aes-gcm = { version = "0.11.1" } aes-gcm = { version = "0.11.1" }
argon2 = { version = "0.6.0" } argon2 = { version = "0.6.0" }
aws-lc-rs = { version = "1.19.0", default-features = false }
blake2 = "0.11.0" blake2 = "0.11.0"
chacha20poly1305 = { version = "0.11.0" } chacha20poly1305 = { version = "0.11.0" }
crc-fast = "1.10.0" crc-fast = "1.10.0"
hmac = { version = "0.13.0" } hmac = { version = "0.13.0" }
jsonwebtoken = { version = "11.1.0" } jsonwebtoken = { version = "11.1.0" }
openidconnect = { default-features = false, version = "4.0" } openidconnect = { default-features = false, version = "4.1.0" }
pbkdf2 = "0.13.0" pbkdf2 = "0.13.0"
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] } p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
rsa = { version = "=0.10.0-rc.18" } rsa = { version = "=0.10.0-rc.18" }
@@ -234,7 +235,7 @@ proptest = "1"
# Time and Date # Time and Date
chrono = { version = "0.4.45" } chrono = { version = "0.4.45" }
humantime = "2.4.0" humantime = "2.4.0"
jiff = { version = "0.2.38" } jiff = { version = "0.2.39" }
time = { version = "0.3.55" } time = { version = "0.3.55" }
# Database # Database
@@ -253,9 +254,9 @@ atoi = "3.1.1"
atomic_enum = "0.3.0" atomic_enum = "0.3.0"
aws-config = { version = "1.13.0" } aws-config = { version = "1.13.0" }
aws-credential-types = { version = "1.3.1" } aws-credential-types = { version = "1.3.1" }
aws-sdk-kms = { default-features = false, version = "1.128.0" } aws-sdk-kms = { default-features = false, version = "1.129.0" }
aws-sdk-s3 = { default-features = false, version = "1.156.0" } aws-sdk-s3 = { default-features = false, version = "1.157.0" }
aws-sdk-sts = { default-features = false, version = "1.123.0" } aws-sdk-sts = { default-features = false, version = "1.124.0" }
aws-smithy-async = { version = "1.3.1" } aws-smithy-async = { version = "1.3.1" }
aws-smithy-http-client = { default-features = false, version = "1.5.2" } aws-smithy-http-client = { default-features = false, version = "1.5.2" }
aws-smithy-runtime-api = { version = "1.19.2" } aws-smithy-runtime-api = { version = "1.19.2" }
@@ -337,7 +338,7 @@ strum = { version = "0.28.0" }
sysinfo = "0.40.1" sysinfo = "0.40.1"
temp-env = "0.3.6" temp-env = "0.3.6"
tempfile = "3.27.0" tempfile = "3.27.0"
test-case = "3.4.0" test-case = "3.4.1"
thiserror = "2.0.21" thiserror = "2.0.21"
tracing = { version = "0.1.44" } tracing = { version = "0.1.44" }
tracing-appender = "0.2.5" tracing-appender = "0.2.5"
@@ -356,13 +357,13 @@ winapi-util = "0.1.11"
windows = { version = "0.62.2" } windows = { version = "0.62.2" }
windows-sys = "0.61.2" windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.19" } xxhash-rust = { version = "0.8.19" }
zip = "9.1.3" zip = "9.3.0"
zstd = "0.14.3" zstd = "0.14.3"
# Observability and Metrics # Observability and Metrics
metrics = "0.24.6" metrics = "0.24.6"
metrics-util = "0.20" metrics-util = "0.20"
dial9 = { version = "0.5.6", default-features = false } dial9 = { version = "0.5.7", default-features = false }
opentelemetry = { version = "0.33.1" } opentelemetry = { version = "0.33.1" }
opentelemetry-appender-tracing = { version = "0.33.1" } opentelemetry-appender-tracing = { version = "0.33.1" }
opentelemetry-otlp = { version = "0.33.1" } opentelemetry-otlp = { version = "0.33.1" }
@@ -377,16 +378,16 @@ libunftp = { version = "0.24.0" }
unftp-core = "0.1.0" unftp-core = "0.1.0"
suppaftp = { version = "12.2.1" } suppaftp = { version = "12.2.1" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] } rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.65.0" } russh = { version = "0.65.1" }
russh-sftp = "3.0.1" russh-sftp = "3.0.1"
# WebDAV # WebDAV
dav-server = "0.11.0" dav-server = "0.11.0"
# Performance Analysis and Memory Profiling # Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.7.2" } rustfs-mimalloc = { version = "0.7.3" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved. # Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.30.2", default-features = false } hotpath = { version = "0.30.3", default-features = false }
# Snapshot testing for output format regression detection # Snapshot testing for output format regression detection
insta = { version = "1.49" } insta = { version = "1.49" }
@@ -427,7 +428,3 @@ strip = "none"
# Pin upstream fixes until compatible crates.io releases are available. # Pin upstream fixes until compatible crates.io releases are available.
[patch.crates-io] [patch.crates-io]
zstd = { path = "crates/zstd-compat" } zstd = { path = "crates/zstd-compat" }
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
oauth2 = { git = "https://github.com/ramosbugs/oauth2-rs", rev = "89585f4a280afd7b285eaf1c9be0068874e03d22" }
+11 -3
View File
@@ -16,6 +16,7 @@ FROM alpine:3.24.2 AS build
ARG TARGETARCH ARG TARGETARCH
ARG RELEASE=latest ARG RELEASE=latest
ARG RC_RELEASE=latest
RUN apk add --no-cache ca-certificates curl jq unzip RUN apk add --no-cache ca-certificates curl jq unzip
WORKDIR /build WORKDIR /build
@@ -69,6 +70,9 @@ RUN set -eux; \
chmod +x /build/rustfs; \ chmod +x /build/rustfs; \
rm -rf rustfs.zip /build/.tmp || true rm -rf rustfs.zip /build/.tmp || true
COPY scripts/download_rc.sh /usr/local/bin/download_rc.sh
RUN sh /usr/local/bin/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /build/rc
FROM alpine:3.24.2 FROM alpine:3.24.2
@@ -95,14 +99,18 @@ RUN apk upgrade --no-cache && \
ca-certificates \ ca-certificates \
coreutils \ coreutils \
curl \ curl \
jq \
tzdata \ tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530" && test "$(TZ=Asia/Kolkata date +%z)" = "+0530"
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /build/rustfs /usr/bin/rustfs COPY --from=build /build/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh COPY --from=build /build/rc /usr/bin/rc
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
COPY bootstrap/ /etc/rustfs/bootstrap/
RUN chmod +x /usr/bin/rustfs /entrypoint.sh RUN chmod +x /usr/bin/rustfs /usr/bin/rc /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
RUN addgroup -g 10001 -S rustfs && \ RUN addgroup -g 10001 -S rustfs && \
adduser -u 10001 -G rustfs -S rustfs -D && \ adduser -u 10001 -G rustfs -S rustfs -D && \
@@ -124,6 +132,6 @@ VOLUME ["/data"]
USER rustfs USER rustfs
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["rustfs"] CMD ["rustfs"]
+13 -4
View File
@@ -12,7 +12,10 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
FROM rust:1.97.1-trixie FROM rust:1.99.1-trixie
ARG TARGETARCH
ARG RC_RELEASE=latest
RUN set -eux; \ RUN set -eux; \
export DEBIAN_FRONTEND=noninteractive; \ export DEBIAN_FRONTEND=noninteractive; \
@@ -21,6 +24,7 @@ RUN set -eux; \
build-essential \ build-essential \
ca-certificates \ ca-certificates \
curl \ curl \
jq \
git \ git \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
@@ -36,6 +40,7 @@ COPY . .
RUN ./scripts/static.sh RUN ./scripts/static.sh
RUN cargo run --bin gproto RUN cargo run --bin gproto
RUN cargo build --release --locked --bin rustfs RUN cargo build --release --locked --bin rustfs
RUN sh /usr/src/rustfs/scripts/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /usr/local/bin/rc
RUN set -eux; \ RUN set -eux; \
groupadd -g 10001 rustfs; \ groupadd -g 10001 rustfs; \
@@ -50,8 +55,12 @@ RUN set -eux; \
chown -R rustfs:rustfs /data /logs /app; \ chown -R rustfs:rustfs /data /logs /app; \
chmod 0750 /data /logs chmod 0750 /data /logs
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /etc/rustfs/entrypoint.sh
RUN install -m 0755 /usr/src/rustfs/target/release/rustfs /usr/bin/rustfs && chmod +x /entrypoint.sh COPY bootstrap/ /etc/rustfs/bootstrap/
RUN install -m 0755 /usr/src/rustfs/target/release/rustfs /usr/bin/rustfs && \
install -m 0755 /usr/local/bin/rc /usr/bin/rc && \
chmod +x /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
ENV RUSTFS_VOLUMES="/data" \ ENV RUSTFS_VOLUMES="/data" \
RUST_LOG="warn" \ RUST_LOG="warn" \
@@ -63,5 +72,5 @@ ENV RUSTFS_VOLUMES="/data" \
EXPOSE 9000 9001 EXPOSE 9000 9001
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["/usr/bin/rustfs"] CMD ["/usr/bin/rustfs"]
+11 -3
View File
@@ -16,6 +16,7 @@ FROM ubuntu:26.04 AS build
ARG TARGETARCH ARG TARGETARCH
ARG RELEASE=latest ARG RELEASE=latest
ARG RC_RELEASE=latest
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
@@ -76,6 +77,9 @@ RUN set -eux; \
chmod +x /build/rustfs; \ chmod +x /build/rustfs; \
rm -rf rustfs.zip /build/.tmp || true rm -rf rustfs.zip /build/.tmp || true
COPY scripts/download_rc.sh /usr/local/bin/download_rc.sh
RUN sh /usr/local/bin/download_rc.sh "$TARGETARCH" gnu "$RC_RELEASE" /build/rc
FROM ubuntu:26.04 FROM ubuntu:26.04
ARG RELEASE=latest ARG RELEASE=latest
@@ -99,14 +103,18 @@ RUN apt-get update && apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
curl \ curl \
jq \
tzdata \ tzdata \
&& test "$(TZ=Asia/Kolkata date +%z)" = "+0530" \ && test "$(TZ=Asia/Kolkata date +%z)" = "+0530" \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=build /build/rustfs /usr/bin/rustfs COPY --from=build /build/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh COPY --from=build /build/rc /usr/bin/rc
COPY entrypoint.sh /etc/rustfs/entrypoint.sh
COPY bootstrap/ /etc/rustfs/bootstrap/
RUN chmod +x /usr/bin/rustfs /entrypoint.sh RUN chmod +x /usr/bin/rustfs /usr/bin/rc /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
RUN groupadd -g 10001 rustfs && \ RUN groupadd -g 10001 rustfs && \
useradd -u 10001 -g rustfs -m -s /sbin/nologin rustfs && \ useradd -u 10001 -g rustfs -m -s /sbin/nologin rustfs && \
@@ -131,6 +139,6 @@ VOLUME ["/data"]
USER rustfs USER rustfs
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["rustfs"] CMD ["rustfs"]
+19 -7
View File
@@ -28,17 +28,19 @@ ARG TARGETPLATFORM
ARG BUILDPLATFORM ARG BUILDPLATFORM
ARG TARGETARCH ARG TARGETARCH
ARG RUSTFS_BUILD_FEATURES="" ARG RUSTFS_BUILD_FEATURES=""
ARG RC_RELEASE=latest
# ----------------------------- # -----------------------------
# Build stage # Build stage
# ----------------------------- # -----------------------------
FROM rust:1.97.1-trixie AS builder FROM rust:1.99.1-trixie AS builder
# Re-declare args after FROM # Re-declare args after FROM
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG BUILDPLATFORM ARG BUILDPLATFORM
ARG TARGETARCH ARG TARGETARCH
ARG RUSTFS_BUILD_FEATURES ARG RUSTFS_BUILD_FEATURES
ARG RC_RELEASE
# Debug: print platforms # Debug: print platforms
RUN echo "Build info -> BUILDPLATFORM=${BUILDPLATFORM}, TARGETPLATFORM=${TARGETPLATFORM}" RUN echo "Build info -> BUILDPLATFORM=${BUILDPLATFORM}, TARGETPLATFORM=${TARGETPLATFORM}"
@@ -52,6 +54,7 @@ RUN set -eux; \
build-essential \ build-essential \
ca-certificates \ ca-certificates \
curl \ curl \
jq \
git \ git \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
@@ -166,6 +169,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
;; \ ;; \
esac esac
RUN sh /usr/src/rustfs/scripts/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /usr/local/bin/rc
# ----------------------------- # -----------------------------
# Development stage (keeps toolchain) # Development stage (keeps toolchain)
# ----------------------------- # -----------------------------
@@ -199,10 +204,13 @@ ENV RUSTFS_VOLUMES="/data" \
# We rely on cargo run to build and run # We rely on cargo run to build and run
EXPOSE 9000 9001 EXPOSE 9000 9001
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /etc/rustfs/entrypoint.sh
RUN chmod +x /entrypoint.sh COPY bootstrap/ /etc/rustfs/bootstrap/
RUN install -m 0755 /usr/local/bin/rc /usr/bin/rc && \
chmod +x /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["cargo", "run", "--bin", "rustfs", "--"] CMD ["cargo", "run", "--bin", "rustfs", "--"]
# ----------------------------- # -----------------------------
@@ -227,6 +235,7 @@ RUN set -eux; \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
curl \ curl \
jq \
tzdata \ tzdata \
coreutils; \ coreutils; \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
@@ -248,8 +257,11 @@ RUN set -eux; \
# Copy the freshly built binary and the entrypoint # Copy the freshly built binary and the entrypoint
COPY --from=builder /usr/local/bin/rustfs /usr/bin/rustfs COPY --from=builder /usr/local/bin/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh COPY --from=builder /usr/local/bin/rc /usr/bin/rc
RUN chmod +x /usr/bin/rustfs /entrypoint.sh COPY entrypoint.sh /etc/rustfs/entrypoint.sh
COPY bootstrap/ /etc/rustfs/bootstrap/
RUN chmod +x /usr/bin/rustfs /usr/bin/rc /etc/rustfs/entrypoint.sh && \
ln -s /etc/rustfs/entrypoint.sh /entrypoint.sh
# Default environment (override in docker run/compose as needed) # Default environment (override in docker run/compose as needed)
ENV RUSTFS_VOLUMES="/data" \ ENV RUSTFS_VOLUMES="/data" \
@@ -263,5 +275,5 @@ EXPOSE 9000
VOLUME ["/data"] VOLUME ["/data"]
# Keep root here; entrypoint will drop privileges using chroot --userspec # Keep root here; entrypoint will drop privileges using chroot --userspec
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/etc/rustfs/entrypoint.sh"]
CMD ["/usr/bin/rustfs"] CMD ["/usr/bin/rustfs"]
+21
View File
@@ -0,0 +1,21 @@
# RustFS image bootstrap
The official image bundles this directory at `/etc/rustfs/bootstrap`. The default `buckets.json` is an empty JSON array, so the image starts RustFS directly and does not require a bootstrap endpoint or bootstrap credentials.
To create buckets, replace `buckets.json` in a derived image with a JSON array of bucket names. The entrypoint creates each bucket if it does not exist and applies `public-access-block.json`, whose default blocks all four public access paths. Edit that file in the derived image only when public access is an explicit requirement.
Optional per-bucket files use the bucket name as the filename without an extension:
| Directory | File format | Applied operation |
| --- | --- | --- |
| `policies/` | S3 bucket policy JSON | `rc bucket anonymous set-json` |
| `versioning/` | JSON boolean (`true` or `false`) | Enable or suspend bucket versioning |
| `lifecycle/` | `rc bucket lifecycle rule import` JSON | Replace lifecycle rules |
| `cors/` | JSON or XML accepted by `rc bucket cors set` | Replace CORS rules |
| `notifications/` | JSON array of `{ "arn": "...", "events": ["..."] }` | Add or replace event rules by ARN (prefix/suffix filters are not currently accepted) |
Every per-bucket file must correspond to a bucket listed in `buckets.json`. Applying these files is repeatable: bucket creation ignores existing buckets, while each configuration operation replaces or reconciles that bucket's requested state.
When `buckets.json` is non-empty, set `RUSTFS_BOOTSTRAP_ENDPOINT` to the RustFS S3 endpoint reachable from the container, for example `http://rustfs:9000` for a Compose service or the internal service DNS name in Kubernetes. The entrypoint waits on that endpoint's `/health/ready`; it never assumes `localhost`. `RUSTFS_BOOTSTRAP_TIMEOUT` controls the readiness wait in seconds (default `120`, maximum `3600`). `RUSTFS_BOOTSTRAP_DIR` can point to a different in-image configuration directory; it defaults to `/etc/rustfs/bootstrap`. Set `RUSTFS_ACCESS_KEY` and `RUSTFS_SECRET_KEY`, or their `_FILE` variants, for bootstrap authentication. The endpoint and credentials are read at runtime and are not baked into the image.
RustFS built-in IAM policies remain unchanged; the default bootstrap installs no custom bucket policies, lifecycle rules, versioning, CORS rules, or notification rules. The bundled RustFS CLI is resolved from a published release and its release checksum is verified. Set the Docker build argument `RC_RELEASE` to a version to pin it. Build the RustFS image after a CLI release containing the `bucket public-access-block` commands is available. For Kubernetes, the configured service must route traffic to the starting pod before its readiness probe succeeds (for example, with `publishNotReadyAddresses: true`); otherwise the service endpoint cannot reach the pod while the entrypoint waits for readiness.
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
{
"block_public_acls": true,
"ignore_public_acls": true,
"block_public_policy": true,
"restrict_public_buckets": true
}
+1
View File
@@ -0,0 +1 @@
+3 -3
View File
@@ -30,10 +30,10 @@ pub const ENV_TRUSTED_PROXY_VALIDATION_MODE: &str = "RUSTFS_TRUSTED_PROXY_VALIDA
/// Default validation mode is "hop_by_hop". /// Default validation mode is "hop_by_hop".
pub const DEFAULT_TRUSTED_PROXY_VALIDATION_MODE: &str = "hop_by_hop"; pub const DEFAULT_TRUSTED_PROXY_VALIDATION_MODE: &str = "hop_by_hop";
/// Environment variable to enable RFC 7239 "Forwarded" header support. /// Select RFC 7239 "Forwarded" instead of "X-Forwarded-For" in legacy mode.
pub const ENV_TRUSTED_PROXY_ENABLE_RFC7239: &str = "RUSTFS_TRUSTED_PROXY_ENABLE_RFC7239"; pub const ENV_TRUSTED_PROXY_ENABLE_RFC7239: &str = "RUSTFS_TRUSTED_PROXY_ENABLE_RFC7239";
/// RFC 7239 support is enabled by default. /// Legacy mode uses X-Forwarded-For unless RFC 7239 is explicitly selected.
pub const DEFAULT_TRUSTED_PROXY_ENABLE_RFC7239: bool = true; pub const DEFAULT_TRUSTED_PROXY_ENABLE_RFC7239: bool = false;
/// Environment variable for the maximum allowed proxy hops. /// Environment variable for the maximum allowed proxy hops.
pub const ENV_TRUSTED_PROXY_MAX_HOPS: &str = "RUSTFS_TRUSTED_PROXY_MAX_HOPS"; pub const ENV_TRUSTED_PROXY_MAX_HOPS: &str = "RUSTFS_TRUSTED_PROXY_MAX_HOPS";
+6
View File
@@ -146,3 +146,9 @@ pub const ENV_TARGET_STORE_COMPRESS: &str = "RUSTFS_TARGET_STORE_COMPRESS";
/// Queue-store compression is enabled by default to reduce disk footprint. /// Queue-store compression is enabled by default to reduce disk footprint.
pub const DEFAULT_TARGET_STORE_COMPRESS: bool = true; pub const DEFAULT_TARGET_STORE_COMPRESS: bool = true;
/// Maximum on-disk bytes in each target queue, including failed entries and pending writes.
pub const ENV_TARGET_STORE_MAX_BYTES: &str = "RUSTFS_TARGET_STORE_MAX_BYTES";
/// Queue writes fail at one GiB unless the operator sets another positive byte budget.
pub const DEFAULT_TARGET_STORE_MAX_BYTES: u64 = 1024 * 1024 * 1024;
+3 -3
View File
@@ -26,7 +26,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) | | **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) | | **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` | | **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
| **distributed 4×4** | [`src/distributed/`](src/distributed) | Storage-sensitive PR and nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) | | **distributed 4×4** | [`src/distributed/`](src/distributed) | Nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) |
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup | | **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory | | **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
@@ -194,9 +194,9 @@ the same profile for membership and execution with one nightly worker.
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) | | `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) | | ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
| KMS suite | `e2e-full` job, merge queue + main | **Active** | | KMS suite | `e2e-full` job, merge queue + main | **Active** |
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** | | Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, release tags + weekly schedule + manual dispatch | **Active** |
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) | | Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
| Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (storage-sensitive PR / nightly / dispatch) | | Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (nightly / dispatch) |
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) | | Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) | | Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) | | Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
@@ -12,11 +12,249 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, unique_bucket}; use super::harness::{DistCluster, DistLayout, TestResult, payload_for, s3_probe_client, sha256_hex, unique_bucket};
use crate::common::init_logging; use crate::common::init_logging;
use aws_sdk_s3::{
Client,
error::{ProvideErrorMetadata, SdkError},
primitives::ByteStream,
};
use std::any::TypeId;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::fmt::Debug;
use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Barrier; use tokio::sync::Barrier;
use tokio::time::Instant;
const LOCK_ADMISSION_BUDGET: Duration = Duration::from_secs(45);
async fn after_lock_contention<T, E, F, Fut>(operation: &str, mut call: F) -> TestResult<Result<T, SdkError<E>>>
where
E: Debug + ProvideErrorMetadata + 'static,
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, SdkError<E>>>,
{
let started = Instant::now();
let mut attempts = 0;
// The workload uses unique unversioned keys. Repeat only explicitly
// advertised lock backpressure; every step still needs its real result.
let outcome = tokio::time::timeout(LOCK_ADMISSION_BUDGET, async {
loop {
attempts += 1;
let result = call().await;
let retry = result.as_ref().err().is_some_and(|error| {
let Some(response) = error.raw_response() else {
return false;
};
let Some(service_error) = error.as_service_error() else {
return false;
};
// HEAD has no XML error body, so its SDK service error can
// lack a code even when the server supplies retry headers.
let namespace_code = service_error.code() == Some("ServiceUnavailable")
|| (service_error.code().is_none()
&& TypeId::of::<E>() == TypeId::of::<aws_sdk_s3::operation::head_object::HeadObjectError>());
response.status().as_u16() == 503
&& namespace_code
&& response.headers().get("x-rustfs-error-cause") == Some("namespace_lock_timeout")
&& response.headers().get("retry-after") == Some("1")
});
if !retry {
return result;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
})
.await;
if attempts > 1 || !matches!(&outcome, Ok(Ok(_))) {
eprintln!(
"mixed workload request: operation={operation}, attempts={attempts}, elapsed={:?}",
started.elapsed()
);
}
outcome.map_err(|_| {
format!("{operation} exceeded its {LOCK_ADMISSION_BUDGET:?} request budget after {attempts} attempts").into()
})
}
async fn assert_workload_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
let output = after_lock_contention(&format!("GET {key}"), || client.get_object().bucket(bucket).key(key).send()).await??;
let actual = output.body.collect().await?.into_bytes();
if actual.as_ref() != expected {
return Err(format!(
"object {key} bytes mismatch: expected {} bytes sha256={} got {} bytes sha256={}",
expected.len(),
sha256_hex(expected),
actual.len(),
sha256_hex(&actual)
)
.into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aws_smithy_http_client::test_util::infallible_client_fn;
use aws_smithy_types::body::SdkBody;
use std::sync::atomic::{AtomicUsize, Ordering};
type Reply = (u16, &'static str, Option<&'static str>, Option<&'static str>, &'static str);
fn client(respond: impl Fn(usize) -> Reply + Send + Sync + 'static) -> (Client, Arc<AtomicUsize>) {
let requests = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&requests);
let transport = infallible_client_fn(move |_| {
let (status, code, cause, retry_after, body) = respond(seen.fetch_add(1, Ordering::SeqCst));
let body = if code.is_empty() {
body.to_string()
} else {
format!("<Error><Code>{code}</Code></Error>")
};
let mut response = http::Response::builder()
.status(status)
.header("content-type", "application/xml")
.header("content-length", body.len());
if let Some(cause) = cause {
response = response.header("x-rustfs-error-cause", cause);
}
if let Some(retry_after) = retry_after {
response = response.header("retry-after", retry_after);
}
response.body(SdkBody::from(body)).expect("scripted S3 response")
});
let config = crate::common::build_test_s3_config(
"http://contention.example.invalid",
"synthetic-access",
"synthetic-secret",
None,
"contention-test",
);
let client = Client::from_conf(config.to_builder().http_client(transport).build());
(s3_probe_client(&client), requests)
}
fn contention() -> Reply {
(503, "ServiceUnavailable", Some("namespace_lock_timeout"), Some("1"), "")
}
#[tokio::test(start_paused = true)]
async fn advertised_contention_recovers_after_standard_sdk_attempts_are_exhausted() -> TestResult {
let (client, requests) = client(|attempt| {
if attempt < 3 {
contention()
} else {
(200, "", None, None, "")
}
});
let standard = Client::from_conf(
client
.config()
.to_builder()
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(3))
.build(),
);
standard
.put_object()
.bucket("bucket")
.key("key")
.body(ByteStream::from_static(b"body"))
.send()
.await
.expect_err("three advertised lock responses exhaust the standard SDK policy");
assert_eq!(requests.load(Ordering::SeqCst), 3);
requests.store(0, Ordering::SeqCst);
let started = Instant::now();
after_lock_contention("PUT recovery", || {
client
.put_object()
.bucket("bucket")
.key("key")
.body(ByteStream::from_static(b"body"))
.send()
})
.await??;
assert_eq!(requests.load(Ordering::SeqCst), 4);
assert!(started.elapsed() >= Duration::from_secs(3), "honor each Retry-After response");
Ok(())
}
#[tokio::test(start_paused = true)]
async fn bodyless_head_contention_uses_explicit_retry_headers() -> TestResult {
let (client, requests) = client(|attempt| {
if attempt == 0 {
(503, "", Some("namespace_lock_timeout"), Some("1"), "")
} else {
(200, "", None, None, "")
}
});
after_lock_contention("HEAD recovery", || client.head_object().bucket("bucket").key("key").send()).await??;
assert_eq!(requests.load(Ordering::SeqCst), 2);
Ok(())
}
#[tokio::test(start_paused = true)]
async fn unrelated_or_malformed_responses_are_not_retried() -> TestResult {
for reply in [
(500, "InternalError", Some("namespace_lock_timeout"), Some("1"), ""),
(403, "AccessDenied", None, None, ""),
(404, "NoSuchKey", None, None, ""),
(503, "ServiceUnavailable", Some("storage_write_quorum"), None, ""),
(503, "ServiceUnavailable", Some("namespace_lock_unavailable"), Some("1"), ""),
(503, "InternalError", Some("namespace_lock_timeout"), Some("1"), ""),
(503, "", Some("namespace_lock_timeout"), Some("1"), ""),
(503, "ServiceUnavailable", None, Some("1"), ""),
(503, "ServiceUnavailable", Some("namespace_lock_timeout"), None, ""),
(503, "ServiceUnavailable", Some("namespace_lock_timeout"), Some("2"), ""),
] {
let (client, requests) = client(move |_| reply);
let result = after_lock_contention("PUT fatal response", || {
client
.put_object()
.bucket("bucket")
.key("key")
.body(ByteStream::from_static(b"body"))
.send()
})
.await?;
assert!(result.is_err(), "fatal response cannot become success: {reply:?}");
assert_eq!(requests.load(Ordering::SeqCst), 1, "fatal response must remain visible: {reply:?}");
}
Ok(())
}
#[tokio::test(start_paused = true)]
async fn persistent_lock_contention_exhausts_the_request_budget() {
let (client, requests) = client(|_| contention());
let started = Instant::now();
let result = after_lock_contention("PUT unavailable", || {
client
.put_object()
.bucket("bucket")
.key("key")
.body(ByteStream::from_static(b"body"))
.send()
})
.await;
assert!(result.is_err(), "persistent contention cannot acknowledge an operation");
assert!(started.elapsed() >= LOCK_ADMISSION_BUDGET);
assert!(requests.load(Ordering::SeqCst) > 1);
assert!(requests.load(Ordering::SeqCst) <= LOCK_ADMISSION_BUDGET.as_secs() as usize + 1);
}
#[tokio::test(start_paused = true)]
async fn acknowledged_wrong_bytes_fail_without_retry() {
let (client, requests) = client(|_| (200, "", None, None, "wrong bytes"));
let error = assert_workload_bytes(&client, "bucket", "key", b"expected bytes")
.await
.expect_err("a successful but incorrect read is a consistency failure");
assert!(error.to_string().contains("bytes mismatch"));
assert_eq!(requests.load(Ordering::SeqCst), 1);
}
}
#[tokio::test] #[tokio::test]
async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node() -> TestResult { async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node() -> TestResult {
@@ -24,7 +262,9 @@ async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node()
let dist = DistCluster::start(DistLayout::FourByFour).await?; let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("conc"); let bucket = unique_bucket("conc");
dist.create_bucket(&bucket).await?; dist.create_bucket(&bucket).await?;
let clients = Arc::new(dist.clients()?); // This consistency probe owns its admission policy; unrelated responses
// must not be hidden by the SDK's general-purpose retries.
let clients = Arc::new(dist.clients()?.iter().map(s3_probe_client).collect::<Vec<_>>());
let worker_count = 24; let worker_count = 24;
let rounds = 4; let rounds = 4;
let barrier = Arc::new(Barrier::new(worker_count)); let barrier = Arc::new(Barrier::new(worker_count));
@@ -44,31 +284,46 @@ async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node()
let key = format!("source/worker-{idx:02}-round-{round}.bin"); let key = format!("source/worker-{idx:02}-round-{round}.bin");
let copy_key = format!("retained/worker-{idx:02}-round-{round}.bin"); let copy_key = format!("retained/worker-{idx:02}-round-{round}.bin");
let body = payload_for(&key, 64 * 1024); let body = payload_for(&key, 64 * 1024);
put_object(writer, &bucket, &key, body.clone()).await?; let context = format!("worker={idx} round={round}");
after_lock_contention(&format!("{context} PUT source"), || {
writer
.put_object()
.bucket(&bucket)
.key(&key)
.body(ByteStream::from(body.clone()))
.send()
})
.await??;
let head = reader.head_object().bucket(&bucket).key(&key).send().await?; let head = after_lock_contention(&format!("{context} HEAD source"), || {
reader.head_object().bucket(&bucket).key(&key).send()
})
.await??;
if head.content_length() != Some(body.len() as i64) { if head.content_length() != Some(body.len() as i64) {
return Err(format!("HEAD returned the wrong size for {key}: {head:?}").into()); return Err(format!("HEAD returned the wrong size for {key}: {head:?}").into());
} }
assert_object_bytes(reader, &bucket, &key, &body).await?; assert_workload_bytes(reader, &bucket, &key, &body).await?;
copier after_lock_contention(&format!("{context} COPY retained"), || {
.copy_object() copier
.bucket(&bucket) .copy_object()
.key(&copy_key) .bucket(&bucket)
.copy_source(format!("{bucket}/{key}")) .key(&copy_key)
.send() .copy_source(format!("{bucket}/{key}"))
.await?; .send()
assert_object_bytes(writer, &bucket, &copy_key, &body).await?; })
.await??;
assert_workload_bytes(writer, &bucket, &copy_key, &body).await?;
writer.delete_object().bucket(&bucket).key(&key).send().await?; after_lock_contention(&format!("{context} DELETE source"), || {
let missing = reader writer.delete_object().bucket(&bucket).key(&key).send()
.head_object() })
.bucket(&bucket) .await??;
.key(&key) let missing = after_lock_contention(&format!("{context} HEAD deleted source"), || {
.send() reader.head_object().bucket(&bucket).key(&key).send()
.await })
.expect_err("deleted source key must not remain visible"); .await?
.expect_err("deleted source key must not remain visible");
if missing.raw_response().map(|response| response.status().as_u16()) != Some(404) { if missing.raw_response().map(|response| response.status().as_u16()) != Some(404) {
return Err(format!("deleted source {key} returned an unexpected result: {missing:?}").into()); return Err(format!("deleted source {key} returned an unexpected result: {missing:?}").into());
} }
@@ -82,14 +337,22 @@ async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node()
for handle in handles { for handle in handles {
inventory.extend(handle.await??); inventory.extend(handle.await??);
} }
assert_eq!(
inventory.len(),
worker_count * rounds,
"every worker round must retain its acknowledged copy"
);
let expected_keys: BTreeSet<_> = inventory.iter().map(|(key, _)| key.as_str()).collect(); let expected_keys: BTreeSet<_> = inventory.iter().map(|(key, _)| key.as_str()).collect();
for (node_idx, client) in clients.iter().enumerate() { for (node_idx, client) in clients.iter().enumerate() {
let listed = client.list_objects_v2().bucket(&bucket).prefix("retained/").send().await?; let listed = after_lock_contention(&format!("node={node_idx} LIST retained"), || {
client.list_objects_v2().bucket(&bucket).prefix("retained/").send()
})
.await??;
let listed_keys: BTreeSet<_> = listed.contents().iter().filter_map(|object| object.key()).collect(); let listed_keys: BTreeSet<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(listed_keys, expected_keys, "node {node_idx} returned a divergent retained-key listing"); assert_eq!(listed_keys, expected_keys, "node {node_idx} returned a divergent retained-key listing");
for (key, body) in &inventory { for (key, body) in &inventory {
assert_object_bytes(client, &bucket, key, body) assert_workload_bytes(client, &bucket, key, body)
.await .await
.map_err(|error| format!("node {node_idx} failed to read {key}: {error}"))?; .map_err(|error| format!("node {node_idx} failed to read {key}: {error}"))?;
} }
+3
View File
@@ -283,6 +283,9 @@ mod ssec_copy_test;
#[cfg(test)] #[cfg(test)]
mod multipart_storage_class_test; mod multipart_storage_class_test;
#[cfg(test)]
mod multipart_upload_id_test;
#[cfg(test)] #[cfg(test)]
mod storage_class_capability_test; mod storage_class_capability_test;
@@ -0,0 +1,175 @@
// 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.
//! Multipart upload ID regression tests.
//!
//! Every operation that addresses an existing multipart upload must report an
//! upload ID that does not name a live upload as `404 NoSuchUpload`, whether
//! the ID is malformed or is a well-formed ID of an aborted upload.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const BUCKET: &str = "multipart-upload-id";
const KEY: &str = "target.bin";
const COPY_SOURCE_KEY: &str = "copy-source.bin";
// RustFS upload IDs are URL-safe base64; '.' is outside that alphabet, so
// the object layer rejects this ID as malformed instead of looking it up.
const MALFORMED_UPLOAD_ID: &str = "malformed.upload.id";
const PART_NUMBER: i32 = 1;
const PART_BODY: &[u8] = b"multipart upload id regression part";
const PART_ETAG: &str = "\"00000000000000000000000000000000\"";
const NO_SUCH_UPLOAD_STATUS: u16 = 404;
const NO_SUCH_UPLOAD_CODE: &str = "NoSuchUpload";
const OBJECT_NOT_FOUND_STATUS: u16 = 404;
/// Describes how `result` differs from `404 NoSuchUpload`, or returns `None` when it matches.
fn no_such_upload_mismatch<T, E>(operation: &str, result: Result<T, SdkError<E>>) -> Option<String>
where
E: ProvideErrorMetadata + std::fmt::Debug,
{
let Err(error) = result else {
return Some(format!("{operation}: succeeded"));
};
let status = error.raw_response().map(|response| response.status().as_u16());
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
(status != Some(NO_SUCH_UPLOAD_STATUS) || code != Some(NO_SUCH_UPLOAD_CODE))
.then(|| format!("{operation}: status {status:?}, code {code:?}: {error:?}"))
}
async fn start_server_with_copy_source() -> Result<(RustFSTestEnvironment, Client), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
env.create_test_bucket(BUCKET).await?;
let client = env.create_s3_client();
client
.put_object()
.bucket(BUCKET)
.key(COPY_SOURCE_KEY)
.body(ByteStream::from_static(PART_BODY))
.send()
.await?;
Ok((env, client))
}
/// Sends every operation that addresses an existing upload and asserts that all of them report `404 NoSuchUpload`.
async fn assert_every_upload_operation_reports_no_such_upload(client: &Client, upload_id: &str) {
let upload_part = client
.upload_part()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.part_number(PART_NUMBER)
.body(ByteStream::from_static(PART_BODY))
.send()
.await;
let upload_part_copy = client
.upload_part_copy()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.part_number(PART_NUMBER)
.copy_source(format!("/{BUCKET}/{COPY_SOURCE_KEY}"))
.send()
.await;
let list_parts = client.list_parts().bucket(BUCKET).key(KEY).upload_id(upload_id).send().await;
let complete = client
.complete_multipart_upload()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(PART_NUMBER).e_tag(PART_ETAG).build())
.build(),
)
.send()
.await;
let abort = client
.abort_multipart_upload()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.send()
.await;
let mismatches: Vec<String> = [
no_such_upload_mismatch("UploadPart", upload_part),
no_such_upload_mismatch("UploadPartCopy", upload_part_copy),
no_such_upload_mismatch("ListParts", list_parts),
no_such_upload_mismatch("CompleteMultipartUpload", complete),
no_such_upload_mismatch("AbortMultipartUpload", abort),
]
.into_iter()
.flatten()
.collect();
assert!(
mismatches.is_empty(),
"upload ID {upload_id:?}: operations not reporting {NO_SUCH_UPLOAD_STATUS} {NO_SUCH_UPLOAD_CODE}:\n{}",
mismatches.join("\n")
);
}
/// Asserts that the rejected operations left neither a multipart upload nor an object under `KEY`.
async fn assert_no_upload_or_object_published(client: &Client) -> TestResult {
let uploads = client.list_multipart_uploads().bucket(BUCKET).prefix(KEY).send().await?;
assert!(uploads.uploads().is_empty(), "rejected operations must not leave an upload: {uploads:?}");
let head = client.head_object().bucket(BUCKET).key(KEY).send().await;
let status = head
.as_ref()
.err()
.and_then(|error| error.raw_response())
.map(|response| response.status().as_u16());
assert_eq!(
status,
Some(OBJECT_NOT_FOUND_STATUS),
"rejected operations must not publish {KEY}: {head:?}"
);
Ok(())
}
#[tokio::test]
async fn malformed_upload_id_reports_no_such_upload_on_every_upload_operation() -> TestResult {
let (_env, client) = start_server_with_copy_source().await?;
assert_every_upload_operation_reports_no_such_upload(&client, MALFORMED_UPLOAD_ID).await;
assert_no_upload_or_object_published(&client).await
}
#[tokio::test]
async fn aborted_upload_id_reports_no_such_upload_on_every_upload_operation() -> TestResult {
let (_env, client) = start_server_with_copy_source().await?;
let created = client.create_multipart_upload().bucket(BUCKET).key(KEY).send().await?;
let upload_id = created.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
client
.abort_multipart_upload()
.bucket(BUCKET)
.key(KEY)
.upload_id(upload_id)
.send()
.await?;
assert_every_upload_operation_reports_no_such_upload(&client, upload_id).await;
assert_no_upload_or_object_published(&client).await
}
}
@@ -31,8 +31,8 @@ use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::operation::list_object_versions::ListObjectVersionsOutput; use aws_sdk_s3::operation::list_object_versions::ListObjectVersionsOutput;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{ use aws_sdk_s3::types::{
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DeleteMarkerEntry, ObjectVersion, ServerSideEncryption, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Delete, DeleteMarkerEntry, ObjectIdentifier, ObjectVersion,
VersioningConfiguration, ServerSideEncryption, VersioningConfiguration,
}; };
use base64_simd::STANDARD as BASE64_STANDARD; use base64_simd::STANDARD as BASE64_STANDARD;
use bytes::Bytes; use bytes::Bytes;
@@ -3729,6 +3729,8 @@ async fn test_bucket_replication_converges_delete_marker_and_version_purge() ->
init_logging(); init_logging();
let mut source_env = RustFSTestEnvironment::new().await?; let mut source_env = RustFSTestEnvironment::new().await?;
let source_log = format!("{}/source.log", source_env.temp_dir);
source_env.capture_log_path = Some(source_log.clone());
let mut source_env_vars = replication_fast_env(); let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV); source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?; source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
@@ -3791,19 +3793,66 @@ async fn test_bucket_replication_converges_delete_marker_and_version_purge() ->
"target did not preserve the source delete-marker version ID" "target did not preserve the source delete-marker version ID"
); );
source_client let wait_for_source_writeback = || async {
.delete_object() let metrics_url = format!("{}/{source_bucket}?replication-metrics", source_env.url);
timeout(Duration::from_secs(30), async {
loop {
let response = signed_request(
http::Method::GET,
&metrics_url,
&source_env.access_key,
&source_env.secret_key,
None,
None,
)
.await?;
let metrics: serde_json::Value = response.error_for_status()?.json().await?;
let queued = metrics["queued"]["curr"]["count"]
.as_f64()
.ok_or("missing replication queue count")?;
if metrics["provider_available"] == true && queued == 0.0 {
return Ok::<(), Box<dyn Error + Send + Sync>>(());
}
sleep(Duration::from_millis(50)).await;
}
})
.await?
};
wait_for_source_writeback().await?;
let purged = source_client
.delete_objects()
.bucket(source_bucket) .bucket(source_bucket)
.key(object_key) .delete(
.version_id(&purged_version_id) Delete::builder()
.objects(
ObjectIdentifier::builder()
.key(object_key)
.version_id(&purged_version_id)
.build()?,
)
.objects(
ObjectIdentifier::builder()
.key(object_key)
.version_id(delete_marker_version_id)
.build()?,
)
.build()?,
)
.send() .send()
.await?; .await?;
assert!(purged.errors().is_empty(), "batch version purge failed: {:?}", purged.errors());
assert_eq!(purged.deleted().len(), 2, "both the data version and marker must be purged");
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?; assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
let target_state = list_replication_state(&target_client, target_bucket).await?; let target_state = list_replication_state(&target_client, target_bucket).await?;
assert!( assert!(
target_state.iter().all(|entry| entry.version_id != purged_version_id), target_state.iter().all(|entry| entry.version_id != purged_version_id),
"target retained the explicitly purged object version" "target retained the explicitly purged object version"
); );
assert!(
target_state.iter().all(|entry| entry.version_id != delete_marker_version_id),
"target retained the explicitly purged delete marker"
);
assert!( assert!(
target_state target_state
.iter() .iter()
@@ -3819,6 +3868,19 @@ async fn test_bucket_replication_converges_delete_marker_and_version_purge() ->
.await?; .await?;
assert_eq!(retained.body.collect().await?.into_bytes().as_ref(), b"versioned replication payload v2"); assert_eq!(retained.body.collect().await?.into_bytes().as_ref(), b"versioned replication payload v2");
// Wait for the source workers to finish their final writeback, which runs
// after the remote purge has already become visible (rustfs#9121).
wait_for_source_writeback().await?;
let logs = fs::read_to_string(source_log).await?;
let writeback_failures: Vec<_> = logs
.lines()
.filter(|line| line.contains("apply_replication_delete_state"))
.collect();
assert!(
writeback_failures.is_empty(),
"a completed batch purge must not report a source writeback failure: {writeback_failures:?}"
);
Ok(()) Ok(())
} }
+1
View File
@@ -31,6 +31,7 @@ workspace = true
[features] [features]
default = [] default = []
fips = ["rustfs-s3-client/fips", "rustfs-utils/fips"]
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"] diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
# Sampled wall/CPU diagnostics only; does not change the read backend. # Sampled wall/CPU diagnostics only; does not change the read backend.
diagnostic-local-batch = ["rustix/time"] diagnostic-local-batch = ["rustix/time"]
@@ -33,6 +33,7 @@ pub(crate) struct ReplicationMetadataStore;
impl ReplicationMetadataStore { impl ReplicationMetadataStore {
pub(crate) const MRF_REPLICATION_FILE: &'static str = "config/replication/mrf.bin"; pub(crate) const MRF_REPLICATION_FILE: &'static str = "config/replication/mrf.bin";
pub(crate) const MRF_REPLICATION_RECOVERY_LOCK: &'static str = "config/replication/mrf.bin.recovery"; pub(crate) const MRF_REPLICATION_RECOVERY_LOCK: &'static str = "config/replication/mrf.bin.recovery";
pub(crate) const MRF_REPLICATION_REPLAY_STAMP: &'static str = "config/replication/mrf.bin.replay";
pub(crate) const FORCE_DELETE_REPLICATION_FILE: &'static str = "config/replication/force-delete.bin"; pub(crate) const FORCE_DELETE_REPLICATION_FILE: &'static str = "config/replication/force-delete.bin";
pub(crate) const FORCE_DELETE_REPLICATION_TRANSACTION_LOCK: &'static str = "config/replication/force-delete.bin.transaction"; pub(crate) const FORCE_DELETE_REPLICATION_TRANSACTION_LOCK: &'static str = "config/replication/force-delete.bin.transaction";
@@ -116,6 +117,10 @@ mod tests {
ReplicationMetadataStore::MRF_REPLICATION_RECOVERY_LOCK, ReplicationMetadataStore::MRF_REPLICATION_RECOVERY_LOCK,
"config/replication/mrf.bin.recovery" "config/replication/mrf.bin.recovery"
); );
assert_eq!(
ReplicationMetadataStore::MRF_REPLICATION_REPLAY_STAMP,
"config/replication/mrf.bin.replay"
);
assert_eq!( assert_eq!(
ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE, ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE,
"config/replication/force-delete.bin" "config/replication/force-delete.bin"
@@ -86,6 +86,10 @@ const MRF_CORRUPT_FILE_PREFIX: &str = "config/replication/mrf.corrupt";
const MRF_PENDING_CAP: usize = 200_000; const MRF_PENDING_CAP: usize = 200_000;
const MRF_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(100); const MRF_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(100);
const MRF_RETRY_MAX_DELAY: Duration = Duration::from_secs(5); const MRF_RETRY_MAX_DELAY: Duration = Duration::from_secs(5);
/// Interval between runtime replays of the MRF journal, cluster-wide. A pass
/// re-attempts every retained entry, so a target that stays down costs one
/// attempt per entry per interval.
const MRF_REPLAY_INTERVAL: Duration = Duration::from_secs(300);
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct DurableMrfBacklog { pub struct DurableMrfBacklog {
@@ -615,7 +619,7 @@ async fn read_mrf_entries<S: ReplicationObjectIO>(storage: Arc<S>) -> Result<Vec
} }
/// Acknowledge only the generation read by the recovery leader. Entries appended /// Acknowledge only the generation read by the recovery leader. Entries appended
/// after that generation are retained as a suffix and replayed on the next startup. /// after that generation are retained as a suffix and replayed on the next pass.
/// Lock order: recovery leader lock -> MRF journal object lock. /// Lock order: recovery leader lock -> MRF journal object lock.
async fn acknowledge_mrf_recovery<S: ReplicationStorage>( async fn acknowledge_mrf_recovery<S: ReplicationStorage>(
storage: Arc<S>, storage: Arc<S>,
@@ -667,7 +671,7 @@ async fn acknowledge_mrf_recovery<S: ReplicationStorage>(
Err(EcstoreError::PreconditionFailed) Err(EcstoreError::PreconditionFailed)
} }
/// Acquires the MRF recovery leader lock for the startup replay. /// Acquires the MRF recovery leader lock for one journal replay pass.
/// Returns `None` (after logging) when the lock cannot be created or another /// Returns `None` (after logging) when the lock cannot be created or another
/// node is already processing the backlog. /// node is already processing the backlog.
async fn acquire_mrf_recovery_guard<S: ReplicationStorage>(storage: &Arc<S>) -> Option<rustfs_lock::NamespaceLockGuard> { async fn acquire_mrf_recovery_guard<S: ReplicationStorage>(storage: &Arc<S>) -> Option<rustfs_lock::NamespaceLockGuard> {
@@ -1018,7 +1022,7 @@ fn admitted_mrf_replicate_object(oi: ObjectInfo, entry: &MrfReplicateEntry, op_t
} }
/// Acknowledges the replayed MRF prefix and returns the retained backlog. /// Acknowledges the replayed MRF prefix and returns the retained backlog.
/// On acknowledgement failure the backlog is preserved for the next startup and /// On acknowledgement failure the backlog is preserved for the next replay pass and
/// re-read (falling back to the replayed snapshot) so the published summary stays accurate. /// re-read (falling back to the replayed snapshot) so the published summary stays accurate.
async fn resolve_retained_mrf_entries<S: ReplicationStorage>( async fn resolve_retained_mrf_entries<S: ReplicationStorage>(
storage: &Arc<S>, storage: &Arc<S>,
@@ -1033,7 +1037,7 @@ async fn resolve_retained_mrf_entries<S: ReplicationStorage>(
component = LOG_COMPONENT_ECSTORE, component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION, subsystem = LOG_SUBSYSTEM_REPLICATION,
error = %error, error = %error,
"Failed to acknowledge the MRF recovery prefix; preserving it for the next startup" "Failed to acknowledge the MRF recovery prefix; preserving it for the next replay pass"
); );
match read_mrf_entries(storage.clone()).await { match read_mrf_entries(storage.clone()).await {
Ok(current) => current, Ok(current) => current,
@@ -1051,6 +1055,120 @@ async fn resolve_retained_mrf_entries<S: ReplicationStorage>(
} }
} }
fn unix_millis_now() -> i64 {
i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000).unwrap_or(i64::MAX)
}
/// Whether a periodic pass may run, given the start time of the last pass on
/// any node. A stamp more than one interval ahead of this node's clock is skew
/// or a bad value, so it does not suppress replay.
fn mrf_replay_due(last_started_ms: Option<i64>, now_ms: i64, interval: Duration) -> bool {
let Some(last_started_ms) = last_started_ms else {
return true;
};
let interval_ms = i64::try_from(interval.as_millis()).unwrap_or(i64::MAX);
now_ms.saturating_sub(last_started_ms) >= interval_ms || last_started_ms.saturating_sub(now_ms) >= interval_ms
}
async fn read_mrf_replay_stamp<S: ReplicationStorage>(storage: &Arc<S>) -> Option<i64> {
let data = ReplicationConfigStore::read(storage.clone(), ReplicationMetadataStore::MRF_REPLICATION_REPLAY_STAMP)
.await
.ok()?;
std::str::from_utf8(&data).ok()?.trim().parse().ok()
}
async fn write_mrf_replay_stamp<S: ReplicationStorage>(storage: &Arc<S>, started_ms: i64) {
if let Err(error) = ReplicationConfigStore::save(
storage.clone(),
ReplicationMetadataStore::MRF_REPLICATION_REPLAY_STAMP,
started_ms.to_string().into_bytes(),
)
.await
{
warn!(
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION,
error = %error,
"Failed to record the MRF replay start time"
);
}
}
/// One MRF journal replay pass. The recovery leader reads the journal,
/// re-drives every entry, and acknowledges the replayed prefix; entries that
/// could not be admitted, and entries appended meanwhile, stay for the next pass.
///
/// Every node runs the periodic loop, and the leader lock only serializes
/// passes. With `min_interval`, a pass is skipped when a pass on any node
/// started within that interval, read from the shared replay stamp, so a
/// cluster replays once per interval rather than once per node. The startup
/// pass passes `None` and always runs. A pass that finds entries records its
/// start time. Returns whether the journal was read.
/// Lock order: recovery leader lock -> replay stamp object lock.
async fn replay_mrf_journal<S: ReplicationStorage>(storage: &Arc<S>, min_interval: Option<Duration>) -> bool {
let Some(recovery_guard) = acquire_mrf_recovery_guard(storage).await else {
return false;
};
let started_ms = unix_millis_now();
if let Some(interval) = min_interval
&& !mrf_replay_due(read_mrf_replay_stamp(storage).await, started_ms, interval)
{
return false;
}
let Some(entries) = load_mrf_recovery_entries(storage).await else {
return true;
};
if !entries.is_empty() {
write_mrf_replay_stamp(storage, started_ms).await;
}
set_durable_mrf_backlog_snapshot(durable_mrf_backlog_summary_from_entries(&entries));
let total = entries.len();
let mut queued_count = 0usize;
let mut retry_entries = Vec::new();
for entry in entries.iter() {
let Some(admission) = replay_mrf_entry(entry, storage, &mut retry_entries).await else {
continue;
};
if admission == ReplicationQueueAdmission::Missed {
retry_entries.push(entry.clone());
} else if admission == ReplicationQueueAdmission::Queued {
queued_count += 1;
}
}
let retained = resolve_retained_mrf_entries(storage, &recovery_guard, &entries, &retry_entries).await;
let retained_count = retained.len();
set_durable_mrf_backlog_snapshot(durable_mrf_backlog_summary_from_entries(&retained));
if queued_count > 0 {
info!(
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION,
recovered = queued_count,
total,
retained = retained_count,
"Replayed MRF entries admitted for retry"
);
}
true
}
/// Re-runs [`replay_mrf_journal`] every `period`. The recovery leader lock keeps
/// one pass at a time across the cluster, including the startup pass, and the
/// shared replay stamp spaces passes from different nodes by `period`.
fn spawn_mrf_replay_loop<S: ReplicationStorage>(storage: Arc<S>, period: Duration) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::time::sleep(period).await;
replay_mrf_journal(&storage, Some(period)).await;
}
})
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[error("replication resync {active_resync_id} is already active for {bucket}/{arn}")] #[error("replication resync {active_resync_id} is already active for {bucket}/{arn}")]
struct ResyncActiveConflictError { struct ResyncActiveConflictError {
@@ -1171,6 +1289,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
// Start background tasks // Start background tasks
pool.start_mrf_persister().await; pool.start_mrf_persister().await;
pool.start_mrf_processor().await; pool.start_mrf_processor().await;
pool.start_mrf_replayer().await;
pool.start_force_delete_processor().await; pool.start_force_delete_processor().await;
pool pool
@@ -1639,55 +1758,26 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
/// Starts the MRF processor — one-shot at startup. /// Starts the MRF processor — one-shot at startup.
/// ///
/// Reads the on-disk MRF file, re-injects admitted entries as Heal operations, and /// Replays the on-disk MRF journal once; [`Self::start_mrf_replayer`] repeats
/// rewrites any entries that could not be admitted for a later startup retry. /// the pass while the server runs.
async fn start_mrf_processor(&self) { async fn start_mrf_processor(&self) {
let storage = self.storage.clone(); let storage = self.storage.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let Some(recovery_guard) = acquire_mrf_recovery_guard(&storage).await else { replay_mrf_journal(&storage, None).await;
return;
};
let Some(entries) = load_mrf_recovery_entries(&storage).await else {
return;
};
set_durable_mrf_backlog_snapshot(durable_mrf_backlog_summary_from_entries(&entries));
let total = entries.len();
let mut queued_count = 0usize;
let mut retry_entries = Vec::new();
for entry in entries.iter() {
let Some(admission) = replay_mrf_entry(entry, &storage, &mut retry_entries).await else {
continue;
};
if admission == ReplicationQueueAdmission::Missed {
retry_entries.push(entry.clone());
} else if admission == ReplicationQueueAdmission::Queued {
queued_count += 1;
}
}
let retained = resolve_retained_mrf_entries(&storage, &recovery_guard, &entries, &retry_entries).await;
let retained_count = retained.len();
set_durable_mrf_backlog_snapshot(durable_mrf_backlog_summary_from_entries(&retained));
if queued_count > 0 {
info!(
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION,
recovered = queued_count,
total,
retained = retained_count,
"Replayed MRF entries admitted for retry"
);
}
}); });
self.task_handles.lock().await.push(handle); self.task_handles.lock().await.push(handle);
} }
/// Starts the runtime MRF replayer — ongoing background task.
///
/// Entries reach the journal while the server runs (worker-queue overflow,
/// delete-marker purge intents whose watch window drained with targets still
/// dirty). Replaying only at startup left them waiting for a restart.
async fn start_mrf_replayer(&self) {
let handle = spawn_mrf_replay_loop(self.storage.clone(), MRF_REPLAY_INTERVAL);
self.task_handles.lock().await.push(handle);
}
async fn start_force_delete_processor(&self) { async fn start_force_delete_processor(&self) {
let storage = self.storage.clone(); let storage = self.storage.clone();
let handle = let handle =
@@ -1755,7 +1845,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
/// writes them to the on-disk MRF file every flush interval (default 10s, /// writes them to the on-disk MRF file every flush interval (default 10s,
/// overridable via `RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS`) or when 1 000 new /// overridable via `RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS`) or when 1 000 new
/// entries accumulate. Runtime writers append under the journal lock; only the /// entries accumulate. Runtime writers append under the journal lock; only the
/// startup recovery leader may remove an acknowledged prefix. /// recovery leader of a replay pass may remove an acknowledged prefix.
async fn start_mrf_persister(&self) { async fn start_mrf_persister(&self) {
let Some(mut rx) = self.mrf_save_rx.lock().await.take() else { let Some(mut rx) = self.mrf_save_rx.lock().await.take() else {
return; return;
@@ -3421,6 +3511,8 @@ mod tests {
allow_first_read: Notify, allow_first_read: Notify,
read_count: AtomicUsize, read_count: AtomicUsize,
delete_storage_calls: AtomicUsize, delete_storage_calls: AtomicUsize,
delete_error: StdMutex<EcstoreError>,
replay_stamp: StdMutex<Option<Vec<u8>>>,
reported_size: StdMutex<Option<i64>>, reported_size: StdMutex<Option<i64>>,
stream_read_bytes: Arc<AtomicUsize>, stream_read_bytes: Arc<AtomicUsize>,
write_count: AtomicUsize, write_count: AtomicUsize,
@@ -3488,6 +3580,24 @@ mod tests {
_h: Self::HeaderMap, _h: Self::HeaderMap,
_opts: &Self::ObjectOptions, _opts: &Self::ObjectOptions,
) -> Result<Self::GetObjectReader, Self::Error> { ) -> Result<Self::GetObjectReader, Self::Error> {
if object.ends_with(ReplicationMetadataStore::MRF_REPLICATION_REPLAY_STAMP) {
let stamp = self
.shared
.replay_stamp
.lock()
.expect("test replay stamp lock should not be poisoned")
.clone()
.ok_or(EcstoreError::FileNotFound)?;
return Ok(Self::GetObjectReader {
object_info: ObjectInfo {
size: i64::try_from(stamp.len()).expect("test stamp length should fit i64"),
..Default::default()
},
stream: Box::new(Cursor::new(stamp)),
buffered_body: None,
body_source: Default::default(),
});
}
if !object.ends_with("/.replication/resync.bin") if !object.ends_with("/.replication/resync.bin")
&& !object.ends_with("config/replication/mrf.bin") && !object.ends_with("config/replication/mrf.bin")
&& !object.ends_with("config/replication/force-delete.bin") && !object.ends_with("config/replication/force-delete.bin")
@@ -3551,6 +3661,16 @@ mod tests {
let lock = self.new_ns_lock(bucket, object).await?; let lock = self.new_ns_lock(bucket, object).await?;
Some(lock.get_write_lock(Duration::from_secs(10)).await?) Some(lock.get_write_lock(Duration::from_secs(10)).await?)
}; };
if object.ends_with(ReplicationMetadataStore::MRF_REPLICATION_REPLAY_STAMP) {
let mut stamp = Vec::new();
data.stream.read_to_end(&mut stamp).await.map_err(EcstoreError::from)?;
*self
.shared
.replay_stamp
.lock()
.expect("test replay stamp lock should not be poisoned") = Some(stamp);
return Ok(ObjectInfo::default());
}
if opts.http_preconditions.is_some() if opts.http_preconditions.is_some()
&& let Some(replacement) = self && let Some(replacement) = self
.shared .shared
@@ -3672,7 +3792,12 @@ mod tests {
_opts: Self::ObjectOptions, _opts: Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error> { ) -> Result<Self::ObjectInfo, Self::Error> {
self.shared.delete_storage_calls.fetch_add(1, Ordering::SeqCst); self.shared.delete_storage_calls.fetch_add(1, Ordering::SeqCst);
Err(EcstoreError::NotImplemented) Err(self
.shared
.delete_error
.lock()
.expect("delete error lock should not be poisoned")
.clone())
} }
async fn delete_objects( async fn delete_objects(
@@ -4171,6 +4296,8 @@ mod tests {
allow_first_read: Notify::new(), allow_first_read: Notify::new(),
read_count: AtomicUsize::new(0), read_count: AtomicUsize::new(0),
delete_storage_calls: AtomicUsize::new(0), delete_storage_calls: AtomicUsize::new(0),
delete_error: StdMutex::new(EcstoreError::NotImplemented),
replay_stamp: StdMutex::new(None),
reported_size: StdMutex::new(None), reported_size: StdMutex::new(None),
stream_read_bytes: Arc::new(AtomicUsize::new(0)), stream_read_bytes: Arc::new(AtomicUsize::new(0)),
write_count: AtomicUsize::new(0), write_count: AtomicUsize::new(0),
@@ -4221,6 +4348,103 @@ mod tests {
} }
} }
#[tokio::test]
async fn delete_worker_already_purged_source_completes_without_retry() {
let arn = "arn:test";
for delete_marker_version in [false, true] {
for version_id in [Uuid::new_v4(), Uuid::nil()] {
for error in [
EcstoreError::VersionNotFound("source".to_string(), "purged".to_string(), version_id.to_string()),
EcstoreError::ObjectNotFound("source".to_string(), "purged".to_string()),
EcstoreError::FileVersionNotFound,
EcstoreError::FileNotFound,
] {
let shared = empty_resync_shared_state();
*shared.delete_error.lock().expect("delete error lock should not be poisoned") = error;
let storage = Arc::new(LoadResyncNodeStore::new("purged-source", shared.clone()));
let delete = DeletedObjectReplicationInfo {
bucket: "source".to_string(),
delete_object: ReplicationDeletedObject {
object_name: "purged".to_string(),
version_id: (!delete_marker_version).then_some(version_id),
delete_marker_version_id: delete_marker_version.then_some(version_id),
replication_state: Some(ReplicationState {
replicate_decision_str: test_replicate_decision(&[arn]).to_string(),
purge_targets: HashMap::from([(arn.to_string(), VersionPurgeStatusType::Complete)]),
..Default::default()
}),
..Default::default()
},
op_type: ReplicationType::Delete,
..Default::default()
};
assert!(
replicate_delete_with_outcome(delete, storage).await,
"a confirmed target purge must complete when the source is already absent (marker={delete_marker_version}, version={version_id})"
);
assert_eq!(
shared.delete_storage_calls.load(Ordering::SeqCst),
2 + usize::from(!delete_marker_version && !version_id.is_nil()),
"the worker must lock and attempt writeback, reading a non-null data version first"
);
}
}
}
}
#[tokio::test]
async fn delete_worker_missing_source_does_not_hide_unfinished_replication() {
let arn = "arn:test";
for (version_purge, target_status, error) in [
(
true,
VersionPurgeStatusType::Pending,
EcstoreError::ObjectNotFound("source".to_string(), "purged".to_string()),
),
(true, VersionPurgeStatusType::Complete, EcstoreError::ErasureWriteQuorum),
(
false,
VersionPurgeStatusType::Empty,
EcstoreError::ObjectNotFound("source".to_string(), "purged".to_string()),
),
] {
let shared = empty_resync_shared_state();
*shared.delete_error.lock().expect("delete error lock should not be poisoned") = error;
let storage = Arc::new(LoadResyncNodeStore::new("unfinished-delete", shared));
let delete = DeletedObjectReplicationInfo {
bucket: "source".to_string(),
delete_object: ReplicationDeletedObject {
object_name: "purged".to_string(),
version_id: version_purge.then(Uuid::new_v4),
delete_marker: !version_purge,
replication_state: Some(ReplicationState {
replicate_decision_str: test_replicate_decision(&[arn]).to_string(),
targets: if version_purge {
HashMap::new()
} else {
HashMap::from([(arn.to_string(), ReplicationStatusType::Completed)])
},
purge_targets: if version_purge {
HashMap::from([(arn.to_string(), target_status)])
} else {
HashMap::new()
},
..Default::default()
}),
..Default::default()
},
op_type: ReplicationType::Delete,
..Default::default()
};
assert!(
!replicate_delete_with_outcome(delete, storage).await,
"unfinished target work, source I/O failures, and marker creation must remain retryable"
);
}
}
#[tokio::test] #[tokio::test]
async fn delete_worker_preserves_persisted_admission_without_live_config() { async fn delete_worker_preserves_persisted_admission_without_live_config() {
let arn = "arn:test"; let arn = "arn:test";
@@ -5304,6 +5528,8 @@ mod tests {
allow_first_read: Notify::new(), allow_first_read: Notify::new(),
read_count: AtomicUsize::new(0), read_count: AtomicUsize::new(0),
delete_storage_calls: AtomicUsize::new(0), delete_storage_calls: AtomicUsize::new(0),
delete_error: StdMutex::new(EcstoreError::NotImplemented),
replay_stamp: StdMutex::new(None),
reported_size: StdMutex::new(None), reported_size: StdMutex::new(None),
stream_read_bytes: Arc::new(AtomicUsize::new(0)), stream_read_bytes: Arc::new(AtomicUsize::new(0)),
write_count: AtomicUsize::new(0), write_count: AtomicUsize::new(0),
@@ -6268,6 +6494,108 @@ mod tests {
assert_eq!(retained.target_arns, entry.target_arns); assert_eq!(retained.target_arns, entry.target_arns);
} }
#[tokio::test]
async fn mrf_replay_loop_drains_entries_persisted_after_startup() {
let shared = empty_resync_shared_state();
let storage = Arc::new(LoadResyncNodeStore::new("mrf-runtime-replay", shared.clone()));
// A force-delete intent from a superseded generation replays as
// Skipped, so a pass that reaches it acknowledges it off the journal.
let stale_intent = |object: &str| MrfReplicateEntry {
bucket: "mrf-runtime-replay".to_string(),
object: object.to_string(),
op: MrfOpKind::Delete,
force_delete_id: Some(uuid::Uuid::new_v4()),
force_delete_local_commit: false,
target_arns: vec!["arn:rustfs:replication:target-a".to_string()],
..Default::default()
};
let journal_drained = || async {
tokio::time::timeout(Duration::from_secs(5), async {
while !shared.data.lock().expect("test data lock should not be poisoned").is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.is_ok()
};
let handle = spawn_mrf_replay_loop(storage, Duration::from_millis(20));
for object in ["first", "second"] {
*shared.data.lock().expect("test data lock should not be poisoned") =
encode_mrf_file(&[stale_intent(object)]).expect("MRF entry should encode");
assert!(
journal_drained().await,
"an MRF entry persisted while the server runs must be replayed without a restart ({object})"
);
}
handle.abort();
}
#[tokio::test]
async fn mrf_periodic_replay_runs_once_per_interval_across_nodes() {
let shared = empty_resync_shared_state();
let node_a = Arc::new(LoadResyncNodeStore::new("mrf-node-a", shared.clone()));
let node_b = Arc::new(LoadResyncNodeStore::new("mrf-node-b", shared.clone()));
let interval = Duration::from_secs(1);
let persist_backlog = |object: &str| {
let entry = MrfReplicateEntry {
bucket: "mrf-node-replay".to_string(),
object: object.to_string(),
op: MrfOpKind::Delete,
force_delete_id: Some(uuid::Uuid::new_v4()),
force_delete_local_commit: false,
target_arns: vec!["arn:rustfs:replication:target-a".to_string()],
..Default::default()
};
*shared.data.lock().expect("test data lock should not be poisoned") =
encode_mrf_file(&[entry]).expect("MRF entry should encode");
};
let journal_is_empty = || shared.data.lock().expect("test data lock should not be poisoned").is_empty();
persist_backlog("first");
assert!(replay_mrf_journal(&node_a, Some(interval)).await, "node A's periodic pass should replay");
assert!(journal_is_empty(), "node A's pass should acknowledge the replayed entry");
// Node B's timer fires within the same interval with a backlog present.
persist_backlog("second");
assert!(
!replay_mrf_journal(&node_b, Some(interval)).await,
"a node must not replay within one interval of another node's pass"
);
assert!(!journal_is_empty(), "the backlog should wait for the next interval");
assert!(
replay_mrf_journal(&node_b, None).await,
"the startup pass should replay regardless of the shared stamp"
);
assert!(journal_is_empty(), "the startup pass should acknowledge the replayed entry");
persist_backlog("third");
tokio::time::sleep(interval).await;
assert!(
replay_mrf_journal(&node_a, Some(interval)).await,
"a periodic pass should replay once the interval has elapsed"
);
assert!(journal_is_empty(), "the due pass should acknowledge the replayed entry");
}
#[test]
fn mrf_replay_due_tolerates_missing_and_skewed_stamps() {
let interval = Duration::from_secs(300);
let now = 1_000_000_000;
assert!(mrf_replay_due(None, now, interval));
assert!(!mrf_replay_due(Some(now - 299_999), now, interval));
assert!(mrf_replay_due(Some(now - 300_000), now, interval));
assert!(
!mrf_replay_due(Some(now + 1_000), now, interval),
"small clock skew should still space passes"
);
assert!(
mrf_replay_due(Some(now + 300_000), now, interval),
"a stamp an interval ahead must not suppress replay"
);
}
#[test] #[test]
fn mrf_entry_delete_marker_roundtrip() { fn mrf_entry_delete_marker_roundtrip() {
let dm_vid = Uuid::new_v4(); let dm_vid = Uuid::new_v4();
@@ -2809,17 +2809,22 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
true true
} }
Err(e) => { Err(e) => {
error!( // A batch purge or another worker may have removed this version
event = EVENT_RESYNC_TARGET_OPERATION_FAILED, // already. Remote failures still control the final outcome below.
component = LOG_COMPONENT_ECSTORE, let source_already_purged = is_version_purge && (is_err_object_not_found(&e) || is_err_version_not_found(&e));
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC, if !source_already_purged {
bucket = %bucket, error!(
arn = %dobj.target_arn, event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
object = %dobj.delete_object.object_name, component = LOG_COMPONENT_ECSTORE,
operation = "apply_replication_delete_state", subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
error = %e, bucket = %bucket,
"Replication target operation failed" arn = %dobj.target_arn,
); object = %dobj.delete_object.object_name,
operation = "apply_replication_delete_state",
error = %e,
"Replication target operation failed"
);
}
send_event(EventArgs { send_event(EventArgs {
event_name, event_name,
bucket_name: bucket.clone(), bucket_name: bucket.clone(),
@@ -2832,7 +2837,7 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
}, },
..Default::default() ..Default::default()
}); });
false source_already_purged
} }
}; };
@@ -3010,7 +3015,7 @@ const DELETE_MARKER_PURGE_WATCH_INTERVAL: TokioDuration = TokioDuration::from_se
/// If the marker disappears (deleted before or while the replica landed), /// If the marker disappears (deleted before or while the replica landed),
/// purge the replicated marker from the targets, retrying failed targets on /// purge the replicated marker from the targets, retrying failed targets on
/// later rounds. When the window drains with targets still dirty, persist the /// later rounds. When the window drains with targets still dirty, persist the
/// purge intent as a durable MRF entry so the next startup replays it through /// purge intent as a durable MRF entry so the next MRF replay pass re-drives it through
/// `purge_stale_delete_marker_targets`. /// `purge_stale_delete_marker_targets`.
async fn watch_and_purge_source_delete_marker<S: ReplicationStorage>( async fn watch_and_purge_source_delete_marker<S: ReplicationStorage>(
bucket: String, bucket: String,
@@ -3101,7 +3106,7 @@ async fn enqueue_delete_marker_purge_mrf(dobj: &DeletedObjectReplicationInfo, fa
/// Heal callers retry for the full watch window because the startup MRF /// Heal callers retry for the full watch window because the startup MRF
/// processor runs before bucket metadata (and thus target clients) finishes /// processor runs before bucket metadata (and thus target clients) finishes
/// initializing — the first pass can see `target_client_missing` and a later /// initializing — the first pass can see `target_client_missing` and a later
/// round resolves the client; the replay loop is serial and startup-only, so /// round resolves the client; replay passes are serial background work, so
/// blocking it for up to the window per dirty entry is acceptable. Live /// blocking it for up to the window per dirty entry is acceptable. Live
/// callers run on replication workers where a down target would pin a worker /// callers run on replication workers where a down target would pin a worker
/// for the whole window, so they attempt once and lean on the durable intent /// for the whole window, so they attempt once and lean on the durable intent
+1 -3
View File
@@ -32,7 +32,6 @@ use crate::storage_api_contracts::internode::{
PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_CAPABILITY_VERSION, PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_CAPABILITY_VERSION,
}; };
use hmac::{Hmac, KeyInit, Mac};
use http::uri::Authority; use http::uri::Authority;
use http::{HeaderMap, HeaderValue, Method, Uri}; use http::{HeaderMap, HeaderValue, Method, Uri};
#[cfg(test)] #[cfg(test)]
@@ -47,6 +46,7 @@ use rustfs_io_metrics::internode_metrics::{
global_internode_metrics, global_internode_metrics,
}; };
use rustfs_object_data_cache::{MemoryBasis, resolve_effective_memory}; use rustfs_object_data_cache::{MemoryBasis, resolve_effective_memory};
use rustfs_utils::crypto::{HmacSha256, KeyInit, Mac};
use rustfs_utils::get_env_bool; use rustfs_utils::get_env_bool;
use sha2::Digest as _; use sha2::Digest as _;
use sha2::Sha256; use sha2::Sha256;
@@ -58,8 +58,6 @@ use time::OffsetDateTime;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
const SIGNATURE_HEADER: &str = "x-rustfs-signature"; const SIGNATURE_HEADER: &str = "x-rustfs-signature";
pub const RPC_BACKGROUND_ADMISSION_HEADER: &str = "x-rustfs-rpc-background-admission"; pub const RPC_BACKGROUND_ADMISSION_HEADER: &str = "x-rustfs-rpc-background-admission";
const RPC_BACKGROUND_ADMISSION_V1: &str = "v1"; const RPC_BACKGROUND_ADMISSION_V1: &str = "v1";
@@ -1637,6 +1637,7 @@ impl RemoteDisk {
let initial_probe_ok = Self::perform_connectivity_check(&addr).await.is_ok(); let initial_probe_ok = Self::perform_connectivity_check(&addr).await.is_ok();
if initial_probe_ok { if initial_probe_ok {
health.record_operation_success(&endpoint, "connectivity_probe_success"); health.record_operation_success(&endpoint, "connectivity_probe_success");
rustfs_io_metrics::internode_metrics::record_peer_reachable_if_offline(&addr);
} else if health.mark_failure(&endpoint, "connectivity_probe_failed") { } else if health.mark_failure(&endpoint, "connectivity_probe_failed") {
warn!( warn!(
event = EVENT_REMOTE_DISK_HEALTH, event = EVENT_REMOTE_DISK_HEALTH,
@@ -1708,6 +1709,7 @@ impl RemoteDisk {
// Perform basic connectivity check // Perform basic connectivity check
if Self::perform_connectivity_check(&addr).await.is_ok() { if Self::perform_connectivity_check(&addr).await.is_ok() {
health.record_operation_success(&endpoint, "connectivity_probe_success"); health.record_operation_success(&endpoint, "connectivity_probe_success");
rustfs_io_metrics::internode_metrics::record_peer_reachable_if_offline(&addr);
} else if health.mark_failure(&endpoint, "connectivity_probe_failed") { } else if health.mark_failure(&endpoint, "connectivity_probe_failed") {
warn!( warn!(
event = EVENT_REMOTE_DISK_HEALTH, event = EVENT_REMOTE_DISK_HEALTH,
@@ -1778,6 +1780,7 @@ impl RemoteDisk {
} }
_ = interval.tick() => { _ = interval.tick() => {
if Self::perform_recovery_probe(&addr, &endpoint).await.is_ok() { if Self::perform_recovery_probe(&addr, &endpoint).await.is_ok() {
rustfs_io_metrics::internode_metrics::record_peer_reachable(&addr);
let became_online = health.mark_recovery_success(&endpoint, "disk_info_probe_success"); let became_online = health.mark_recovery_success(&endpoint, "disk_info_probe_success");
debug!( debug!(
event = EVENT_REMOTE_DISK_HEALTH, event = EVENT_REMOTE_DISK_HEALTH,
@@ -2009,6 +2012,7 @@ impl RemoteDisk {
let operation_result = operation().await; let operation_result = operation().await;
if operation_result.is_ok() { if operation_result.is_ok() {
self.health.record_operation_success(&self.endpoint, "operation_success"); self.health.record_operation_success(&self.endpoint, "operation_success");
rustfs_io_metrics::internode_metrics::record_peer_reachable_if_offline(&self.addr);
} }
self.handle_network_like_error(op, timeout_duration, &operation_result, failure_health_action) self.handle_network_like_error(op, timeout_duration, &operation_result, failure_health_action)
.await; .await;
@@ -2023,6 +2027,7 @@ impl RemoteDisk {
// Log success; the waiting guard balances every exit path. // Log success; the waiting guard balances every exit path.
if operation_result.is_ok() { if operation_result.is_ok() {
self.health.record_operation_success(&self.endpoint, "operation_success"); self.health.record_operation_success(&self.endpoint, "operation_success");
rustfs_io_metrics::internode_metrics::record_peer_reachable_if_offline(&self.addr);
} }
self.handle_network_like_error(op, timeout_duration, &operation_result, failure_health_action) self.handle_network_like_error(op, timeout_duration, &operation_result, failure_health_action)
.await; .await;
@@ -445,6 +445,10 @@ impl RemoteClient {
} }
} }
}); });
// A successful lock RPC over a cached or fresh channel proves the peer is
// reachable; reconcile the internode peer-health gauge so it does not stay
// stale when no fresh dial fires (issue #9119).
rustfs_io_metrics::internode_metrics::record_peer_reachable_if_offline(&self.addr);
} }
fn admit_late_release(&self) -> bool { fn admit_late_release(&self) -> bool {
@@ -672,6 +676,7 @@ impl RemoteClient {
with_lock_peer_health(&addr, |health| { with_lock_peer_health(&addr, |health| {
health.last_success = Some(Instant::now()); health.last_success = Some(Instant::now());
}); });
rustfs_io_metrics::internode_metrics::record_peer_reachable_if_offline(&addr);
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "success"); rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "success");
if let Some(late) = late { if let Some(late) = late {
late(response).await; late(response).await;
@@ -18,6 +18,7 @@ use crate::storage_api_contracts::namespace::NamespaceLocking as _;
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split}; use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
use std::path::PathBuf; use std::path::PathBuf;
use tokio::fs; use tokio::fs;
use uuid::Uuid;
struct PhysicalFixture { struct PhysicalFixture {
_directories: Vec<tempfile::TempDir>, _directories: Vec<tempfile::TempDir>,
@@ -628,3 +629,559 @@ async fn pool_meta_native_physical_three_plus_one_checkpoint_converges_all_shard
); );
} }
} }
async fn owner_repair_no_backups_fixture() -> PhysicalFixture {
let fixture = fixture(0).await;
for pool in disks(&fixture.store).await {
for disk in pool {
let object = object_path(&disk);
let mut entries = fs::read_dir(&object).await.expect("fixture metadata directory");
while let Some(entry) = entries.next_entry().await.expect("fixture directory entry") {
if entry.file_type().await.expect("fixture entry type").is_dir() {
fs::remove_dir_all(entry.path())
.await
.expect("remove task-owned fixture rollback directory");
}
}
}
}
fixture
}
async fn owner_repair_identity_heads(store: &ECStore) -> Vec<Vec<(Uuid, Bytes)>> {
let mut all = Vec::new();
for pool in &store.pools {
let set = pool.get_disks_by_key(POOL_META_IDENTITY_NAME);
let members = set
.disks
.read()
.await
.iter()
.cloned()
.map(|disk| disk.expect("identity fixture disk must be online"))
.collect::<Vec<_>>();
let mut heads = Vec::new();
for disk in members {
match disk
.pool_meta_native_recovery(Request::InspectIdentity {})
.await
.expect("physical identity inspection")
{
Response::IdentityInspected { disk_id, raw } => heads.push((disk_id, raw)),
_ => panic!("identity inspection must return its physical head"),
}
}
all.push(heads);
}
all
}
async fn legacy_native_owner_fixture(version: u16, stale: bool) -> (PhysicalFixture, Vec<u8>) {
let fixture = owner_repair_no_backups_fixture().await;
let mut meta = match decode_pool_meta_replica(fixture.canonical.clone()) {
PoolMetaReplica::Valid {
meta, committed: true, ..
} => meta,
_ => panic!("fixture must provide valid committed pool metadata"),
};
meta.version = version;
let raw = meta
.encode_config_data_for_v2_gate(version == POOL_META_VERSION)
.expect("legacy fixture should encode in its original version");
assert_eq!(u16::from_le_bytes([raw[2], raw[3]]), version);
let all = disks(&fixture.store).await;
let now = OffsetDateTime::now_utc();
for pool in &all {
let heads = replicas(&raw, now).await;
for (index, disk) in pool.iter().enumerate() {
fs::write(object_path(disk).join("xl.meta"), &heads[index])
.await
.expect("install exact legacy head");
}
}
if stale {
let old = replicas(&raw, now - Duration::seconds(10)).await;
fs::write(object_path(&all[0][0]).join("xl.meta"), &old[0])
.await
.expect("install stale same-raw legacy head");
}
(fixture, raw)
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_heal_v1_with_all_upgrade_gates_disabled_keeps_exact_raw() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_POOL_META_V2_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED, Some("false")),
(rustfs_config::ENV_POOL_META_V3_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED, Some("false")),
],
async {
let (fixture, raw) = legacy_native_owner_fixture(POOL_META_V1_VERSION, false).await;
let guard = owner(&fixture.store).await;
let mut state = fixture.store.pool_meta_save_gate.lock().await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
fixture.store.heal_pool_meta_replica_under_fence(0, guard, &mut state),
)
.await
.expect("legacy native heal must terminate");
assert!(result.is_ok(), "fully online legacy native heal should succeed: {result:?}");
drop(state);
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
for read in &reads {
assert!(
matches!(&read.replica, PoolMetaReplica::Valid { raw: bytes, revision, .. }
if bytes == &raw && revision.version == POOL_META_V1_VERSION),
"native heal must not upgrade a V1 raw payload when both upgrade gates are disabled"
);
}
},
)
.await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_heal_rejects_legacy_source_with_trailing_bytes_without_writes() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_POOL_META_V2_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED, Some("false")),
(rustfs_config::ENV_POOL_META_V3_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED, Some("false")),
],
async {
for version in [POOL_META_V1_VERSION, POOL_META_VERSION] {
let (fixture, raw) = legacy_native_owner_fixture(version, false).await;
let mut source_raw = raw.clone();
// A second MessagePack value is readable by legacy decoding but
// must not be copied as part of an unverified repair payload.
source_raw.push(0xc0);
assert!(
matches!(decode_pool_meta_replica(source_raw.clone()), PoolMetaReplica::Valid { raw, .. } if raw == source_raw),
"ordinary legacy reads must remain compatible with trailing bytes"
);
let all_disks = disks(&fixture.store).await;
let source_heads = replicas(&source_raw, OffsetDateTime::now_utc() + Duration::seconds(1)).await;
for (index, disk) in all_disks[0].iter().enumerate() {
fs::write(object_path(disk).join("xl.meta"), &source_heads[index])
.await
.expect("install readable legacy source with trailing bytes");
}
let selection = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await
.expect("legacy semantic selection must remain readable");
assert_eq!(selection.legacy_raw.as_deref(), Some(source_raw.as_slice()));
let before = snapshots(&fixture.store).await;
let identities = owner_repair_identity_heads(&fixture.store).await;
let guard = owner(&fixture.store).await;
let mut state = fixture.store.pool_meta_save_gate.lock().await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
fixture.store.heal_pool_meta_replica_under_fence(1, guard, &mut state),
)
.await
.expect("legacy native heal must terminate");
drop(state);
assert!(result.is_err(), "version {version}: unparsed trailing source bytes must not be republished");
assert_eq!(snapshots(&fixture.store).await, before, "version {version}: no pool.bin head or backup may change");
assert_eq!(
owner_repair_identity_heads(&fixture.store).await,
identities,
"version {version}: no identity shard may change"
);
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(&reads[0].replica, PoolMetaReplica::Valid { raw, .. } if raw == &source_raw));
assert!(matches!(&reads[1].replica, PoolMetaReplica::Valid { raw: target, .. } if target == &raw));
}
},
)
.await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_heal_mixed_v1_v2_preserves_observed_format_floor() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_POOL_META_V2_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED, Some("false")),
(rustfs_config::ENV_POOL_META_V3_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED, Some("false")),
],
async {
for first_version in [POOL_META_V1_VERSION, POOL_META_VERSION] {
for target_pool in [0, 1] {
let (fixture, raw_v1) = legacy_native_owner_fixture(POOL_META_V1_VERSION, false).await;
let mut meta = match decode_pool_meta_replica(raw_v1.clone()) {
PoolMetaReplica::Valid { meta, committed: true, .. } => meta,
_ => panic!("initial V1 metadata must decode"),
};
meta.version = POOL_META_VERSION;
let raw_v2 = meta.encode_config_data_for_v2_gate(true).expect("equivalent V2 payload must encode");
assert_eq!(u16::from_le_bytes([raw_v1[2], raw_v1[3]]), POOL_META_V1_VERSION);
assert_eq!(u16::from_le_bytes([raw_v2[2], raw_v2[3]]), POOL_META_VERSION);
let semantic_v1 = match decode_pool_meta_replica(raw_v1.clone()) {
PoolMetaReplica::Valid { canonical, .. } => canonical,
_ => panic!("V1 payload must remain valid"),
};
let semantic_v2 = match decode_pool_meta_replica(raw_v2.clone()) {
PoolMetaReplica::Valid { canonical, .. } => canonical,
_ => panic!("V2 payload must remain valid"),
};
assert_eq!(semantic_v1, semantic_v2, "the two formats must carry equivalent metadata");
let other_version = if first_version == POOL_META_V1_VERSION { POOL_META_VERSION } else { POOL_META_V1_VERSION };
// Pool two follows pool zero; pools zero and one cover both
// orderings and both target formats in separate runs.
let versions = [first_version, other_version, first_version];
let physical = disks(&fixture.store).await;
let timestamp = OffsetDateTime::now_utc();
for (pool_index, pool) in physical.iter().enumerate() {
let raw = if versions[pool_index] == POOL_META_V1_VERSION { &raw_v1 } else { &raw_v2 };
let heads = replicas(raw, timestamp).await;
for (index, disk) in pool.iter().enumerate() {
fs::write(object_path(disk).join("xl.meta"), &heads[index]).await.expect("install mixed legacy pool head");
}
}
let before = snapshots(&fixture.store).await;
let selection = load_pool_meta_replicas(fixture.store.pools.clone(), true)
.await.expect("equivalent legacy pools should be selectable");
assert_eq!(selection.revision.version, POOL_META_VERSION, "the observed V2 format floor must be retained");
assert_eq!(selection.canonical.as_deref(), Some(semantic_v2.as_slice()));
let guard = owner(&fixture.store).await;
let mut state = fixture.store.pool_meta_save_gate.lock().await;
tokio::time::timeout(std::time::Duration::from_secs(30),
fixture.store.heal_pool_meta_replica_under_fence(target_pool, guard, &mut state))
.await.expect("mixed legacy heal must terminate")
.expect("equivalent legacy metadata should heal without downgrading the format floor");
drop(state);
let after = snapshots(&fixture.store).await;
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
for (pool_index, read) in reads.iter().enumerate() {
let expected_version = if pool_index == target_pool { POOL_META_VERSION } else { versions[pool_index] };
let expected_raw = if expected_version == POOL_META_V1_VERSION { &raw_v1 } else { &raw_v2 };
assert!(matches!(&read.replica,
PoolMetaReplica::Valid { raw, canonical, revision, committed: true, .. }
if raw == expected_raw && canonical == &semantic_v2 && revision.version == expected_version
), "pool {pool_index}, first={first_version}, target={target_pool}: preserve equivalent payload and expected legacy version");
for (index, snapshot) in after[pool_index].iter().enumerate() {
codec::validate_inline_pool_meta_shard(snapshot.current.as_ref().expect("physical head"), index, expected_raw)
.await.expect("every physical shard must match its selected legacy payload");
}
if pool_index != target_pool {
assert_eq!(after[pool_index], before[pool_index], "healing one pool must not modify other pools");
}
}
}
}
},
).await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_heal_rejects_v1_and_v3_prepare_in_either_pool_order() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_POOL_META_V2_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED, Some("false")),
(rustfs_config::ENV_POOL_META_V3_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED, Some("false")),
],
async {
for prepare_pool in [0, 1] {
let (fixture, raw_v1) = legacy_native_owner_fixture(POOL_META_V1_VERSION, false).await;
let (legacy_meta, legacy_canonical) = match decode_pool_meta_replica(raw_v1.clone()) {
PoolMetaReplica::Valid {
meta,
canonical,
revision,
committed: true,
..
} if revision.version == POOL_META_V1_VERSION => (meta, canonical),
_ => panic!("baseline V1 metadata must be committed and readable"),
};
let previous = PoolMetaCommittedCandidate {
canonical: legacy_canonical,
meta: legacy_meta.clone(),
revision: PoolMetaRevision::legacy(POOL_META_V1_VERSION),
};
let mut next = legacy_meta;
next.version = POOL_META_GENERATION_VERSION;
next.pools[0].last_update += Duration::seconds(1);
let revision = PoolMetaRevision {
version: POOL_META_GENERATION_VERSION,
cluster_id: fixture.revision.cluster_id,
epoch: fixture.revision.epoch,
generation: 1,
transaction_id: Some(Uuid::new_v4()),
};
let pending = encode_pool_meta_v3_envelope(&next, revision, false, Some(&previous))
.expect("V3 PREPARE with V1 predecessor must encode");
assert!(matches!(decode_pool_meta_replica(pending.clone()),
PoolMetaReplica::Valid { committed: false, revision: actual, .. } if actual == revision));
let heads = replicas(&pending, OffsetDateTime::now_utc() + Duration::seconds(1)).await;
let physical = disks(&fixture.store).await;
for (index, disk) in physical[prepare_pool].iter().enumerate() {
fs::write(object_path(disk).join("xl.meta"), &heads[index])
.await
.expect("install task-owned V3 PREPARE evidence");
}
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(&reads[prepare_pool].replica,
PoolMetaReplica::Valid { raw, committed: false, revision: actual, .. }
if raw == &pending && *actual == revision));
let other_pool = 1 - prepare_pool;
assert!(matches!(&reads[other_pool].replica,
PoolMetaReplica::Valid { raw, committed: true, revision: actual, .. }
if raw == &raw_v1 && actual.version == POOL_META_V1_VERSION));
let before = snapshots(&fixture.store).await;
let identities = owner_repair_identity_heads(&fixture.store).await;
let guard = owner(&fixture.store).await;
let mut state = fixture.store.pool_meta_save_gate.lock().await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
fixture
.store
.heal_pool_meta_replica_under_fence(prepare_pool, guard, &mut state),
)
.await
.expect("mixed V1/V3 PREPARE heal must terminate");
drop(state);
assert!(
result.is_err(),
"pool {prepare_pool}: native heal must not overwrite a V3 PREPARE with legacy data"
);
assert_eq!(
snapshots(&fixture.store).await,
before,
"pool {prepare_pool}: rejection must retain all physical head and backup evidence"
);
assert_eq!(
owner_repair_identity_heads(&fixture.store).await,
identities,
"pool {prepare_pool}: rejection must not mutate identity evidence"
);
}
},
)
.await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_heal_rejects_v1_legacy_dont_save_true_without_writes() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_POOL_META_V2_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED, Some("false")),
(rustfs_config::ENV_POOL_META_V3_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED, Some("false")),
],
async {
let (fixture, safe_v1) = legacy_native_owner_fixture(POOL_META_V1_VERSION, false).await;
let meta = match decode_pool_meta_replica(safe_v1.clone()) {
PoolMetaReplica::Valid {
meta, committed: true, ..
} => meta,
_ => panic!("baseline V1 metadata must decode"),
};
let statuses = meta
.pools
.iter()
.map(|pool| {
assert!(pool.decommission.is_none(), "this regression fixture does not inject decommission state");
LegacyPoolStatus {
id: pool.id,
cmd_line: pool.cmd_line.clone(),
last_update: pool.last_update,
decommission: None,
}
})
.collect();
let legacy = LegacyPoolMeta {
version: POOL_META_V1_VERSION,
pools: statuses,
dont_save: true,
};
let mut dangerous_raw = Vec::new();
dangerous_raw.extend_from_slice(&POOL_META_FORMAT.to_le_bytes());
dangerous_raw.extend_from_slice(&POOL_META_V1_VERSION.to_le_bytes());
dangerous_raw.extend_from_slice(&rmp_serde::to_vec(&legacy).expect("legacy three-field tuple must serialize"));
let old_reader: LegacyPoolMeta =
rmp_serde::from_slice(&dangerous_raw[4..]).expect("legacy reader must decode source");
assert!(old_reader.dont_save, "the old reader would treat this source as permanently do-not-save");
assert_eq!(rmp::decode::read_array_len(&mut &dangerous_raw[4..]).expect("tuple length"), 3);
let source = match decode_pool_meta_replica(dangerous_raw.clone()) {
PoolMetaReplica::Valid {
canonical,
revision,
committed: true,
..
} => {
assert_eq!(revision.version, POOL_META_V1_VERSION);
canonical
}
_ => panic!("current reader must recognize the legacy source"),
};
assert!(matches!(decode_pool_meta_replica(safe_v1.clone()),
PoolMetaReplica::Valid { canonical, revision, committed: true, .. }
if canonical == source && revision.version == POOL_META_V1_VERSION));
let physical = disks(&fixture.store).await;
let source_heads = replicas(&dangerous_raw, OffsetDateTime::now_utc() + Duration::seconds(1)).await;
for (index, disk) in physical[0].iter().enumerate() {
fs::write(object_path(disk).join("xl.meta"), &source_heads[index])
.await
.expect("install legacy source on pool zero");
}
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(&reads[0].replica, PoolMetaReplica::Valid { raw, committed: true, .. } if raw == &dangerous_raw));
assert!(matches!(&reads[1].replica, PoolMetaReplica::Valid { raw, committed: true, .. } if raw == &safe_v1));
let before = snapshots(&fixture.store).await;
let identities = owner_repair_identity_heads(&fixture.store).await;
let guard = owner(&fixture.store).await;
let mut state = fixture.store.pool_meta_save_gate.lock().await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
fixture.store.heal_pool_meta_replica_under_fence(1, guard, &mut state),
)
.await
.expect("unsafe legacy heal must terminate");
drop(state);
let error = result.expect_err("native heal must reject a source whose old-reader dont_save is true");
assert!(
error.to_string().contains("dont_save"),
"rejection must identify the unsafe legacy field: {error}"
);
assert_eq!(
snapshots(&fixture.store).await,
before,
"rejection must preserve every original head and backup"
);
assert_eq!(
owner_repair_identity_heads(&fixture.store).await,
identities,
"rejection must not write identity shards"
);
let after = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(&after[0].replica, PoolMetaReplica::Valid { raw, .. } if raw == &dangerous_raw));
assert!(
matches!(&after[1].replica, PoolMetaReplica::Valid { raw, .. } if raw == &safe_v1),
"the dangerous source bytes must not be copied into the target pool"
);
},
)
.await;
}
#[tokio::test]
#[serial_test::serial(pool_meta_version_env)]
async fn pool_meta_native_heal_rejects_target_v1_dont_save_true_without_writes() {
temp_env::async_with_vars(
[
(rustfs_config::ENV_POOL_META_V2_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V2_FLEET_CONFIRMED, Some("false")),
(rustfs_config::ENV_POOL_META_V3_WRITE, Some("false")),
(rustfs_config::ENV_POOL_META_V3_FLEET_CONFIRMED, Some("false")),
],
async {
let (fixture, safe_v1) = legacy_native_owner_fixture(POOL_META_V1_VERSION, false).await;
let meta = match decode_pool_meta_replica(safe_v1.clone()) {
PoolMetaReplica::Valid {
meta, committed: true, ..
} => meta,
_ => panic!("baseline V1 metadata must decode"),
};
let statuses = meta
.pools
.iter()
.map(|pool| {
assert!(pool.decommission.is_none(), "this regression fixture does not inject decommission state");
LegacyPoolStatus {
id: pool.id,
cmd_line: pool.cmd_line.clone(),
last_update: pool.last_update,
decommission: None,
}
})
.collect();
let legacy = LegacyPoolMeta {
version: POOL_META_V1_VERSION,
pools: statuses,
dont_save: true,
};
let mut dangerous_raw = Vec::new();
dangerous_raw.extend_from_slice(&POOL_META_FORMAT.to_le_bytes());
dangerous_raw.extend_from_slice(&POOL_META_V1_VERSION.to_le_bytes());
dangerous_raw.extend_from_slice(&rmp_serde::to_vec(&legacy).expect("legacy three-field tuple must serialize"));
let old_reader: LegacyPoolMeta =
rmp_serde::from_slice(&dangerous_raw[4..]).expect("legacy reader must decode target");
assert!(old_reader.dont_save, "the old reader interprets the target as do-not-save");
assert_eq!(rmp::decode::read_array_len(&mut &dangerous_raw[4..]).expect("tuple length"), 3);
let source_canonical = match decode_pool_meta_replica(safe_v1.clone()) {
PoolMetaReplica::Valid {
canonical,
revision,
committed: true,
..
} => {
assert_eq!(revision.version, POOL_META_V1_VERSION);
canonical
}
_ => panic!("safe pool-zero source must decode"),
};
assert!(
matches!(decode_pool_meta_replica(dangerous_raw.clone()),
PoolMetaReplica::Valid { canonical, revision, committed: true, .. }
if canonical == source_canonical && revision.version == POOL_META_V1_VERSION),
"the dangerous target must remain semantically equivalent to the safe source"
);
let physical = disks(&fixture.store).await;
let target_heads = replicas(&dangerous_raw, OffsetDateTime::now_utc() + Duration::seconds(1)).await;
for (index, disk) in physical[1].iter().enumerate() {
fs::write(object_path(disk).join("xl.meta"), &target_heads[index])
.await
.expect("install legacy dangerous target head");
}
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(&reads[0].replica, PoolMetaReplica::Valid { raw, committed: true, .. } if raw == &safe_v1));
assert!(matches!(&reads[1].replica, PoolMetaReplica::Valid { raw, committed: true, .. } if raw == &dangerous_raw));
let before = snapshots(&fixture.store).await;
let identities = owner_repair_identity_heads(&fixture.store).await;
let guard = owner(&fixture.store).await;
let mut state = fixture.store.pool_meta_save_gate.lock().await;
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
fixture.store.heal_pool_meta_replica_under_fence(1, guard, &mut state),
)
.await
.expect("unsafe legacy target heal must terminate");
drop(state);
let error = result.expect_err("native heal must reject a target whose old-reader dont_save is true");
assert!(
error.to_string().contains("dont_save"),
"rejection must identify the unsafe legacy field: {error}"
);
assert_eq!(
snapshots(&fixture.store).await,
before,
"rejection must preserve all physical heads and backups"
);
assert_eq!(
owner_repair_identity_heads(&fixture.store).await,
identities,
"rejection must not write identity shards"
);
let after = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
assert!(matches!(&after[0].replica, PoolMetaReplica::Valid { raw, .. } if raw == &safe_v1));
assert!(
matches!(&after[1].replica, PoolMetaReplica::Valid { raw, .. } if raw == &dangerous_raw),
"unsafe target evidence must not be overwritten by the safe source"
);
},
)
.await;
}
+128 -4
View File
@@ -94,7 +94,6 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fmt::Display; use std::fmt::Display;
#[cfg(test)]
use std::io::Cursor; use std::io::Cursor;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
@@ -6839,11 +6838,47 @@ impl Drop for PoolMetaTransactionArm {
} }
} }
fn ensure_legacy_pool_meta_republish_safe(data: &[u8]) -> Result<()> {
if data.len() >= 4
&& LittleEndian::read_u16(&data[2..4]) == POOL_META_V1_VERSION
// The selection already validates the payload. The runtime-only
// flag can only appear in the legacy three-field sequence or map.
&& rmp::decode::read_array_len(&mut &data[4..])
.or_else(|_| rmp::decode::read_map_len(&mut &data[4..]))
.ok()
== Some(3)
&& let Ok(legacy) = rmp_serde::from_slice::<LegacyPoolMeta>(&data[4..])
&& legacy.dont_save
{
return Err(Error::other("pool metadata legacy source contains dont_save=true"));
}
Ok(())
}
// Ordinary legacy reads retain their compatibility behavior, but bytes that
// will be copied to another pool must not contain an unparsed extension.
fn ensure_legacy_pool_meta_payload_complete(data: &[u8]) -> Result<()> {
let payload = data
.get(4..)
.ok_or_else(|| Error::other("pool metadata legacy source is too short"))?;
let mut decoder = rmp_serde::Deserializer::new(Cursor::new(payload));
serde::de::IgnoredAny::deserialize(&mut decoder).map_err(|_| Error::other("pool metadata legacy source is invalid"))?;
let payload_len = u64::try_from(payload.len()).map_err(|_| Error::other("pool metadata legacy source is too large"))?;
if decoder.get_ref().position() != payload_len {
return Err(Error::other("pool metadata legacy source has trailing bytes"));
}
Ok(())
}
#[derive(Debug)] #[derive(Debug)]
struct PoolMetaSelection { struct PoolMetaSelection {
meta: PoolMeta, meta: PoolMeta,
revision: PoolMetaRevision, revision: PoolMetaRevision,
canonical: Option<Vec<u8>>, canonical: Option<Vec<u8>>,
// Legacy semantic comparison canonicalizes to V2; a heal must instead
// preserve verified raw bytes at the highest observed format floor.
legacy_raw: Option<Vec<u8>>,
legacy_has_unsafe_flag: bool,
replica_state: PoolMetaReplicaState, replica_state: PoolMetaReplicaState,
cas_tokens: Vec<PoolMetaCasToken>, cas_tokens: Vec<PoolMetaCasToken>,
absent: bool, absent: bool,
@@ -7234,6 +7269,8 @@ fn select_pool_meta_replica_reads(reads: Vec<PoolMetaReplicaRead>) -> Result<Poo
meta: PoolMeta::default(), meta: PoolMeta::default(),
revision: PoolMetaRevision::legacy(0), revision: PoolMetaRevision::legacy(0),
canonical: None, canonical: None,
legacy_raw: None,
legacy_has_unsafe_flag: false,
replica_state: PoolMetaReplicaState { replica_state: PoolMetaReplicaState {
needs_repair: false, needs_repair: false,
repair_write_safe: true, repair_write_safe: true,
@@ -7288,6 +7325,8 @@ fn select_pool_meta_replica_reads(reads: Vec<PoolMetaReplicaRead>) -> Result<Poo
meta: selected.meta.clone(), meta: selected.meta.clone(),
revision: selected.revision, revision: selected.revision,
canonical: Some(selected.canonical.clone()), canonical: Some(selected.canonical.clone()),
legacy_raw: None,
legacy_has_unsafe_flag: false,
replica_state: PoolMetaReplicaState { replica_state: PoolMetaReplicaState {
needs_repair, needs_repair,
repair_write_safe, repair_write_safe,
@@ -7301,6 +7340,10 @@ fn select_pool_meta_replica_reads(reads: Vec<PoolMetaReplicaRead>) -> Result<Poo
// Legacy V1/V2 has no durable generation. Pool zero remains the commit // Legacy V1/V2 has no durable generation. Pool zero remains the commit
// record; divergent backups without it are ambiguous and fail closed. // record; divergent backups without it are ambiguous and fail closed.
let legacy_has_unsafe_flag = committed
.iter()
.any(|(_, raw, _)| ensure_legacy_pool_meta_republish_safe(raw).is_err());
let mut floor_raw = None;
let mut selected: Option<(usize, Vec<u8>, PoolMetaCommittedCandidate)> = None; let mut selected: Option<(usize, Vec<u8>, PoolMetaCommittedCandidate)> = None;
for (idx, raw, candidate) in committed { for (idx, raw, candidate) in committed {
if let Some((selected_idx, selected_raw, selected_candidate)) = selected.as_ref() { if let Some((selected_idx, selected_raw, selected_candidate)) = selected.as_ref() {
@@ -7314,19 +7357,36 @@ fn select_pool_meta_replica_reads(reads: Vec<PoolMetaReplicaRead>) -> Result<Poo
} }
} else { } else {
needs_repair |= selected_raw != &raw; needs_repair |= selected_raw != &raw;
if floor_raw.is_none()
&& candidate.revision.version == observed_version
&& raw
.get(2..4)
.is_some_and(|version| LittleEndian::read_u16(version) == observed_version)
{
floor_raw = Some(raw);
}
} }
} else { } else {
selected = Some((idx, raw, candidate)); selected = Some((idx, raw, candidate));
} }
} }
let (_, _, mut selected) = let (_, selected_raw, mut selected) =
selected.ok_or_else(|| Error::other("pool metadata recovery required: no valid legacy replica is available"))?; selected.ok_or_else(|| Error::other("pool metadata recovery required: no valid legacy replica is available"))?;
if selected.revision.version == observed_version
&& selected_raw
.get(2..4)
.is_some_and(|version| LittleEndian::read_u16(version) == observed_version)
{
floor_raw = Some(selected_raw);
}
selected.meta.version = observed_version; selected.meta.version = observed_version;
selected.revision.version = observed_version; selected.revision.version = observed_version;
Ok(PoolMetaSelection { Ok(PoolMetaSelection {
meta: selected.meta, meta: selected.meta,
revision: selected.revision, revision: selected.revision,
canonical: Some(selected.canonical), canonical: Some(selected.canonical),
legacy_raw: floor_raw,
legacy_has_unsafe_flag,
replica_state: PoolMetaReplicaState { replica_state: PoolMetaReplicaState {
needs_repair, needs_repair,
repair_write_safe, repair_write_safe,
@@ -14546,6 +14606,23 @@ impl ECStore {
.canonical .canonical
.as_ref() .as_ref()
.ok_or_else(|| Error::other("pool metadata heal has no committed source"))?; .ok_or_else(|| Error::other("pool metadata heal has no committed source"))?;
if selection.generation_protocol_observed && !selection.revision.is_generation_protocol() {
return Err(Error::other("pool metadata heal requires recovery of the observed generation protocol"));
}
if selection.legacy_has_unsafe_flag {
return Err(Error::other("pool metadata legacy replica contains dont_save=true"));
}
let repair_data = if selection.revision.is_generation_protocol() {
canonical.clone()
} else {
let raw = selection
.legacy_raw
.clone()
.ok_or_else(|| Error::other("pool metadata heal cannot prove the legacy format floor"))?;
ensure_legacy_pool_meta_payload_complete(&raw)?;
raw
};
ensure_legacy_pool_meta_republish_safe(&repair_data)?;
let pool = self let pool = self
.pools .pools
.get(pool_index) .get(pool_index)
@@ -14556,10 +14633,10 @@ impl ECStore {
.get(pool_index) .get(pool_index)
.ok_or_else(|| Error::other("pool metadata heal has no target revision"))?; .ok_or_else(|| Error::other("pool metadata heal has no target revision"))?;
let mut arm = state.arm_transaction(); let mut arm = state.arm_transaction();
save_pool_meta_object_cas(pool.clone(), POOL_META_NAME, canonical.clone(), token, &fence, "native_heal", &mut arm) save_pool_meta_object_cas(pool.clone(), POOL_META_NAME, repair_data.clone(), token, &fence, "native_heal", &mut arm)
.await?; .await?;
let (confirmed, _) = read_config_no_lock_preserve_empty_with_metadata(pool, POOL_META_NAME).await?; let (confirmed, _) = read_config_no_lock_preserve_empty_with_metadata(pool, POOL_META_NAME).await?;
if confirmed != *canonical { if confirmed != repair_data {
return Err(Error::other("pool metadata native heal readback diverged")); return Err(Error::other("pool metadata native heal readback diverged"));
} }
fence.ensure_held()?; fence.ensure_held()?;
@@ -29482,6 +29559,53 @@ mod tests {
); );
} }
#[test]
fn legacy_republish_guard_checks_only_three_field_sequence_or_map() {
let frame = |payload: Vec<u8>| {
let mut data = Vec::new();
data.extend_from_slice(&POOL_META_FORMAT.to_le_bytes());
data.extend_from_slice(&POOL_META_V1_VERSION.to_le_bytes());
data.extend_from_slice(&payload);
data
};
let two_fields = PersistedPoolMetaV1 {
version: POOL_META_V1_VERSION,
pools: Vec::new(),
};
for payload in [rmp_serde::to_vec(&two_fields), rmp_serde::to_vec_named(&two_fields)] {
let data = frame(payload.expect("two-field V1 payload must serialize"));
assert!(matches!(decode_pool_meta_replica(data.clone()), PoolMetaReplica::Valid { .. }));
assert!(ensure_legacy_pool_meta_republish_safe(&data).is_ok());
assert!(ensure_legacy_pool_meta_payload_complete(&data).is_ok());
let mut trailing = data;
trailing.push(0xc0);
assert!(ensure_legacy_pool_meta_payload_complete(&trailing).is_err());
}
for dont_save in [false, true] {
let legacy = LegacyPoolMeta {
version: POOL_META_V1_VERSION,
pools: Vec::new(),
dont_save,
};
for payload in [rmp_serde::to_vec(&legacy), rmp_serde::to_vec_named(&legacy)] {
let data = frame(payload.expect("three-field V1 payload must serialize"));
assert!(matches!(decode_pool_meta_replica(data.clone()), PoolMetaReplica::Valid { .. }));
assert_eq!(ensure_legacy_pool_meta_republish_safe(&data).is_err(), dont_save);
assert!(ensure_legacy_pool_meta_payload_complete(&data).is_ok());
let mut trailing = data;
trailing.push(0xc0);
assert!(ensure_legacy_pool_meta_payload_complete(&trailing).is_err());
}
}
// This guard is only called for already-decoded replicas; malformed
// containers must fail selection rather than becoming repair sources.
let truncated = frame(vec![0x93, 0x01]);
assert!(matches!(
decode_pool_meta_replica(truncated),
PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_)
));
}
#[test] #[test]
fn pool_meta_decode_supports_legacy_payload() { fn pool_meta_decode_supports_legacy_payload() {
let start_time = OffsetDateTime::now_utc(); let start_time = OffsetDateTime::now_utc();
+103
View File
@@ -853,6 +853,8 @@ pub(crate) struct DiskHealthMetricEpoch {
fallback_operation_metrics: StdRwLock<HashMap<&'static str, Arc<DiskOperationMetrics>>>, fallback_operation_metrics: StdRwLock<HashMap<&'static str, Arc<DiskOperationMetrics>>>,
#[cfg(test)] #[cfg(test)]
multipart_cleanup_observer: std::sync::Mutex<Option<std::sync::Weak<ScopedMultipartCleanupCompletions>>>, multipart_cleanup_observer: std::sync::Mutex<Option<std::sync::Weak<ScopedMultipartCleanupCompletions>>>,
#[cfg(test)]
operation_timeout_override: std::sync::Mutex<Option<Duration>>,
/// Caller API operations currently executing through the disk health wrapper. /// Caller API operations currently executing through the disk health wrapper.
api_waiting: AtomicU32, api_waiting: AtomicU32,
/// Operations rejected because the disk was unavailable for the caller. /// Operations rejected because the disk was unavailable for the caller.
@@ -880,6 +882,8 @@ impl Default for DiskHealthMetricEpoch {
fallback_operation_metrics: StdRwLock::new(HashMap::new()), fallback_operation_metrics: StdRwLock::new(HashMap::new()),
#[cfg(test)] #[cfg(test)]
multipart_cleanup_observer: std::sync::Mutex::new(None), multipart_cleanup_observer: std::sync::Mutex::new(None),
#[cfg(test)]
operation_timeout_override: std::sync::Mutex::new(None),
api_waiting: AtomicU32::new(0), api_waiting: AtomicU32::new(0),
total_errors_availability: AtomicU64::new(0), total_errors_availability: AtomicU64::new(0),
total_errors_timeout: AtomicU64::new(0), total_errors_timeout: AtomicU64::new(0),
@@ -1573,6 +1577,20 @@ impl Default for DiskHealthTracker {
} }
} }
#[cfg(test)]
#[derive(Debug)]
pub(crate) struct ScopedDiskOperationTimeout {
metrics: Arc<DiskHealthMetricEpoch>,
previous: Option<Duration>,
}
#[cfg(test)]
impl Drop for ScopedDiskOperationTimeout {
fn drop(&mut self) {
*self.metrics.operation_timeout_override.lock().expect("test timeout lock") = self.previous;
}
}
/// LocalDiskWrapper wraps a DiskStore with health tracking capabilities. /// LocalDiskWrapper wraps a DiskStore with health tracking capabilities.
/// This is similar to Go's xlStorageDiskIDCheck. /// This is similar to Go's xlStorageDiskIDCheck.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -1914,6 +1932,23 @@ impl LocalDiskWrapper {
self.health_check self.health_check
} }
#[cfg(test)]
pub(crate) fn override_operation_timeout_for_test(&self, timeout: Duration) -> ScopedDiskOperationTimeout {
assert!(!timeout.is_zero(), "fault injection needs a positive timeout");
// The metrics epoch follows this drive across clones and reconnects.
// Its scoped override cannot shorten unrelated healthy drives' I/O.
let previous = self
.metrics
.operation_timeout_override
.lock()
.expect("test timeout lock")
.replace(timeout);
ScopedDiskOperationTimeout {
metrics: Arc::clone(&self.metrics),
previous,
}
}
/// Enable health monitoring after disk creation. /// Enable health monitoring after disk creation.
/// Used to defer health checks until after startup format loading completes. /// Used to defer health checks until after startup format loading completes.
pub fn enable_health_check(&self) { pub fn enable_health_check(&self) {
@@ -2638,6 +2673,13 @@ impl LocalDiskWrapper {
} }
return result; return result;
} }
#[cfg(test)]
let timeout_duration = self
.metrics
.operation_timeout_override
.lock()
.expect("test timeout lock")
.map_or(timeout_duration, |injected| timeout_duration.min(injected));
// Execute the operation with timeout // Execute the operation with timeout
let result = tokio::time::timeout(timeout_duration, operation).await; let result = tokio::time::timeout(timeout_duration, operation).await;
@@ -6044,6 +6086,67 @@ mod tests {
assert!(health.offline_duration().is_none()); assert!(health.offline_duration().is_none());
} }
#[tokio::test]
async fn scoped_operation_timeout_is_per_disk_and_preserves_caller_policy() {
let directories = [
tempfile::tempdir().expect("faulted drive"),
tempfile::tempdir().expect("healthy drive"),
];
let mut wrappers = Vec::new();
for directory in &directories {
let endpoint = Endpoint::try_from(directory.path().to_str().expect("UTF-8 drive path")).expect("drive endpoint");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local drive"));
wrappers.push(LocalDiskWrapper::new(disk, false));
}
let reconnected = LocalDiskWrapper::new_with_reconnect_state(
Arc::clone(&wrappers[0].disk),
false,
Some(wrappers[0].health_tracker_epoch_for_reconnect()),
);
let scope = wrappers[0].override_operation_timeout_for_test(Duration::from_secs(1));
tokio::time::pause();
let operation = || async {
tokio::time::sleep(Duration::from_secs(2)).await;
Ok(())
};
let (faulted, healthy) = tokio::join!(
reconnected.track_disk_health_with_op_and_timeout_action(
"read_metadata",
operation,
Duration::from_secs(3),
TimeoutHealthAction::IgnoreFailure,
),
wrappers[1].track_disk_health_with_op_and_timeout_action(
"read_metadata",
operation,
Duration::from_secs(3),
TimeoutHealthAction::IgnoreFailure,
),
);
assert_eq!(faulted.expect_err("only the selected drive must time out"), DiskError::Timeout);
healthy.expect("healthy drive keeps its caller's deadline");
let shorter_started = tokio::time::Instant::now();
let shorter = wrappers[0]
.track_disk_health_with_op_and_timeout_action(
"read_metadata",
operation,
Duration::from_millis(500),
TimeoutHealthAction::IgnoreFailure,
)
.await;
assert_eq!(shorter.expect_err("fault injection cannot extend a caller deadline"), DiskError::Timeout);
assert!(shorter_started.elapsed() < Duration::from_secs(1));
wrappers[0]
.track_disk_health_with_op("read_metadata", operation, Duration::ZERO)
.await
.expect("explicit unbounded physical ownership cannot acquire a test deadline");
drop(scope);
reconnected
.track_disk_health_with_op("read_metadata", operation, Duration::from_secs(3))
.await
.expect("dropping the scope restores reconnected callers' deadlines");
}
#[tokio::test] #[tokio::test]
async fn ignored_timeout_does_not_mark_drive_failure() { async fn ignored_timeout_does_not_mark_drive_failure() {
let dir = tempfile::tempdir().expect("temp dir should be created"); let dir = tempfile::tempdir().expect("temp dir should be created");
+464 -2
View File
@@ -5,6 +5,7 @@ use super::{FdCache, FdCacheEntry, FdKey};
use std::fs::File; use std::fs::File;
use std::os::unix::fs::FileExt; use std::os::unix::fs::FileExt;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool;
fn key(volume: &str, path: &str, direct: bool) -> FdKey { fn key(volume: &str, path: &str, direct: bool) -> FdKey {
FdKey { FdKey {
@@ -52,7 +53,11 @@ async fn uring_fd_cache_exact_invalidation_removes_both_modes_and_preserves_othe
async fn uring_fd_cache_exact_invalidation_bumps_one_generation_and_rejects_old_epoch_inserts() { async fn uring_fd_cache_exact_invalidation_bumps_one_generation_and_rejects_old_epoch_inserts() {
let (_directory, file) = fixture(); let (_directory, file) = fixture();
let cache = FdCache::new(); let cache = FdCache::new();
let entry = Arc::new(FdCacheEntry { file, len: 3 }); let entry = Arc::new(FdCacheEntry {
file,
len: 3,
invalidated: AtomicBool::new(false),
});
let old_generation = cache.generation(); let old_generation = cache.generation();
cache.invalidate_exact("v", "part").await; cache.invalidate_exact("v", "part").await;
let current_generation = cache.generation(); let current_generation = cache.generation();
@@ -125,7 +130,7 @@ async fn uring_fd_cache_volume_and_clear_invalidation_cover_both_modes() {
cache.insert(key("v", "part", direct), Arc::clone(&file)).await; cache.insert(key("v", "part", direct), Arc::clone(&file)).await;
assert!( assert!(
cache.get(&key("v", "part", direct)).await.is_some(), cache.get(&key("v", "part", direct)).await.is_some(),
"new entries must survive the earlier volume predicate" "new entries must survive the earlier volume invalidation"
); );
} }
cache.clear(); cache.clear();
@@ -164,6 +169,7 @@ async fn uring_fd_cache_invalidation_keeps_borrowed_old_inode_alive_and_allows_r
let replacement = Arc::new(FdCacheEntry { let replacement = Arc::new(FdCacheEntry {
file: Arc::new(File::open(directory.path().join("part")).expect("open replacement inode")), file: Arc::new(File::open(directory.path().join("part")).expect("open replacement inode")),
len: 11, len: 11,
invalidated: AtomicBool::new(false),
}); });
for direct in [false, true] { for direct in [false, true] {
let key = key("v", "part", direct); let key = key("v", "part", direct);
@@ -177,3 +183,459 @@ async fn uring_fd_cache_invalidation_keeps_borrowed_old_inode_alive_and_allows_r
assert_eq!(fresh.len, 11); assert_eq!(fresh.len, 11);
} }
} }
#[tokio::test]
async fn fd_cache_tracker_skips_unmatched_invalidations_and_handles_deep_prefixes() {
let (_directory, file) = fixture();
let mut cache = FdCache::new();
// Unmatched invalidations on an empty cache must not spawn the maintenance task.
cache.invalidate_under("v", "missing/prefix");
cache.invalidate_volume("v");
cache.invalidate_exact("v", "missing/prefix/part.1").await;
cache.clear();
assert!(
cache.maintenance_task.get().is_none(),
"empty-cache invalidations must not spawn maintenance"
);
// Populate a normal entry and verify matched vs unmatched filter gates directly,
// and ensure unmatched invalidations do not enqueue pending removals.
let cached_key = key("v", "obj-1/0198a3f0/part.1", false);
cache.insert(cached_key.clone(), Arc::clone(&file)).await;
assert_eq!(cache.tracker.live_entries(), 1);
assert!(cache.tracker.may_match_volume("v"));
assert!(!cache.tracker.may_match_volume("other-volume"));
assert!(cache.tracker.may_match_prefix("v", "obj-1"));
assert!(cache.tracker.may_match_prefix("v", "obj-1/0198a3f0"));
assert!(!cache.tracker.may_match_prefix("v", "obj-2/0198a3f0"));
assert!(cache.tracker.may_match_exact("v", "obj-1/0198a3f0/part.1"));
assert!(!cache.tracker.may_match_exact("v", "obj-1/0198a3f0/xl.meta"));
cache.invalidate_volume("other-volume");
cache.invalidate_under("v", "obj-2/0198a3f0");
cache.invalidate_exact("v", "obj-1/0198a3f0/xl.meta").await;
assert!(
!cache
.tracker
.has_pending_invalidations
.load(std::sync::atomic::Ordering::SeqCst),
"unmatched invalidations must not enqueue maintenance removals"
);
assert!(cache.get(&cached_key).await.is_some());
// Deeply nested path (> FD_CACHE_MAX_INDEXED_PREFIXES slashes) must fall back
// to per-volume unindexed_counts without degrading other volumes.
let other_vol_key = key("v2", "shallow/part.1", false);
cache.insert(other_vol_key.clone(), Arc::clone(&file)).await;
let deep_path = "c1/c2/c3/c4/c5/c6/c7/c8/c9/c10/c11/c12/c13/c14/part.1";
let deep_key = key("v", deep_path, false);
cache.insert(deep_key.clone(), Arc::clone(&file)).await;
assert_eq!(
cache
.tracker
.unindexed_counts
.iter()
.map(|c| c.load(std::sync::atomic::Ordering::Acquire))
.sum::<u32>(),
1
);
assert!(
!cache.tracker.may_match_prefix("v2", "unmatched/prefix"),
"deep key in volume v must not disable prefix filtering in volume v2"
);
cache.invalidate_under("v", "c1/c2/c3/c4/c5/c6/c7/c8/c9/c10/c11/c12/c13");
assert!(cache.get(&deep_key).await.is_none());
cache.invalidate_volume("v2");
assert!(cache.get(&other_vol_key).await.is_none());
// Replacing an invalidated key before maintenance drains pending_invalidations
// must release the old descriptor immediately and keep the replacement live
// even when a strong reference to the old CachedFdEntry shell survives until
// maintenance runs its Arc::ptr_eq check.
let old_file = Arc::new(tempfile::tempfile().expect("create replaced descriptor"));
let old_weak = Arc::downgrade(&old_file);
cache.insert(cached_key.clone(), old_file).await;
let held_old_shell = cache.cache.get(&cached_key).expect("cached shell must exist");
cache.invalidate_under("v", "obj-1/0198a3f0");
assert!(cache.get(&cached_key).await.is_none());
cache.insert(cached_key.clone(), Arc::clone(&file)).await;
assert!(
old_weak.upgrade().is_none(),
"replacing an entry must retire and drop the previous descriptor immediately"
);
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while cache
.tracker
.has_pending_invalidations
.load(std::sync::atomic::Ordering::SeqCst)
{
tokio::task::yield_now().await;
}
})
.await
.expect("maintenance should drain pending invalidations");
drop(held_old_shell);
assert!(
cache.get(&cached_key).await.is_some(),
"stale pending invalidation must not evict a newer replacement"
);
assert_eq!(
cache
.tracker
.unindexed_counts
.iter()
.map(|c| c.load(std::sync::atomic::Ordering::Acquire))
.sum::<u32>(),
0
);
// Dropping a cache while a live non-invalidated entry is present (even if a
// cloned Moka handle outlives FdCache and the maintenance task is aborted)
// must retire the descriptor and release FdCacheTracker without cycles.
let live_drop_file = Arc::new(tempfile::tempfile().expect("create live drop descriptor"));
let live_drop_weak = Arc::downgrade(&live_drop_file);
let shutdown_file = Arc::new(tempfile::tempfile().expect("create shutdown descriptor"));
let shutdown_weak = Arc::downgrade(&shutdown_file);
let tracker_weak = Arc::downgrade(&cache.tracker);
let cloned_moka = cache.cache.clone();
cache.insert(key("v", "live-on-drop/part.1", false), live_drop_file).await;
cache.insert(key("v", "shutdown/part.1", false), shutdown_file).await;
cache.invalidate_under("v", "shutdown");
if let Some(handle) = cache.maintenance_task.take() {
handle.abort();
let _ = handle.await;
}
drop(cache);
assert!(
live_drop_weak.upgrade().is_none(),
"Drop for FdCache must retire live descriptors even if a cloned Moka handle survives"
);
assert!(
shutdown_weak.upgrade().is_none(),
"dropping cache when maintenance is aborted must still release descriptors"
);
assert!(
tracker_weak.upgrade().is_none(),
"dropping cache must release FdCacheTracker without reference cycles"
);
drop(cloned_moka);
// Short-TTL expired entries (including mixed expired and fresh entries) must
// be retired by invalidate_under and clear() even though Moka's iterator skips
// expired entries.
let ttl_cache = FdCache::with_ttl(std::time::Duration::from_millis(15));
let expired_file = Arc::new(tempfile::tempfile().expect("create short-ttl descriptor"));
let expired_weak = Arc::downgrade(&expired_file);
ttl_cache.insert(key("v", "ttl-obj/part.1", false), expired_file).await;
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
let fresh_file = Arc::new(tempfile::tempfile().expect("create fresh descriptor"));
let fresh_weak = Arc::downgrade(&fresh_file);
ttl_cache.insert(key("v", "ttl-obj/part.2", false), fresh_file).await;
ttl_cache.invalidate_under("v", "ttl-obj");
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while expired_weak.upgrade().is_some() || fresh_weak.upgrade().is_some() {
tokio::task::yield_now().await;
}
})
.await
.expect("invalidate_under must retire both expired and fresh matching descriptors");
assert_eq!(ttl_cache.tracker.live_entries(), 0);
let clear_expired_file = Arc::new(tempfile::tempfile().expect("create clear short-ttl descriptor"));
let clear_expired_weak = Arc::downgrade(&clear_expired_file);
ttl_cache
.insert(key("v", "ttl-clear/part.1", false), clear_expired_file)
.await;
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
let clear_live_file = Arc::new(tempfile::tempfile().expect("create clear live descriptor"));
let clear_live_weak = Arc::downgrade(&clear_live_file);
ttl_cache.insert(key("v", "ttl-clear/part.2", false), clear_live_file).await;
ttl_cache.clear();
assert!(
clear_expired_weak.upgrade().is_none() && clear_live_weak.upgrade().is_none(),
"clear() must synchronously retire both TTL-expired and live descriptors"
);
assert_eq!(ttl_cache.tracker.live_entries(), 0);
}
#[tokio::test]
async fn fd_cache_lockstep_oracle_equivalence_and_counter_conservation() {
use std::collections::HashMap;
use std::sync::atomic::Ordering;
let (_directory, file) = fixture();
let cache = FdCache::with_ttl(std::time::Duration::from_secs(3600));
let mut oracle: HashMap<FdKey, Arc<FdCacheEntry>> = HashMap::new();
let volumes = ["vol-a", "vol-b", "vol-c", ".rustfs.sys/multipart"];
let paths = [
"a/b",
"a/b/part.1",
"a/b/dir/part.2",
"a/bc",
"a/bc/part.1",
"prefix/obj-0001.bin/0198a3f0/part.1",
"prefix/obj-0002.bin/0198a3f0/part.1",
"d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/d11/d12/d13/part.1",
];
let prefixes = [
"a",
"a/",
"a/b",
"a/b/",
"a/b/dir",
"a/bc",
"prefix",
"prefix/obj-0001.bin",
"prefix/obj-0001.bin/0198a3f0",
"d1/d2/d3/d4/d5/c_missing",
"d1/d2/d3/d4/d5/d6/d7/d8/d9/d10/d11/d12/d13",
"nonexistent/prefix",
];
let mut candidate_keys = Vec::new();
for v in volumes {
for p in paths {
for direct in [false, true] {
candidate_keys.push(key(v, p, direct));
}
}
}
let mut rng = 0x9e37_79b9_7f4a_7c15u64;
let mut next_u64 = || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
for step in 0..1000 {
let op = next_u64() % 100;
if op < 45 {
// Insert or replace (either fresh or stale generation)
let k = candidate_keys[(next_u64() as usize) % candidate_keys.len()].clone();
let entry = Arc::new(FdCacheEntry {
file: Arc::clone(&file),
len: step,
invalidated: AtomicBool::new(false),
});
let cur_gen = cache.generation();
let stale = (next_u64() % 5) == 0 && cur_gen > 0;
let gen_to_use = if stale { cur_gen - 1 } else { cur_gen };
cache.insert_if_fresh(k.clone(), Arc::clone(&entry), gen_to_use).await;
if !stale {
oracle.insert(k, entry);
}
} else if op < 65 {
// Exact invalidation
let v = volumes[(next_u64() as usize) % volumes.len()];
let p = paths[(next_u64() as usize) % paths.len()];
cache.invalidate_exact(v, p).await;
oracle.remove(&key(v, p, false));
oracle.remove(&key(v, p, true));
} else if op < 88 {
// Prefix invalidation
let v = volumes[(next_u64() as usize) % volumes.len()];
let raw_prefix = prefixes[(next_u64() as usize) % prefixes.len()];
cache.invalidate_under(v, raw_prefix);
let trimmed = raw_prefix.trim_end_matches('/');
oracle.retain(|k, _| {
!(k.volume == v && (k.path == trimmed || k.path.strip_prefix(trimmed).is_some_and(|r| r.starts_with('/'))))
});
} else if op < 97 {
// Volume invalidation
let v = volumes[(next_u64() as usize) % volumes.len()];
cache.invalidate_volume(v);
oracle.retain(|k, _| k.volume != v);
} else {
// Full clear
cache.clear();
oracle.clear();
}
// Verify lockstep equivalence against the reference oracle after every operation.
for k in &candidate_keys {
let got = cache.get(k).await;
let expected = oracle.get(k);
match (got, expected) {
(Some(g), Some(e)) => assert!(
Arc::ptr_eq(&g, e),
"step {step}: pointer mismatch for key ({}, {}, {})",
k.volume,
k.path,
k.direct
),
(None, None) => {}
(g, e) => panic!(
"step {step}: oracle mismatch for key ({}, {}, {}): got={}, expected={}",
k.volume,
k.path,
k.direct,
g.is_some(),
e.is_some()
),
}
}
}
// Clear and wait for maintenance to drain all entries, then verify exact counter conservation.
cache.clear();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while cache.tracker.live_entries() != 0 || cache.tracker.has_pending_invalidations.load(Ordering::SeqCst) {
tokio::task::yield_now().await;
}
})
.await
.expect("clear must drain all live entries and pending invalidations");
assert_eq!(cache.tracker.live_entries(), 0);
assert!(cache.tracker.unindexed_counts.iter().all(|c| c.load(Ordering::SeqCst) == 0));
assert!(cache.tracker.volume_counts.iter().all(|c| c.load(Ordering::SeqCst) == 0));
assert!(cache.tracker.exact_counts.iter().all(|c| c.load(Ordering::SeqCst) == 0));
assert!(cache.tracker.prefix_counts.iter().all(|c| c.load(Ordering::SeqCst) == 0));
assert!(cache.tracker.prefix_shards.iter().all(|s| s.lock().is_empty()));
assert!(cache.tracker.entry_shards.iter().all(|s| s.lock().is_empty()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn fd_cache_concurrent_insert_and_invalidation_stress() {
use std::sync::atomic::{AtomicU64, Ordering};
let (_directory, file) = fixture();
let cache = Arc::new(FdCache::new());
// Deterministic Dekker-partner interleaving check: pause `CachedFdEntry::new`
// at `entry_shards[shard].lock()` after it has passed the initial generation
// check and incremented `volume_counts`, bump generation via an unmatched
// invalidation, and verify the post-`new()` recheck refuses publication and
// preserves the existing entry.
let seam_key = key("v-seam", "obj-1/part.1", false);
cache.insert(seam_key.clone(), Arc::clone(&file)).await;
let initial_entry = cache.get(&seam_key).await.expect("initial entry must exist");
let (vol_hash, vol_slot) = super::fd_cache_volume_hash_and_slot("v-seam");
let entry_shard = (vol_hash as usize) & (super::FD_CACHE_PREFIX_SHARDS - 1);
let stale_gen = cache.generation();
let stale_entry = Arc::new(FdCacheEntry {
file: Arc::clone(&file),
len: 999_999,
invalidated: AtomicBool::new(false),
});
let blocked_insert = {
let hold = cache.tracker.entry_shards[entry_shard].lock();
let cache_clone = Arc::clone(&cache);
let seam_key_clone = seam_key.clone();
let task = tokio::spawn(async move {
cache_clone.insert_if_fresh(seam_key_clone, stale_entry, stale_gen).await;
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while cache.tracker.volume_counts[usize::from(vol_slot)].load(Ordering::SeqCst) < 2 {
assert!(
std::time::Instant::now() < deadline,
"insert never reached CachedFdEntry::new with the previous entry still live"
);
std::thread::yield_now();
}
cache.invalidate_under("v-seam", "unmatched-prefix");
drop(hold);
task
};
blocked_insert.await.expect("blocked insert task should complete");
let preserved = cache
.get(&seam_key)
.await
.expect("refused stale insert must not retire a valid previous entry");
assert!(Arc::ptr_eq(&preserved, &initial_entry));
cache.invalidate_volume("v-seam");
let floor_v1 = Arc::new(AtomicU64::new(0));
let floor_v2 = Arc::new(AtomicU64::new(0));
let mut handles = Vec::new();
for t in 0..8usize {
let cache = Arc::clone(&cache);
let file = Arc::clone(&file);
let floor_v1 = Arc::clone(&floor_v1);
let floor_v2 = Arc::clone(&floor_v2);
handles.push(tokio::spawn(async move {
for i in 0..250u64 {
let is_v1 = (t + i as usize).is_multiple_of(2);
let vol = if is_v1 { "v1" } else { "v2" };
let path = format!("dir-{}/obj-{}/part.1", t % 4, i % 16);
let k = key(vol, &path, i.is_multiple_of(2));
let g = cache.generation();
let entry = Arc::new(FdCacheEntry {
file: Arc::clone(&file),
len: g,
invalidated: AtomicBool::new(false),
});
cache.insert_if_fresh(k.clone(), entry, g).await;
let min_gen = if is_v1 {
floor_v1.load(Ordering::SeqCst)
} else {
floor_v2.load(Ordering::SeqCst)
};
if let Some(hit) = cache.get(&k).await {
assert!(
hit.len >= min_gen,
"stale descriptor served after volume invalidation: opened_gen={}, floor_gen={min_gen}",
hit.len
);
}
}
}));
}
for t in 0..4usize {
let cache = Arc::clone(&cache);
let floor_v1 = Arc::clone(&floor_v1);
let floor_v2 = Arc::clone(&floor_v2);
handles.push(tokio::spawn(async move {
for i in 0..250u64 {
let is_v1 = (t + i as usize).is_multiple_of(2);
let vol = if is_v1 { "v1" } else { "v2" };
match i % 4 {
0 => cache.invalidate_under(vol, &format!("dir-{}/obj-{}", t % 4, i % 16)),
1 => {
let p = format!("dir-{}/obj-{}/part.1", t % 4, i % 16);
cache.invalidate_exact(vol, &p).await;
}
2 => {
let before = cache.generation();
cache.invalidate_volume(vol);
let floor = before + 1;
if is_v1 {
floor_v1.fetch_max(floor, Ordering::SeqCst);
} else {
floor_v2.fetch_max(floor, Ordering::SeqCst);
}
}
_ => cache.invalidate_under("uncached-vol", "uncached/prefix"),
}
tokio::task::yield_now().await;
}
}));
}
for h in handles {
h.await.expect("worker task should succeed");
}
cache.clear();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while cache.tracker.live_entries() != 0 || cache.tracker.has_pending_invalidations.load(Ordering::SeqCst) {
tokio::task::yield_now().await;
}
})
.await
.expect("clear after concurrent stress must drain all live entries");
assert_eq!(cache.tracker.live_entries(), 0);
assert!(cache.tracker.unindexed_counts.iter().all(|c| c.load(Ordering::SeqCst) == 0));
assert!(cache.tracker.volume_counts.iter().all(|c| c.load(Ordering::SeqCst) == 0));
assert!(cache.tracker.exact_counts.iter().all(|c| c.load(Ordering::SeqCst) == 0));
assert!(cache.tracker.prefix_counts.iter().all(|c| c.load(Ordering::SeqCst) == 0));
assert!(cache.tracker.prefix_shards.iter().all(|s| s.lock().is_empty()));
assert!(cache.tracker.entry_shards.iter().all(|s| s.lock().is_empty()));
}
+20 -31
View File
@@ -148,58 +148,47 @@ pub async fn make_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
native_io(move || create_dir_all_std(&path)).await native_io(move || create_dir_all_std(&path)).await
} }
fn is_dir_error(path: &Path, e: &io::Error) -> bool { fn is_dir_error(e: &io::Error) -> bool {
e.raw_os_error() == Some(libc::EISDIR) e.raw_os_error() == Some(libc::EISDIR)
|| e.kind() == io::ErrorKind::IsADirectory || e.kind() == io::ErrorKind::IsADirectory
// macOS: remove_file on a directory returns EPERM // macOS: remove_file on a directory returns EPERM
|| (cfg!(target_os = "macos") && e.raw_os_error() == Some(libc::EPERM)) || (cfg!(target_os = "macos") && e.raw_os_error() == Some(libc::EPERM))
|| is_windows_dir_access_denied(path, e)
}
/// Windows reports `remove_file` on a directory (or directory symlink) as
/// ERROR_ACCESS_DENIED, which is indistinguishable from a real permission
/// failure without inspecting the entry itself.
#[cfg(windows)]
fn is_windows_dir_access_denied(path: &Path, e: &io::Error) -> bool {
use std::os::windows::fs::FileTypeExt;
e.kind() == io::ErrorKind::PermissionDenied
&& std::fs::symlink_metadata(path).is_ok_and(|metadata| {
let file_type = metadata.file_type();
file_type.is_dir() || file_type.is_symlink_dir()
})
}
#[cfg(not(windows))]
fn is_windows_dir_access_denied(_path: &Path, _e: &io::Error) -> bool {
false
} }
#[tracing::instrument(level = "debug", skip_all)] #[tracing::instrument(level = "debug", skip_all)]
pub async fn remove(path: impl AsRef<Path>) -> io::Result<()> { pub async fn remove(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref().to_path_buf(); // Try remove_file first; fall back to remove_dir if it's a directory
native_io(move || remove_std(path)).await let file_path = path.as_ref().to_path_buf();
match native_io(move || std::fs::remove_file(file_path)).await {
Ok(()) => Ok(()),
Err(e) if is_dir_error(&e) => {
let path = path.as_ref().to_path_buf();
native_io(move || std::fs::remove_dir(path)).await
}
Err(e) => Err(e),
}
} }
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")] #[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
pub async fn remove_all(path: impl AsRef<Path>) -> io::Result<()> { pub async fn remove_all(path: impl AsRef<Path>) -> io::Result<()> {
// Try remove_file first; fall back to remove_dir_all if it's a directory // Try remove_file first; fall back to remove_dir_all if it's a directory
let path = path.as_ref().to_path_buf(); let file_path = path.as_ref().to_path_buf();
native_io(move || match std::fs::remove_file(&path) { match native_io(move || std::fs::remove_file(file_path)).await {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(e) if is_dir_error(&path, &e) => std::fs::remove_dir_all(&path), Err(e) if is_dir_error(&e) => {
let path = path.as_ref().to_path_buf();
native_io(move || std::fs::remove_dir_all(path)).await
}
Err(e) => Err(e), Err(e) => Err(e),
}) }
.await
} }
#[tracing::instrument(level = "debug", skip_all)] #[tracing::instrument(level = "debug", skip_all)]
pub fn remove_std(path: impl AsRef<Path>) -> io::Result<()> { pub fn remove_std(path: impl AsRef<Path>) -> io::Result<()> {
// Try remove_file first; fall back to remove_dir if it's a directory // Try remove_file first; fall back to remove_dir if it's a directory
let path = path.as_ref(); match std::fs::remove_file(path.as_ref()) {
match std::fs::remove_file(path) {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(e) if is_dir_error(path, &e) => std::fs::remove_dir(path), Err(e) if is_dir_error(&e) => std::fs::remove_dir(path.as_ref()),
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
File diff suppressed because it is too large Load Diff
+4 -10
View File
@@ -198,12 +198,7 @@ pub(super) fn create_local_inline_rollback_backup(
return Err(err); return Err(err);
} }
} else if sync && !old_metadata.is_empty() { } else if sync && !old_metadata.is_empty() {
// Windows only flushes a handle opened for writing. std::fs::File::open(&backup_path)?.sync_data()?;
#[cfg(windows)]
let backup = std::fs::OpenOptions::new().write(true).open(&backup_path)?;
#[cfg(not(windows))]
let backup = std::fs::File::open(&backup_path)?;
backup.sync_data()?;
} }
Ok(backup_path) Ok(backup_path)
} }
@@ -1599,13 +1594,12 @@ impl LocalDisk {
); );
} }
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() { if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() {
#[cfg(any(target_os = "linux", windows))] #[cfg(target_os = "linux")]
let payload_guard = if multipart_publication.is_some() { let payload_guard = if multipart_publication.is_some() {
Some( Some(
os::multipart_payload_commit_guard( os::multipart_payload_commit_guard(
&commit.directory_guard, &commit.directory_guard,
src_data_path.parent().ok_or(DiskError::InvalidPath)?, src_data_path.parent().ok_or(DiskError::InvalidPath)?,
commit.publication_root,
commit.mutation_lease.clone(), commit.mutation_lease.clone(),
) )
.await .await
@@ -1614,9 +1608,9 @@ impl LocalDisk {
} else { } else {
None None
}; };
#[cfg(any(target_os = "linux", windows))] #[cfg(target_os = "linux")]
let directory_guard = payload_guard.as_ref().unwrap_or(&commit.directory_guard); let directory_guard = payload_guard.as_ref().unwrap_or(&commit.directory_guard);
#[cfg(not(any(target_os = "linux", windows)))] #[cfg(not(target_os = "linux"))]
let directory_guard = &commit.directory_guard; let directory_guard = &commit.directory_guard;
if let Err(err) = os::rename_all_with_commit_guard( if let Err(err) = os::rename_all_with_commit_guard(
src_data_path, src_data_path,
@@ -1002,21 +1002,17 @@ pub(super) fn inspection_with_markers(
type PreparedDeleteReceiptEntry = (DeleteRecoveryJournal, bool); type PreparedDeleteReceiptEntry = (DeleteRecoveryJournal, bool);
static PREPARED_DELETE_RECEIPTS: std::sync::LazyLock<parking_lot::Mutex<HashMap<PathBuf, PreparedDeleteReceiptEntry>>> = const PREPARED_DELETE_RECEIPTS_PER_SHARD: usize = 64;
std::sync::LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
fn put_prepared_delete_receipt(rollback: PathBuf, entry: PreparedDeleteReceiptEntry) { static PREPARED_DELETE_RECEIPTS: std::sync::LazyLock<os::ShardedPathCache<PreparedDeleteReceiptEntry>> =
let mut map = PREPARED_DELETE_RECEIPTS.lock(); std::sync::LazyLock::new(|| os::ShardedPathCache::new(PREPARED_DELETE_RECEIPTS_PER_SHARD));
if map.len() >= 4096
&& let Some(evict_key) = map.keys().next().cloned() fn put_prepared_delete_receipt(rollback: impl AsRef<Path>, entry: PreparedDeleteReceiptEntry) {
{ PREPARED_DELETE_RECEIPTS.insert(rollback.as_ref(), entry);
map.remove(&evict_key);
}
map.insert(rollback, entry);
} }
fn take_prepared_delete_receipt(rollback: &Path) -> Option<PreparedDeleteReceiptEntry> { fn take_prepared_delete_receipt(rollback: &Path) -> Option<PreparedDeleteReceiptEntry> {
PREPARED_DELETE_RECEIPTS.lock().remove(rollback) PREPARED_DELETE_RECEIPTS.remove(rollback)
} }
impl LocalDisk { impl LocalDisk {
@@ -1291,9 +1287,15 @@ impl LocalDisk {
let volume = volume.to_owned(); let volume = volume.to_owned();
let object = object.to_owned(); let object = object.to_owned();
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await; let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await;
os::run_blocking_namespace_operation(lease, move || Ok(inspection_with_markers(&root, &volume, &object, transaction))) // Journal inspections ride the durable-mutation lane (the fused delete
.await // prepare already does): a bulk delete publishes one transaction per
.map_err(to_file_error)? // key, and the one-worker foreground namespace lane would serialize
// those per-drive publishes behind every other mutation.
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
Ok(inspection_with_markers(&root, &volume, &object, transaction))
})
.await
.map_err(to_file_error)?
} }
async fn activate_delete_preparation( async fn activate_delete_preparation(
@@ -1689,7 +1691,10 @@ impl LocalDisk {
let plan_root = root.clone(); let plan_root = root.clone();
let missing = observed.current_digest.is_none(); let missing = observed.current_digest.is_none();
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner.clone()).await; let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner.clone()).await;
let plan = os::run_blocking_namespace_operation(lease, move || { // Same durable-mutation lane as the journal inspection above: this
// plan closure runs inside every settle on the bulk-delete publish
// path.
let plan = os::run_blocking_namespace_operation_with_sync(lease, true, move || {
Ok((|| { Ok((|| {
let before = let before =
regular_metadata_bytes(&plan_root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP))? regular_metadata_bytes(&plan_root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP))?
@@ -270,21 +270,17 @@ struct ActiveMultipartSourceEntry {
parts: std::collections::HashMap<usize, (FileStamp, rustfs_filemeta::ObjectPartInfo)>, parts: std::collections::HashMap<usize, (FileStamp, rustfs_filemeta::ObjectPartInfo)>,
} }
static ACTIVE_MULTIPART_SOURCE_HEADS: std::sync::LazyLock< const ACTIVE_MULTIPART_SOURCE_HEADS_PER_SHARD: usize = 64;
parking_lot::Mutex<std::collections::HashMap<PathBuf, ActiveMultipartSourceEntry>>,
> = std::sync::LazyLock::new(|| parking_lot::Mutex::new(std::collections::HashMap::new()));
pub(super) fn put_active_multipart_source_head(source: PathBuf, raw: Bytes, fi: FileInfo) { static ACTIVE_MULTIPART_SOURCE_HEADS: std::sync::LazyLock<os::ShardedPathCache<ActiveMultipartSourceEntry>> =
std::sync::LazyLock::new(|| os::ShardedPathCache::new(ACTIVE_MULTIPART_SOURCE_HEADS_PER_SHARD));
pub(super) fn put_active_multipart_source_head(source: impl AsRef<Path>, raw: Bytes, fi: FileInfo) {
let source = source.as_ref();
let Some(head_stamp) = FileStamp::from_path(&source.join(STORAGE_FORMAT_FILE)) else { let Some(head_stamp) = FileStamp::from_path(&source.join(STORAGE_FORMAT_FILE)) else {
return; return;
}; };
let mut map = ACTIVE_MULTIPART_SOURCE_HEADS.lock(); ACTIVE_MULTIPART_SOURCE_HEADS.insert(
if map.len() >= 4096
&& let Some(evict_key) = map.keys().next().cloned()
{
map.remove(&evict_key);
}
map.insert(
source, source,
ActiveMultipartSourceEntry { ActiveMultipartSourceEntry {
raw, raw,
@@ -304,56 +300,50 @@ fn stamp_matches_active_disk(source: &Path, expected_head_stamp: FileStamp) -> b
} }
pub(super) fn get_active_multipart_source_head(source: &Path) -> Option<(Bytes, FileInfo)> { pub(super) fn get_active_multipart_source_head(source: &Path) -> Option<(Bytes, FileInfo)> {
let (raw, fi, head_stamp) = { let (raw, fi, head_stamp) =
let map = ACTIVE_MULTIPART_SOURCE_HEADS.lock(); ACTIVE_MULTIPART_SOURCE_HEADS.with_get(source, |entry| (entry.raw.clone(), entry.fi.clone(), entry.head_stamp))?;
let entry = map.get(source)?;
(entry.raw.clone(), entry.fi.clone(), entry.head_stamp)
};
if !stamp_matches_active_disk(source, head_stamp) { if !stamp_matches_active_disk(source, head_stamp) {
ACTIVE_MULTIPART_SOURCE_HEADS.lock().remove(source); ACTIVE_MULTIPART_SOURCE_HEADS.remove(source);
return None; return None;
} }
Some((raw, fi)) Some((raw, fi))
} }
pub(super) fn has_active_multipart_source_head(source: &Path) -> bool { pub(super) fn has_active_multipart_source_head(source: &Path) -> bool {
let Some(head_stamp) = ACTIVE_MULTIPART_SOURCE_HEADS.lock().get(source).map(|entry| entry.head_stamp) else { let Some(head_stamp) = ACTIVE_MULTIPART_SOURCE_HEADS.with_get(source, |entry| entry.head_stamp) else {
return false; return false;
}; };
if !stamp_matches_active_disk(source, head_stamp) { if !stamp_matches_active_disk(source, head_stamp) {
ACTIVE_MULTIPART_SOURCE_HEADS.lock().remove(source); ACTIVE_MULTIPART_SOURCE_HEADS.remove(source);
return false; return false;
} }
true true
} }
pub(super) fn has_clean_active_multipart_source(source: &Path) -> bool { pub(super) fn has_clean_active_multipart_source(source: &Path) -> bool {
let Some((head_stamp, clean)) = ACTIVE_MULTIPART_SOURCE_HEADS let Some((head_stamp, clean)) =
.lock() ACTIVE_MULTIPART_SOURCE_HEADS.with_get(source, |entry| (entry.head_stamp, entry.unsettled_parts.is_empty()))
.get(source)
.map(|entry| (entry.head_stamp, entry.unsettled_parts.is_empty()))
else { else {
return false; return false;
}; };
if !stamp_matches_active_disk(source, head_stamp) { if !stamp_matches_active_disk(source, head_stamp) {
ACTIVE_MULTIPART_SOURCE_HEADS.lock().remove(source); ACTIVE_MULTIPART_SOURCE_HEADS.remove(source);
return false; return false;
} }
clean clean
} }
pub(super) fn begin_active_multipart_part_transaction(source: &Path, part_number: usize) { pub(super) fn begin_active_multipart_part_transaction(source: &Path, part_number: usize) {
if let Some(entry) = ACTIVE_MULTIPART_SOURCE_HEADS.lock().get_mut(source) { ACTIVE_MULTIPART_SOURCE_HEADS.with_get_mut(source, |entry| {
entry.unsettled_parts.insert(part_number); entry.unsettled_parts.insert(part_number);
entry.parts.remove(&part_number); entry.parts.remove(&part_number);
} });
} }
pub(super) fn record_active_multipart_part_committed(source: &Path, part: rustfs_filemeta::ObjectPartInfo) { pub(super) fn record_active_multipart_part_committed(source: &Path, part: rustfs_filemeta::ObjectPartInfo) {
let Some(data_dir) = ACTIVE_MULTIPART_SOURCE_HEADS let Some(data_dir) = ACTIVE_MULTIPART_SOURCE_HEADS
.lock() .with_get(source, |entry| entry.fi.data_dir)
.get(source) .flatten()
.and_then(|entry| entry.fi.data_dir)
.filter(|dir| !dir.is_nil()) .filter(|dir| !dir.is_nil())
else { else {
return; return;
@@ -362,9 +352,9 @@ pub(super) fn record_active_multipart_part_committed(source: &Path, part: rustfs
let Some(part_stamp) = FileStamp::from_path(&part_meta_path) else { let Some(part_stamp) = FileStamp::from_path(&part_meta_path) else {
return; return;
}; };
if let Some(entry) = ACTIVE_MULTIPART_SOURCE_HEADS.lock().get_mut(source) { ACTIVE_MULTIPART_SOURCE_HEADS.with_get_mut(source, |entry| {
entry.parts.insert(part.number, (part_stamp, part)); entry.parts.insert(part.number, (part_stamp, part));
} });
} }
pub(super) fn finish_active_multipart_part_transaction( pub(super) fn finish_active_multipart_part_transaction(
@@ -373,34 +363,29 @@ pub(super) fn finish_active_multipart_part_transaction(
committed: bool, committed: bool,
retired_existing_txn: bool, retired_existing_txn: bool,
) { ) {
let mut map = ACTIVE_MULTIPART_SOURCE_HEADS.lock();
let Some(part_number) = part_number.filter(|_| committed) else { let Some(part_number) = part_number.filter(|_| committed) else {
map.remove(source); ACTIVE_MULTIPART_SOURCE_HEADS.remove(source);
return; return;
}; };
if !retired_existing_txn { if !retired_existing_txn {
return; return;
} }
if let Some(entry) = map.get_mut(source) ACTIVE_MULTIPART_SOURCE_HEADS.remove_if_mut(source, |entry| !entry.unsettled_parts.remove(&part_number));
&& !entry.unsettled_parts.remove(&part_number)
{
map.remove(source);
}
} }
pub(super) fn get_active_multipart_source_part(source: &Path, part_number: usize) -> Option<rustfs_filemeta::ObjectPartInfo> { pub(super) fn get_active_multipart_source_part(source: &Path, part_number: usize) -> Option<rustfs_filemeta::ObjectPartInfo> {
let (head_stamp, data_dir, expected_stamp, part) = { let (head_stamp, data_dir, expected_stamp, part) = ACTIVE_MULTIPART_SOURCE_HEADS
let map = ACTIVE_MULTIPART_SOURCE_HEADS.lock(); .with_get(source, |entry| {
let entry = map.get(source)?; if !entry.unsettled_parts.is_empty() {
if !entry.unsettled_parts.is_empty() { return None;
return None; }
} let data_dir = entry.fi.data_dir.filter(|dir| !dir.is_nil())?;
let data_dir = entry.fi.data_dir.filter(|dir| !dir.is_nil())?; let (expected_stamp, part) = entry.parts.get(&part_number)?;
let (expected_stamp, part) = entry.parts.get(&part_number)?; Some((entry.head_stamp, data_dir, *expected_stamp, part.clone()))
(entry.head_stamp, data_dir, *expected_stamp, part.clone()) })
}; .flatten()?;
if !stamp_matches_active_disk(source, head_stamp) { if !stamp_matches_active_disk(source, head_stamp) {
ACTIVE_MULTIPART_SOURCE_HEADS.lock().remove(source); ACTIVE_MULTIPART_SOURCE_HEADS.remove(source);
return None; return None;
} }
let part_meta_path = source.join(data_dir.to_string()).join(format!("part.{part_number}.meta")); let part_meta_path = source.join(data_dir.to_string()).join(format!("part.{part_number}.meta"));
@@ -411,11 +396,11 @@ pub(super) fn get_active_multipart_source_part(source: &Path, part_number: usize
} }
pub(super) fn invalidate_active_multipart_source_head(source: &Path) { pub(super) fn invalidate_active_multipart_source_head(source: &Path) {
ACTIVE_MULTIPART_SOURCE_HEADS.lock().remove(source); ACTIVE_MULTIPART_SOURCE_HEADS.remove(source);
} }
pub(super) fn invalidate_active_multipart_sources_under(prefix: &Path) { pub(super) fn invalidate_active_multipart_sources_under(prefix: &Path) {
ACTIVE_MULTIPART_SOURCE_HEADS.lock().retain(|key, _| !key.starts_with(prefix)); ACTIVE_MULTIPART_SOURCE_HEADS.retain_outside_prefix(prefix);
} }
/// Canonical source mutations are admitted under SourceRW at the syscall, /// Canonical source mutations are admitted under SourceRW at the syscall,
@@ -1527,7 +1512,7 @@ impl LocalDisk {
} }
Err(error) => return Err(error), Err(error) => return Err(error),
}; };
let directory_pin = os::pin_multipart_source_directory(&directory, base, root)?; let _directory = os::pin_multipart_source_directory(&directory, base, root)?;
if !parts.is_empty() { if !parts.is_empty() {
let anchor_path = directory.join(RETIREMENT_ANCHOR); let anchor_path = directory.join(RETIREMENT_ANCHOR);
let bytes = os::read_multipart_source_metadata(&anchor_path, base, root)?; let bytes = os::read_multipart_source_metadata(&anchor_path, base, root)?;
@@ -1560,13 +1545,7 @@ impl LocalDisk {
} }
// An anchor-less empty DD is only rmdir'ed. A new child makes // An anchor-less empty DD is only rmdir'ed. A new child makes
// this fail closed; no recursive removal can consume its bytes. // this fail closed; no recursive removal can consume its bytes.
// A Windows pin refuses DELETE sharing, so the leaf pin is
// released first; the object pin still holds the parent.
#[cfg(windows)]
drop(directory_pin);
std::fs::remove_dir(&directory)?; std::fs::remove_dir(&directory)?;
#[cfg(not(windows))]
drop(directory_pin);
if sync { if sync {
os::fsync_dir_std(&object_path)?; os::fsync_dir_std(&object_path)?;
} }
@@ -1942,6 +1921,7 @@ impl NativeMultipartPublication {
*self.validated_prepared.lock() = Some(validated); *self.validated_prepared.lock() = Some(validated);
Ok(()) Ok(())
} }
#[cfg(target_os = "linux")]
pub(super) fn prepare_and_install_head_on_thread( pub(super) fn prepare_and_install_head_on_thread(
&self, &self,
new_metadata: &[u8], new_metadata: &[u8],
File diff suppressed because it is too large Load Diff
@@ -1899,10 +1899,6 @@ mod tests {
&ObjectOptions { &ObjectOptions {
version_id: Some(version_id.to_string()), version_id: Some(version_id.to_string()),
mod_time: earlier(version.mod_time), mod_time: earlier(version.mod_time),
// Without the object lock the rename waits for every
// drive, so no early-ACK tail is still writing the copies
// removed below.
no_lock: true,
..versioned ..versioned
}, },
)) ))
+106 -3
View File
@@ -318,6 +318,21 @@ fn is_rebalance_transient_lock_error(err: &rustfs_lock::LockError) -> bool {
} }
} }
fn is_rebalance_metadata_admission_retryable(err: &Error) -> bool {
match rebalance_error_source(err) {
Error::Lock(err) => is_rebalance_transient_lock_error(err),
// No metadata was written when lock admission failed. Each attempt
// must acquire its quorum again before loading or saving this run.
Error::NamespaceLockQuorumUnavailable {
mode: "write",
bucket,
object,
..
} => bucket == crate::disk::RUSTFS_META_BUCKET && object == REBAL_META_NAME,
_ => false,
}
}
fn is_rebalance_transient_io_error(err: &std::io::Error) -> bool { fn is_rebalance_transient_io_error(err: &std::io::Error) -> bool {
if err.kind() == std::io::ErrorKind::TimedOut { if err.kind() == std::io::ErrorKind::TimedOut {
return true; return true;
@@ -385,9 +400,7 @@ where
match result { match result {
Ok(value) => return Ok(value), Ok(value) => return Ok(value),
Err(err) => { Err(err) => {
if attempt.saturating_add(1) >= max_attempts.max(1) if attempt.saturating_add(1) >= max_attempts.max(1) || !is_rebalance_metadata_admission_retryable(&err) {
|| !matches!(rebalance_error_source(&err), Error::Lock(lock_err) if is_rebalance_transient_lock_error(lock_err))
{
return Err(err); return Err(err);
} }
#[cfg(test)] #[cfg(test)]
@@ -719,6 +732,82 @@ mod error_source_tests {
} }
} }
#[tokio::test(start_paused = true)]
async fn rebalance_metadata_retry_recovers_after_write_quorum_failure() {
let mut attempts = 0;
retry_rebalance_metadata_access(None, 3, || {
attempts += 1;
std::future::ready(if attempts == 1 {
resolve_rebalance_meta_save_result(
Err(rebalance_meta_lock_error(
rustfs_lock::LockError::QuorumNotReached {
required: 1,
achieved: 0,
},
"write",
)),
"stop_rebalance",
)
} else {
Ok(())
})
})
.await
.expect("a later quorum-protected admission must be allowed to succeed");
assert_eq!(attempts, 2);
}
#[tokio::test(start_paused = true)]
async fn rebalance_metadata_retry_retains_persistent_write_quorum_failure() {
for max_attempts in [0, 1, 3] {
let mut attempts = 0;
let result = retry_rebalance_metadata_access(None, max_attempts, || {
attempts += 1;
std::future::ready(Err::<(), _>(rebalance_meta_lock_error(
rustfs_lock::LockError::QuorumNotReached {
required: 1,
achieved: 0,
},
"write",
)))
})
.await;
let error = result.expect_err("exhausted admission attempts must never acknowledge a write");
assert_eq!(attempts, max_attempts.max(1));
assert!(matches!(
rebalance_error_source(&error),
Error::NamespaceLockQuorumUnavailable {
mode: "write",
required: 1,
achieved: 0,
..
}
));
}
}
#[tokio::test(start_paused = true)]
async fn rebalance_metadata_retry_stops_after_admission_is_cancelled() {
let mut attempts = 0;
let result = retry_rebalance_metadata_access(None, 3, || {
attempts += 1;
std::future::ready(Err::<(), _>(if attempts == 1 {
rebalance_meta_lock_error(
rustfs_lock::LockError::QuorumNotReached {
required: 1,
achieved: 0,
},
"write",
)
} else {
Error::OperationCanceled
}))
})
.await;
assert_eq!(attempts, 2, "a stale or cancelled run cannot enter another admission attempt");
assert!(matches!(result, Err(Error::OperationCanceled)));
}
#[tokio::test] #[tokio::test]
async fn rebalance_metadata_retry_does_not_retry_permanent_or_untyped_errors() { async fn rebalance_metadata_retry_does_not_retry_permanent_or_untyped_errors() {
for err in [ for err in [
@@ -731,6 +820,20 @@ mod error_source_tests {
required: 3, required: 3,
achieved: 2, achieved: 2,
}, },
Error::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: "data-bucket".to_string(),
object: REBAL_META_NAME.to_string(),
required: 1,
achieved: 0,
},
Error::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: crate::disk::RUSTFS_META_BUCKET.to_string(),
object: "other-metadata.bin".to_string(),
required: 1,
achieved: 0,
},
Error::other("stale rebalance run rejected: lock acquisition timed out"), Error::other("stale rebalance run rejected: lock acquisition timed out"),
Error::other("rebalance distributed run fence lost"), Error::other("rebalance distributed run fence lost"),
Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut)), Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut)),
+31 -6
View File
@@ -4696,6 +4696,17 @@ pub(crate) fn same_distributed_lock_domain(left: &[Arc<dyn LockClient>], right:
.all(|right_client| left.iter().any(|left_client| Arc::ptr_eq(left_client, right_client))) .all(|right_client| left.iter().any(|left_client| Arc::ptr_eq(left_client, right_client)))
} }
// Reusing a held writer additionally requires the same quorum, so repeated
// clients must not give different weight to an otherwise identical domain.
fn same_distributed_lock_quorum(left: &[Arc<dyn LockClient>], right: &[Arc<dyn LockClient>]) -> bool {
!left.is_empty()
&& left.len() == right.len()
&& left.iter().all(|client| {
left.iter().filter(|other| Arc::ptr_eq(client, other)).count() == 1
&& right.iter().filter(|other| Arc::ptr_eq(client, other)).count() == 1
})
}
const ERASURE_CACHE_MAX_ENTRIES: usize = 32; const ERASURE_CACHE_MAX_ENTRIES: usize = 32;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -5645,9 +5656,9 @@ impl SetDisks {
pub(crate) async fn shares_namespace_lock_domain(&self, other: &Self) -> bool { pub(crate) async fn shares_namespace_lock_domain(&self, other: &Self) -> bool {
match (self.ctx.is_dist_erasure().await, other.ctx.is_dist_erasure().await) { match (self.ctx.is_dist_erasure().await, other.ctx.is_dist_erasure().await) {
(false, false) => Arc::ptr_eq(&self.local_lock_manager, &other.local_lock_manager), (false, false) => Arc::ptr_eq(&self.local_lock_manager, &other.local_lock_manager),
(true, true) => { // Distributed lock clients receive the raw ObjectKey; the pool
self.set_lock_namespace == other.set_lock_namespace && same_distributed_lock_domain(&self.lockers, &other.lockers) // namespace is not part of the key stored by their backends.
} (true, true) => same_distributed_lock_quorum(&self.lockers, &other.lockers),
_ => false, _ => false,
} }
} }
@@ -9882,7 +9893,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
async fn namespace_lock_domain_includes_pool_namespace() { async fn distributed_namespace_lock_domain_follows_backend_not_pool_name() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await; let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let first: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new()))); let first: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new())));
@@ -9921,11 +9932,25 @@ mod tests {
"sets in the same pool share the object namespace lock domain" "sets in the same pool share the object namespace lock domain"
); );
assert!( assert!(
!same_pool_first_set.shares_namespace_lock_domain(&other_pool_set).await, same_pool_first_set.shares_namespace_lock_domain(&other_pool_set).await,
"different pool namespaces must not be deduplicated solely by identical clients" "different pools using identical backends lock the same raw object key"
); );
} }
#[test]
fn distributed_lock_domain_rejects_partial_or_duplicate_backends() {
let a: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new())));
let b: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new())));
let c: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new())));
assert!(same_distributed_lock_quorum(&[a.clone(), b.clone()], &[b.clone(), a.clone()]));
assert!(!same_distributed_lock_quorum(&[a.clone(), b.clone()], &[a.clone(), c]));
assert!(!same_distributed_lock_quorum(
&[a.clone(), a.clone(), b.clone()],
&[a.clone(), b.clone(), b]
));
assert!(!same_distributed_lock_quorum(&[], &[]));
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
#[serial] #[serial]
async fn streaming_reader_holds_read_lock_until_eof() { async fn streaming_reader_holds_read_lock_until_eof() {
+89 -6
View File
@@ -95,13 +95,8 @@ impl SetDisks {
bucket: &str, bucket: &str,
object: &str, object: &str,
) -> Result<Arc<rustfs_lock::NamespaceLockGuard>> { ) -> Result<Arc<rustfs_lock::NamespaceLockGuard>> {
let resource = ObjectKey::with_version(
bucket,
object_name_disk_identity(object).as_ref(),
format!("rustfs:delete-transaction:{}:{}", self.pool_index, self.set_index),
);
let guard = self let guard = self
.namespace_lock_for_key(resource) .namespace_lock_for_key(self.delete_transaction_resource(bucket, object))
.await .await
.get_write_lock(super::super::get_lock_acquire_timeout()) .get_write_lock(super::super::get_lock_acquire_timeout())
.await .await
@@ -111,6 +106,94 @@ impl SetDisks {
Ok(Arc::new(guard)) Ok(Arc::new(guard))
} }
fn delete_transaction_resource(&self, bucket: &str, object: &str) -> ObjectKey {
ObjectKey::with_version(
bucket,
object_name_disk_identity(object).as_ref(),
format!("rustfs:delete-transaction:{}:{}", self.pool_index, self.set_index),
)
}
/// Batched write-lock acquisition for the whole key set of one bulk
/// mutation: one lock round instead of one quorum round-trip per key.
///
/// Bulk delete is the only multi-key write-lock holder. The batched round
/// is deadlock-free by construction (a single `lock_batch` RPC per lock
/// client, and every lock server orders concurrent batches by the same
/// global (shard, key) order), so unlike an optimistic probe wave it
/// needs no canonical serial fallback. Outcomes are per key and use the
/// same error shape as the single-key paths.
async fn namespace_write_locks_batch(
&self,
bucket: &str,
resources: &[ObjectKey],
timeout: std::time::Duration,
mode: &'static str,
) -> Vec<Result<rustfs_lock::NamespaceLockGuard>> {
if resources.is_empty() {
return Vec::new();
}
let lock = self.namespace_lock_for_key(resources[0].clone()).await;
lock.get_write_locks_batch(resources, timeout)
.await
.into_iter()
.zip(resources)
.map(|(result, resource)| {
result.map_err(|error| self.map_namespace_lock_error(bucket, resource.object.as_ref(), mode, error))
})
.collect()
}
/// Batched raw namespace write locks for a bulk delete's key set. The
/// resource keys use the same disk-identity folding as `new_ns_lock`, so
/// aliases (`a\b` vs `a/b`) still share one lock.
pub(crate) async fn acquire_object_write_locks_batch(
&self,
bucket: &str,
objects: &[&str],
timeout: std::time::Duration,
) -> Vec<Result<rustfs_lock::NamespaceLockGuard>> {
let resources: Vec<ObjectKey> = objects
.iter()
.map(|object| ObjectKey {
bucket: Arc::from(bucket),
object: Arc::from(object_name_disk_identity(object).as_ref()),
version: None,
})
.collect();
self.namespace_write_locks_batch(bucket, &resources, timeout, "write").await
}
/// Batched delete-transaction locks for a bulk delete's key set; see
/// [`Self::acquire_delete_transaction_lock`] for the resource shape.
pub(in crate::set_disk) async fn acquire_delete_transaction_locks_batch(
&self,
bucket: &str,
objects: &[&str],
timeout: std::time::Duration,
) -> Vec<Result<Arc<rustfs_lock::NamespaceLockGuard>>> {
let resources: Vec<ObjectKey> = objects
.iter()
.map(|object| self.delete_transaction_resource(bucket, object))
.collect();
let mut guards = Vec::with_capacity(resources.len());
for (object, result) in objects.iter().zip(
self.namespace_write_locks_batch(bucket, &resources, timeout, "delete_transaction")
.await,
) {
match result {
Ok(guard) => {
#[cfg(test)]
super::delete_recovery::test_hooks::run_after_transaction_lock_acquired(bucket, object, &guard);
let _ = object;
guards.push(Ok(Arc::new(guard)));
}
Err(error) => guards.push(Err(error)),
}
}
guards
}
#[allow(dead_code, reason = "lock diagnostics formatter with no caller in this port (backlog#1823)")] #[allow(dead_code, reason = "lock diagnostics formatter with no caller in this port (backlog#1823)")]
pub(in crate::set_disk) fn format_lock_error(&self, bucket: &str, object: &str, mode: &str, err: &LockResult) -> String { pub(in crate::set_disk) fn format_lock_error(&self, bucket: &str, object: &str, mode: &str, err: &LockResult) -> String {
match err { match err {
+46 -3
View File
@@ -57,7 +57,6 @@ use super::object::{
object_transaction_fencing_requested, read_object_transaction_epoch_fence, verify_object_transaction_epoch_fence, object_transaction_fencing_requested, read_object_transaction_epoch_fence, verify_object_transaction_epoch_fence,
}; };
use crate::api::config::storageclass; use crate::api::config::storageclass;
#[cfg(test)]
use crate::bucket::metadata_sys::ObjectLockConfigState; use crate::bucket::metadata_sys::ObjectLockConfigState;
use crate::bucket::quota::reservation; use crate::bucket::quota::reservation;
use crate::bucket::replication::ReplicationStatusType; use crate::bucket::replication::ReplicationStatusType;
@@ -4114,6 +4113,40 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
) )
.await?; .await?;
if !opts.versioned
&& !crate::bucket::utils::is_meta_bucketname(bucket)
&& let Some(snapshot) = opts.object_lock_config_snapshot.as_deref()
&& !matches!(snapshot.state(), ObjectLockConfigState::ConfirmedAbsent)
{
// Completion replaces the null version, not necessarily the latest
// version inspected by the request-level Object Lock check.
let current = self
.get_object_info(
bucket,
object,
&ObjectOptions {
version_id: Some(Uuid::nil().to_string()),
no_lock: true,
metadata_cache_safe: false,
versioned: opts.versioned,
version_suspended: opts.version_suspended,
..Default::default()
},
)
.await;
match current {
Ok(existing) => {
if check_object_lock_for_deletion_with_state(snapshot.state(), &existing, false)?.is_some() {
return Err(StorageError::PrefixAccessDenied(bucket.to_string(), object.to_string()));
}
}
// A null delete marker has no protected payload to replace.
Err(StorageError::MethodNotAllowed) => {}
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
Err(err) => return Err(err),
}
}
if opts.data_movement if opts.data_movement
&& opts.http_preconditions.is_some() && opts.http_preconditions.is_some()
&& let Some(err) = self.check_write_precondition(bucket, object, opts).await && let Some(err) = self.check_write_precondition(bucket, object, opts).await
@@ -10060,7 +10093,7 @@ mod tests {
// Real disk callbacks and health probes must share wall-clock deadlines. // Real disk callbacks and health probes must share wall-clock deadlines.
// Advancing Tokio time can expire unrelated healthy-drive probes. // Advancing Tokio time can expire unrelated healthy-drive probes.
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("2"))], async { temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, None::<&str>)], async {
let (dirs, store) = crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks().await; let (dirs, store) = crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks().await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await; crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "mpu-quorum-success-physical"; let bucket = "mpu-quorum-success-physical";
@@ -10087,6 +10120,8 @@ mod tests {
local.observe_multipart_cleanup_for_test(&upload_path) local.observe_multipart_cleanup_for_test(&upload_path)
}).collect::<Vec<_>>(); }).collect::<Vec<_>>();
let disk::Disk::Local(paused_local) = disks[3].as_ref() else { panic!("actual local physical tail") }; let disk::Disk::Local(paused_local) = disks[3].as_ref() else { panic!("actual local physical tail") };
// Fault only the held leg; healthy disks keep their normal I/O budgets.
let _paused_timeout = paused_local.override_operation_timeout_for_test(Duration::from_secs(2));
let io_target = paused_local.get_disk().get_object_path_for_io(bucket, object).expect("real IO target"); let io_target = paused_local.get_disk().get_object_path_for_io(bucket, object).expect("real IO target");
let mutation_key = paused_local.get_disk().get_object_path(bucket, object).expect("logical native mutation key"); let mutation_key = paused_local.get_disk().get_object_path(bucket, object).expect("logical native mutation key");
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
@@ -10243,7 +10278,15 @@ mod tests {
let size = copy_info.get_actual_size().expect("actual source size"); let size = copy_info.get_actual_size().expect("actual source size");
copy_info.put_object_reader = Some(PutObjReader::new(HashReader::from_stream(reader.stream, size, size, None, None, false).expect("actual Copy body stream"))); copy_info.put_object_reader = Some(PutObjReader::new(HashReader::from_stream(reader.stream, size, size, None, None, false).expect("actual Copy body stream")));
tokio::time::timeout(Duration::from_secs(5), store.copy_object(bucket, object, bucket, copy_object, &mut copy_info, &ObjectOptions::default(), &ObjectOptions::default())) tokio::time::timeout(Duration::from_secs(5), store.copy_object(bucket, object, bucket, copy_object, &mut copy_info, &ObjectOptions::default(), &ObjectOptions::default()))
.await.expect("cross-key Copy cannot wait on the old successful logical owner").expect("actual CopyObject"); .await.expect("cross-key Copy cannot wait on the old successful logical owner")
.unwrap_or_else(|error| {
let health = disks.iter().enumerate().map(|(slot, disk)| {
let disk::Disk::Local(local) = disk.as_ref() else { panic!("actual local Copy backend") };
let metrics = local.metrics_snapshot();
(slot, disk.runtime_state(), metrics.total_errors_timeout, metrics.total_errors_availability, metrics.total_waiting, metrics.api_calls)
}).collect::<Vec<_>>();
panic!("actual CopyObject: {error:?}; drive health (slot, state, timeouts, unavailable, waiting, calls): {health:?}");
});
drop(copy_info); drop(copy_info);
let mut copied = store.get_object_reader(bucket, copy_object, None, HeaderMap::new(), &ObjectOptions::default()).await.expect("actual copied body"); let mut copied = store.get_object_reader(bucket, copy_object, None, HeaderMap::new(), &ObjectOptions::default()).await.expect("actual copied body");
let mut actual = Vec::new(); let mut actual = Vec::new();
+387 -43
View File
@@ -4958,7 +4958,20 @@ impl SetDisks {
); );
} }
if let Some(version_id) = opts.version_id.as_deref() // A write without a version id to a key that is not versioning-Enabled
// replaces the key's null version in place, so the WORM gate must judge
// that version too. The request-layer pre-check reads the latest
// version, which is not the one replaced when a UUID version or a
// delete marker sits above a retained null version.
let worm_gate_version_id = opts.version_id.clone().or_else(|| {
(!opts.versioned
&& opts
.object_lock_config_snapshot
.as_ref()
.is_some_and(|snapshot| !matches!(snapshot.state(), ObjectLockConfigState::ConfirmedAbsent)))
.then(|| Uuid::nil().to_string())
});
if let Some(version_id) = worm_gate_version_id.as_deref()
&& !is_meta_bucketname(bucket) && !is_meta_bucketname(bucket)
{ {
let explicit_version_lookup_started = rustfs_io_metrics::put_stage_timer(); let explicit_version_lookup_started = rustfs_io_metrics::put_stage_timer();
@@ -4978,9 +4991,10 @@ impl SetDisks {
.await; .await;
match current { match current {
Ok(existing) => { Ok(existing) => {
let object_lock_config = opts.object_lock_config_snapshot.as_deref().ok_or_else(|| { let object_lock_config = opts
Error::other("explicit-version PUT is missing its Object Lock configuration snapshot") .object_lock_config_snapshot
})?; .as_deref()
.ok_or_else(|| Error::other("destructive PUT is missing its Object Lock configuration snapshot"))?;
// The WORM gate protects the locked version from local // The WORM gate protects the locked version from local
// overwrites; an authorized replication write passes it // overwrites; an authorized replication write passes it
// only when the LWW merge below will judge every // only when the LWW merge below will judge every
@@ -5011,6 +5025,8 @@ impl SetDisks {
} }
} }
} }
// A null delete marker has no protected payload to replace.
Err(StorageError::MethodNotAllowed) if version_id == Uuid::nil().to_string() => {}
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {} Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
Err(err) => return Err(err), Err(err) => return Err(err),
} }
@@ -9304,6 +9320,60 @@ impl SetDisks {
} }
impl SetDisks { impl SetDisks {
/// Judge the version a versionless destructive delete removes.
///
/// For a key that is not versioning-`Enabled`, a delete without a version
/// id removes the `null` version, while the preceding Object Lock check
/// judged the key's latest entry. When a UUID version or delete marker
/// sits above a retained `null` version, the two differ and the retained
/// version must be judged on its own. The caller holds the object's write
/// lock. Plain unversioned keys and versioning-`Enabled` keys keep a single
/// lookup: their latest entry is the version removed.
///
/// The lookup future is boxed from a synchronous wrapper so it leaves the
/// delete futures' frames: `delete_object` already nests the boxed batch
/// path, and inlining a second `get_object_info` overflowed the 4 MiB test
/// stack on Linux.
fn check_null_version_object_lock_delete<'a>(
&'a self,
bucket: &'a str,
object: &'a str,
latest: &'a ObjectInfo,
opts: &'a ObjectOptions,
) -> impl Future<Output = Result<()>> + Send + 'a {
Box::pin(self.check_null_version_object_lock_delete_inner(bucket, object, latest, opts))
}
async fn check_null_version_object_lock_delete_inner(
&self,
bucket: &str,
object: &str,
latest: &ObjectInfo,
opts: &ObjectOptions,
) -> Result<()> {
if is_meta_bucketname(bucket)
|| opts.version_id.is_some()
|| set_disk_delete_creates_delete_marker(opts)
|| opts.transition.expire_restored
|| latest.version_id.is_none_or(|version| version.is_nil())
{
return Ok(());
}
let null_opts = ObjectOptions {
version_id: Some(Uuid::nil().to_string()),
no_lock: true,
metadata_cache_safe: false,
..opts.clone()
};
match self.get_object_info(bucket, object, &null_opts).await {
Ok(existing) => check_object_lock_delete(&self.ctx, bucket, object, &existing, &null_opts).await,
// A null delete marker has no protected payload to remove.
Err(StorageError::MethodNotAllowed) => Ok(()),
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => Ok(()),
Err(err) => Err(err),
}
}
/// Read current metadata without cache admission or read-side repair. The /// Read current metadata without cache admission or read-side repair. The
/// outer batch owner already holds the sorted namespace write-lock set. /// outer batch owner already holds the sorted namespace write-lock set.
pub(crate) async fn conditional_delete_current_snapshot( pub(crate) async fn conditional_delete_current_snapshot(
@@ -9338,6 +9408,76 @@ where
futures::future::try_join_all(probes).await.map(|_| ()).map_err(Error::from) futures::future::try_join_all(probes).await.map(|_| ()).map_err(Error::from)
} }
/// Per-key source-read inputs for the bulk-delete admission loop. The bounded
/// metadata pre-fetch and the loop itself must resolve these identically, so
/// both sides derive them here.
struct BatchDeleteSourceInputs {
replication_object_name: String,
explicit_null_version: bool,
version_id: Option<Uuid>,
versioned: bool,
version_suspended: bool,
check_opts: ObjectOptions,
object_lock_check_required: bool,
replicate_delete: bool,
needs_source_read: bool,
}
fn batch_delete_source_inputs(
bucket: &str,
dobj: &ObjectToDelete,
opts: &ObjectOptions,
delete_config_snapshot: &DeleteReplicationConfigSnapshot,
object_lock_config: &Arc<ObjectLockConfigSnapshot>,
guarded: bool,
) -> BatchDeleteSourceInputs {
let replication_object_name = decode_dir_object(&dobj.object_name);
let explicit_null_version = is_explicit_null_version(dobj.version_id);
let version_id = delete_file_info_version_id(dobj.version_id);
let (versioned, version_suspended) = delete_config_snapshot
.versioning_config()
.delete_state(replication_object_name.as_str());
let check_opts = ObjectOptions {
version_id: dobj.version_id.map(|version_id| version_id.to_string()),
versioned,
version_suspended,
object_lock_delete: opts.object_lock_delete.clone(),
object_lock_config_snapshot: Some(Arc::clone(object_lock_config)),
no_lock: true,
suppress_read_repair: guarded,
..Default::default()
};
// A missing bucket Object Lock configuration does not prove that
// persisted object metadata is clean: migrated or corrupt data may
// still carry an explicit retention or legal hold. Only a real
// delete-marker creation can skip the object-level WORM check.
let object_lock_check_required = !is_meta_bucketname(bucket) && !set_disk_delete_creates_delete_marker(&check_opts);
let replicate_delete = delete_config_snapshot.has_active_rule(&replication_object_name);
let marker_delete = dobj.version_id.is_none() || dobj.synthetic_version_id;
let replication_needs_source = replicate_delete
&& (!marker_delete || delete_config_snapshot.active_delete_marker_rules_require_tags(&replication_object_name));
// Guarded explicit-version deletes are rejected before any read and must
// not join the pre-fetch wave.
let admission_error = guarded && dobj.version_id.is_some();
let needs_source_read = !admission_error
&& (object_lock_check_required
|| replication_needs_source
|| opts.tier_delete_journal_api.is_some()
|| dobj.expected_identity.is_some()
|| dobj.version_id.is_some());
BatchDeleteSourceInputs {
replication_object_name,
explicit_null_version,
version_id,
versioned,
version_suspended,
check_opts,
object_lock_check_required,
replicate_delete,
needs_source_read,
}
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectOperations for SetDisks { impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
type Error = Error; type Error = Error;
@@ -9736,7 +9876,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let unique_lock_count = batch.requests.len(); let unique_lock_count = batch.requests.len();
let mut failed_map = HashMap::new(); let mut failed_map = HashMap::new();
let mut _local_batch_guards: Vec<FastLockGuard> = Vec::with_capacity(batch.requests.len()); let mut local_batch_guards: Vec<FastLockGuard> = Vec::with_capacity(batch.requests.len());
let mut locked_objects = HashSet::new(); let mut locked_objects = HashSet::new();
// Instance-scoped, not the ambient facade (backlog#1052) — see // Instance-scoped, not the ambient facade (backlog#1052) — see
@@ -9753,7 +9893,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
(failed_map, locked_objects, dist_batch_lock_ids) = self.acquire_dist_delete_object_locks_batch(&batch).await; (failed_map, locked_objects, dist_batch_lock_ids) = self.acquire_dist_delete_object_locks_batch(&batch).await;
} else { } else {
let batch_result = self.local_lock_manager.acquire_locks_batch(batch).await; let batch_result = self.local_lock_manager.acquire_locks_batch(batch).await;
_local_batch_guards = batch_result.guards; local_batch_guards = batch_result.guards;
for key in batch_result.successful_locks { for key in batch_result.successful_locks {
locked_objects.insert(key.object.as_ref().to_string()); locked_objects.insert(key.object.as_ref().to_string());
@@ -9795,8 +9935,17 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// Every raw batch lock is acquired first. Canonical sorting also // Every raw batch lock is acquired first. Canonical sorting also
// gives aliases of one disk key exactly one transaction owner. // gives aliases of one disk key exactly one transaction owner.
let keys: std::collections::BTreeSet<_> = locked_objects.iter().cloned().collect(); let keys: std::collections::BTreeSet<_> = locked_objects.iter().cloned().collect();
for key in keys { // Bulk delete is the only multi-key transaction-lock holder; one
match self.acquire_delete_transaction_lock(bucket, &key).await { // batched lock round replaces one quorum round-trip per key and is
// deadlock-free by construction (a single `lock_batch` RPC per lock
// client, global (shard, key) order at each server). Per-key
// failures keep the set layer's per-key error reporting.
let key_names: Vec<&str> = keys.iter().map(|key| key.as_str()).collect();
let acquired = self
.acquire_delete_transaction_locks_batch(bucket, &key_names, super::super::get_lock_acquire_timeout())
.await;
for (key, result) in keys.into_iter().zip(acquired) {
match result {
Ok(guard) => { Ok(guard) => {
opts.add_namespace_lock_guard(&guard); opts.add_namespace_lock_guard(&guard);
transaction_guards.insert(key, guard); transaction_guards.insert(key, guard);
@@ -9832,6 +9981,54 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}, },
} }
}; };
// Per-key read inputs are resolved exactly once; the pre-fetch and the
// admission loop below consume this table so the two cannot disagree
// on eligibility or read options.
let mut source_inputs: Vec<Option<BatchDeleteSourceInputs>> = Vec::with_capacity(objects.len());
for dobj in &objects {
let guarded = current_generations.is_some_and(|generations| generations.contains_key(&dobj.object_name));
source_inputs.push(Some(batch_delete_source_inputs(
bucket,
dobj,
&opts,
&delete_config_snapshot,
&object_lock_config,
guarded,
)));
}
// Bounded-parallel pre-fetch of the source metadata each admitted key
// needs below. These reads are pure quorum lookups under the locks
// already held; sequencing one metadata fan-out per key made a
// 1000-key batch pay a full round of drive reads at a time.
const DELETE_OBJECTS_SOURCE_READ_CONCURRENCY: usize = 16;
let mut source_reads: HashMap<usize, (ObjectInfo, FileInfo, Option<Error>)> = HashMap::with_capacity(objects.len());
{
let mut pending = Vec::new();
for (i, source) in source_inputs.iter().enumerate() {
if del_errs[i].is_some() {
continue;
}
let Some(source) = source else {
continue;
};
if !source.needs_source_read {
continue;
}
let object_name = objects[i].object_name.clone();
let check_opts = &source.check_opts;
pending.push(async move {
let (goi, file_info, _write_quorum, gerr) = self
.get_object_info_fileinfo_and_quorum(bucket, &object_name, check_opts)
.await;
(i, (goi, file_info, gerr))
});
}
let reads = futures::stream::iter(pending)
.buffer_unordered(DELETE_OBJECTS_SOURCE_READ_CONCURRENCY)
.collect::<Vec<_>>()
.await;
source_reads.extend(reads);
}
let mut vers_map: HashMap<&String, FileInfoVersions> = HashMap::new(); let mut vers_map: HashMap<&String, FileInfoVersions> = HashMap::new();
let mut tier_reference_leases: Vec<(usize, String, Option<TierDestinationId>)> = Vec::new(); let mut tier_reference_leases: Vec<(usize, String, Option<TierDestinationId>)> = Vec::new();
let mut tier_free_version_receipt_candidates: HashMap<usize, TierFreeVersionReceiptCandidate> = HashMap::new(); let mut tier_free_version_receipt_candidates: HashMap<usize, TierFreeVersionReceiptCandidate> = HashMap::new();
@@ -9853,41 +10050,30 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
)); ));
continue; continue;
} }
let replication_object_name = decode_dir_object(&dobj.object_name); let Some(source) = source_inputs[i].take() else {
let explicit_null_version = is_explicit_null_version(dobj.version_id); continue;
let version_id = delete_file_info_version_id(dobj.version_id); };
let (versioned, version_suspended) = delete_config_snapshot let BatchDeleteSourceInputs {
.versioning_config() replication_object_name,
.delete_state(replication_object_name.as_str()); explicit_null_version,
let check_opts = ObjectOptions { version_id,
version_id: dobj.version_id.map(|version_id| version_id.to_string()),
versioned, versioned,
version_suspended, version_suspended,
object_lock_delete: opts.object_lock_delete.clone(), check_opts,
object_lock_config_snapshot: Some(Arc::clone(&object_lock_config)), object_lock_check_required,
no_lock: true, replicate_delete,
suppress_read_repair: guarded, needs_source_read,
..Default::default() } = source;
}; let (mut goi, mut authoritative_file_info, mut gerr) = if needs_source_read {
// A missing bucket Object Lock configuration does not prove that match source_reads.remove(&i) {
// persisted object metadata is clean: migrated or corrupt data may Some(read) => read,
// still carry an explicit retention or legal hold. Only a real None => {
// delete-marker creation can skip the object-level WORM check. let (goi, file_info, _write_quorum, gerr) = self
let object_lock_check_required = !is_meta_bucketname(bucket) && !set_disk_delete_creates_delete_marker(&check_opts); .get_object_info_fileinfo_and_quorum(bucket, &dobj.object_name, &check_opts)
let replicate_delete = delete_config_snapshot.has_active_rule(&replication_object_name); .await;
let marker_delete = dobj.version_id.is_none() || dobj.synthetic_version_id; (goi, file_info, gerr)
let replication_needs_source = replicate_delete }
&& (!marker_delete || delete_config_snapshot.active_delete_marker_rules_require_tags(&replication_object_name)); }
let (mut goi, mut authoritative_file_info, mut gerr) = if object_lock_check_required
|| replication_needs_source
|| opts.tier_delete_journal_api.is_some()
|| dobj.expected_identity.is_some()
|| dobj.version_id.is_some()
{
let (goi, file_info, _write_quorum, gerr) = self
.get_object_info_fileinfo_and_quorum(bucket, &dobj.object_name, &check_opts)
.await;
(goi, file_info, gerr)
} else { } else {
(ObjectInfo::default(), FileInfo::default(), None) (ObjectInfo::default(), FileInfo::default(), None)
}; };
@@ -9904,6 +10090,17 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
&& !guarded && !guarded
&& goi.name.is_empty() && goi.name.is_empty()
{ {
// Recovery can restore or retire versions of this key
// (rolling back an interrupted prepared delete brings a
// retained version back). A pre-fetched read for a later
// duplicate of the same name would still report the stale
// miss and skip its object-lock check — bypassing retention —
// so evict those entries and let the loop re-read them live.
for (other_index, other) in objects.iter().enumerate() {
if other_index != i && other.object_name == dobj.object_name {
source_reads.remove(&other_index);
}
}
let recovery = self let recovery = self
.recover_delete_key( .recover_delete_key(
bucket, bucket,
@@ -10022,6 +10219,17 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
del_errs[i] = Some(err); del_errs[i] = Some(err);
continue; continue;
} }
// Not gated on `source_missing`: with a delete marker on top the
// latest-version lookup reports the key as missing while the null
// version below it may still be retained.
if object_lock_check_required
&& let Err(err) = self
.check_null_version_object_lock_delete(bucket, &dobj.object_name, &goi, &check_opts)
.await
{
del_errs[i] = Some(err);
continue;
}
if opts.tier_delete_journal_api.is_some() if opts.tier_delete_journal_api.is_some()
&& !source_missing && !source_missing
@@ -10477,6 +10685,10 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// response can hide a persisted decision and must never roll back. // response can hide a persisted decision and must never roll back.
let groups = groups.collect::<Vec<_>>(); let groups = groups.collect::<Vec<_>>();
published_groups.extend(groups.iter().map(|index| vers[*index].name.clone())); published_groups.extend(groups.iter().map(|index| vers[*index].name.clone()));
// Journal publish fans out per object per drive; keep enough
// publications in flight to feed the drives' durable-mutation
// lanes (8 workers each) across a 16-drive set.
const DELETE_JOURNAL_PUBLISH_CONCURRENCY: usize = 32;
let publications = futures::stream::iter(groups.into_iter().map(|index: usize| { let publications = futures::stream::iter(groups.into_iter().map(|index: usize| {
let group = &vers[index]; let group = &vers[index];
let recovery_ticket = recovery_tickets[index].clone(); let recovery_ticket = recovery_tickets[index].clone();
@@ -10512,7 +10724,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
(index, result) (index, result)
} }
})) }))
.buffer_unordered(16) .buffer_unordered(DELETE_JOURNAL_PUBLISH_CONCURRENCY)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.await; .await;
for (index, result) in publications { for (index, result) in publications {
@@ -10652,9 +10864,14 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// Deferred recovery takes fresh raw and transaction locks. Discard the // Deferred recovery takes fresh raw and transaction locks. Discard the
// completed leases' deadlines while retaining every caller-owned fence. // completed leases' deadlines while retaining every caller-owned fence.
opts.namespace_lock_fence = outer_namespace_lock_fence; opts.namespace_lock_fence = outer_namespace_lock_fence;
// Release inner owners before reacquiring any raw key lock. // Release inner owners before reacquiring any raw key lock. The raw
// batch guards must go too: deferred recovery takes a fresh write lock
// on the same keys below, and a still-held local batch guard would
// deadlock it against itself (the distributed path releases its batch
// ids in the same region).
drop(namespace_owner); drop(namespace_owner);
drop(transaction_guards); drop(transaction_guards);
drop(local_batch_guards);
// TODO(backlog): support partial object deletion for multi-part objects // TODO(backlog): support partial object deletion for multi-part objects
@@ -11094,6 +11311,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
opts.precondition_check(&goi)?; opts.precondition_check(&goi)?;
check_object_lock_delete(&self.ctx, bucket, object, &goi, &opts).await?; check_object_lock_delete(&self.ctx, bucket, object, &goi, &opts).await?;
} }
self.check_null_version_object_lock_delete(bucket, object, &goi, &opts)
.await?;
let delete_marker_purge_candidate = opts let delete_marker_purge_candidate = opts
.version_id .version_id
.as_deref() .as_deref()
@@ -24992,6 +25211,131 @@ mod delete_objects_lock_gating_tests {
.expect("plain object should be written"); .expect("plain object should be written");
} }
/// Regression for the PR9155 review finding: a pre-fetched source-missing
/// read of a later duplicate must not skip the object-lock check after an
/// earlier entry's recovery rolls back an interrupted prepared delete and
/// restores a GOVERNANCE-retained version. Without the eviction the later
/// duplicate deletes the restored version, bypassing retention.
#[tokio::test]
async fn duplicate_entries_recheck_object_lock_after_recovery_restores_retained_version() {
let (_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "recovery-retention-dup";
let object = "retained.bin";
for disk in &disk_stores {
disk.make_volume(bucket).await.expect("create bucket");
}
// A versioned put, so the batch can address the version explicitly.
let mut reader = PutObjReader::from_vec(vec![7u8; 256]);
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
no_lock: true,
..Default::default()
},
)
.await
.expect("versioned object should be written");
let version = set_disks
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.expect("read back the version identity")
.version_id
.expect("versioned put assigns a version id");
// GOVERNANCE retention on that version, persisted the way
// PutObjectRetention persists it.
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(60);
set_disks
.put_object_metadata(
bucket,
object,
&ObjectOptions {
no_lock: true,
version_id: Some(version.to_string()),
eval_metadata: Some(HashMap::from([
(metadata_keys::OBJECT_LOCK_MODE.to_string(), "GOVERNANCE".to_string()),
(
metadata_keys::OBJECT_LOCK_RETAIN_UNTIL_DATE.to_string(),
retain_until
.format(&time::format_description::well_known::Rfc3339)
.expect("retention date formats"),
),
])),
..Default::default()
},
)
.await
.expect("governance retention should persist");
// Interrupted authorized delete: the recoverable per-disk delete
// applies the version removal and leaves the prepared journal
// undecided — exactly a crash between apply and publish.
let transaction = Uuid::new_v4();
for disk in &disk_stores {
let fi = disk
.read_version("", bucket, object, &version.to_string(), &crate::disk::ReadOptions::default())
.await
.expect("version exists before the interrupted delete");
let errors = disk
.delete_versions_recoverable(
bucket,
vec![FileInfoVersions {
name: object.to_string(),
versions: vec![fi],
..Default::default()
}],
DeleteOptions {
recoverable_delete: true,
old_data_dir: Some(transaction),
..Default::default()
},
)
.await;
assert!(errors.iter().all(Option::is_none), "the plant must apply: {errors:?}");
}
// A no-bypass batch naming the retained version twice: the first
// entry rolls the prepared delete back (restoring the retained
// version) and must be denied; the second must re-read and stay
// denied too instead of reusing the stale pre-fetched miss.
let victim = ObjectToDelete {
object_name: object.to_string(),
version_id: Some(version),
..Default::default()
};
let (_deleted, errors) = set_disks
.delete_objects(
bucket,
vec![victim.clone(), victim],
ObjectOptions {
versioned: true,
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(
ObjectLockConfigState::ConfirmedAbsent,
))),
..Default::default()
},
)
.await;
assert!(
errors.iter().all(|error| error
.as_ref()
.is_some_and(|error| matches!(error, StorageError::PrefixAccessDenied(_, _)))),
"both duplicates must be denied by the restored retention: {errors:?}"
);
// The retained version survives the batch.
for disk in &disk_stores {
disk.read_version("", bucket, object, &version.to_string(), &crate::disk::ReadOptions::default())
.await
.expect("the retained version must survive the batch");
}
}
#[tokio::test] #[tokio::test]
#[serial_test::serial(shard_integrity_rollout)] #[serial_test::serial(shard_integrity_rollout)]
async fn conditional_delete_snapshot_preserves_multipart_etag() { async fn conditional_delete_snapshot_preserves_multipart_etag() {
+171
View File
@@ -3692,6 +3692,177 @@ mod tests {
); );
} }
// The RPC lock service uses the raw ObjectKey and one backend per node,
// even when the requesting sets carry different pool namespace strings.
async fn pool_meta_heal_store_with_lock_domains(source: &Arc<ECStore>, shared: bool) -> Arc<ECStore> {
use rustfs_lock::client::{LockClient, local::LocalClient};
let clients = || {
(0..2)
.map(|_| {
Arc::new(LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new()))) as Arc<dyn LockClient>
})
.collect::<Vec<_>>()
};
let first = clients();
let second = if shared { first.clone() } else { clients() };
let mut pools = source.pools.clone();
for (pool, lockers) in pools.iter_mut().zip([first, second]) {
for set in &mut Arc::make_mut(pool).disk_set {
Arc::make_mut(set).lockers = lockers.clone();
}
}
let endpoints: EndpointServerPools = pools.iter().map(|pool| pool.endpoints.clone()).collect::<Vec<_>>().into();
let state = source.pool_meta_save_gate.lock().await.independent_clone_for_test();
Arc::new(ECStore {
id: Uuid::new_v4(),
disk_map: Default::default(),
pools,
peer_sys: crate::cluster::rpc::S3PeerSys::new_with_instance_ctx(&endpoints, Arc::clone(&source.ctx)),
pool_meta: tokio::sync::RwLock::new(source.pool_meta.read().await.clone()),
rebalance_meta: tokio::sync::RwLock::new(None),
decommission_cancelers: tokio::sync::RwLock::new(vec![None; 2]),
start_gate: tokio::sync::Mutex::new(()),
pool_meta_save_gate: tokio::sync::Mutex::new(state),
ctx: Arc::clone(&source.ctx),
bucket_fence_registry: Arc::default(),
})
}
#[tokio::test]
#[serial_test::serial]
async fn pool_meta_same_distributed_backend_has_one_raw_lock_across_pools() {
let (_dirs, source, _peer) = test_two_pool_stores(None).await;
let store = pool_meta_heal_store_with_lock_domains(&source, true).await;
let first = store.pools[0].get_disks_by_key(POOL_META_NAME);
let second = store.pools[1].get_disks_by_key(POOL_META_NAME);
let first_lock = first
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("first pool lock");
let second_lock = second
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("second pool lock");
let guard = first_lock
.get_write_lock(std::time::Duration::from_secs(2))
.await
.expect("first writer");
assert!(
second_lock
.get_write_lock(std::time::Duration::from_millis(150))
.await
.is_err(),
"different pool namespaces still conflict on the same backend and object key"
);
drop(guard);
second_lock
.get_write_lock(std::time::Duration::from_secs(3))
.await
.expect("second writer should acquire after the first releases");
}
#[tokio::test]
#[serial_test::serial]
async fn pool_meta_heal_reuses_same_distributed_backend_across_pools() {
let (_dirs, source, _peer) = test_two_pool_stores(None).await;
let store = pool_meta_heal_store_with_lock_domains(&source, true).await;
let (item, error) = tokio::time::timeout(
std::time::Duration::from_secs(3),
store.handle_heal_object(
RUSTFS_META_BUCKET,
POOL_META_NAME,
"",
&HealOpts {
pool: Some(1),
dry_run: true,
..Default::default()
},
),
)
.await
.expect("inspection must reuse the fixed pool's writer when the lock backend is shared")
.expect("pool metadata heal inspection should return a result");
assert!(error.is_none(), "inspection should succeed: {error:?}");
assert_eq!(item.object, POOL_META_NAME);
}
#[tokio::test]
#[serial_test::serial]
async fn pool_meta_heal_repairs_other_pool_with_shared_distributed_backend() {
let (_dirs, source, _peer) = test_two_pool_stores(None).await;
let store = pool_meta_heal_store_with_lock_domains(&source, true).await;
let missing = remove_pool_meta_shard(&store, 1).await;
let (item, error) = tokio::time::timeout(
std::time::Duration::from_secs(10),
store.handle_heal_object(
RUSTFS_META_BUCKET,
POOL_META_NAME,
"",
&HealOpts {
pool: Some(1),
..Default::default()
},
),
)
.await
.expect("cross-pool repair must not reenter the shared writer")
.expect("pool metadata repair should return a result");
assert!(error.is_none(), "cross-pool repair should complete: {error:?}");
assert_eq!(item.object, POOL_META_NAME);
assert!(
missing.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_ok(),
"the selected missing shard must be restored"
);
}
#[tokio::test]
#[serial_test::serial]
async fn pool_meta_heal_keeps_target_lock_for_independent_distributed_backend() {
let (_dirs, source, _peer) = test_two_pool_stores(None).await;
let store = pool_meta_heal_store_with_lock_domains(&source, false).await;
let target = store.pools[1].get_disks_by_key(POOL_META_NAME);
let lock = target
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
.await
.expect("target lock");
let guard = lock
.get_write_lock(std::time::Duration::from_secs(2))
.await
.expect("hold target lock");
let barrier = DecommissionCapacityLockOrderBarrier::install(store.id, store.id);
let heal_store = Arc::clone(&store);
let mut heal = tokio::spawn(async move {
heal_store
.handle_heal_object(
RUSTFS_META_BUCKET,
POOL_META_NAME,
"",
&HealOpts {
pool: Some(1),
dry_run: true,
..Default::default()
},
)
.await
});
tokio::time::timeout(
std::time::Duration::from_secs(3),
barrier.wait_until_external_heal_target_lock_attempted(),
)
.await
.expect("inspection must attempt the independent target lock");
assert!(!heal.is_finished(), "inspection must wait while the target writer is held");
drop(guard);
let (item, error) = tokio::time::timeout(std::time::Duration::from_secs(3), &mut heal)
.await
.expect("inspection should progress after target lock release")
.expect("heal task should not panic")
.expect("pool metadata heal inspection should return a result");
assert!(error.is_none(), "inspection should succeed: {error:?}");
assert_eq!(item.object, POOL_META_NAME);
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn pool_meta_heal_cas_repairs_only_the_requested_pool() { async fn pool_meta_heal_cas_repairs_only_the_requested_pool() {
+278
View File
@@ -27352,4 +27352,282 @@ mod tests {
.expect("recovery must preserve the local source object"); .expect("recovery must preserve the local source object");
assert_eq!(source_after.size, i64::try_from(payload.len()).expect("payload length should fit i64")); assert_eq!(source_after.size, i64::try_from(payload.len()).expect("payload length should fit i64"));
} }
#[cfg(feature = "test-util")]
async fn seed_retained_null_version(
store: &Arc<crate::store::ECStore>,
bucket: &str,
object: &str,
protection: &str,
latest_is_marker: bool,
) {
let metadata = if protection == "legal-hold" {
HashMap::from([("x-amz-object-lock-legal-hold".to_string(), "ON".to_string())])
} else {
HashMap::from([
("x-amz-object-lock-mode".to_string(), protection.to_string()),
("x-amz-object-lock-retain-until-date".to_string(), "2099-01-01T00:00:00Z".to_string()),
])
};
let mut original = PutObjReader::from_vec(b"protected null version".to_vec());
store
.put_object(
bucket,
object,
&mut original,
&ObjectOptions {
user_defined: metadata,
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(1)),
..Default::default()
},
)
.await
.expect("seed retained null version");
let mut latest = PutObjReader::from_vec(b"unprotected latest version".to_vec());
store
.put_object(
bucket,
object,
&mut latest,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("seed newer unprotected UUID version");
if latest_is_marker {
store
.delete_object(
bucket,
object,
ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("create a UUID delete marker above the retained null version");
}
}
#[cfg(feature = "test-util")]
async fn assert_retained_null_version_body(store: &Arc<crate::store::ECStore>, bucket: &str, object: &str) {
let mut reader = store
.get_object_reader(
bucket,
object,
None,
HeaderMap::new(),
&ObjectOptions {
version_id: Some(Uuid::nil().to_string()),
..Default::default()
},
)
.await
.expect("retained null version must remain readable");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("read retained null version");
assert_eq!(body, b"protected null version");
}
/// A versionless delete removes the null version, so the Object Lock check
/// must judge that version even when a UUID version or delete marker is
/// the key's latest entry (rustfs/backlog#3017).
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn null_version_object_lock_delete_checks_actual_target() {
use crate::bucket::replication::DeleteReplicationConfigSnapshot;
use s3s::dto::{BucketVersioningStatus, ExcludedPrefix, VersioningConfiguration};
let temp = tempfile::tempdir().expect("create null-version delete store");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "null-version-delete-lock", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("null-delete-lock-{}", Uuid::new_v4());
store
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("create Object Lock bucket");
let configs = [
VersioningConfiguration::default(),
VersioningConfiguration {
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)),
..Default::default()
},
VersioningConfiguration {
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
excluded_prefixes: Some(vec![ExcludedPrefix {
prefix: Some("archive/".to_string()),
}]),
..Default::default()
},
];
for (case, config) in configs.into_iter().enumerate() {
let snapshot = Arc::new(DeleteReplicationConfigSnapshot::from_configs_for_test(config, None));
for protection in ["COMPLIANCE", "GOVERNANCE", "legal-hold"] {
for latest_is_marker in [false, true] {
let object = format!("archive/{case}-{protection}-{latest_is_marker}");
seed_retained_null_version(&store, &bucket, &object, protection, latest_is_marker).await;
let (_, errors) = store
.delete_objects(
&bucket,
vec![ObjectToDelete {
object_name: object.clone(),
..Default::default()
}],
ObjectOptions {
delete_replication_config_snapshot: Some(Arc::clone(&snapshot)),
..Default::default()
},
)
.await;
assert!(
matches!(errors.as_slice(), [Some(StorageError::PrefixAccessDenied(_, _))]),
"batch delete must judge the retained null version: {object}: {errors:?}"
);
let error = store
.delete_object(
&bucket,
&object,
ObjectOptions {
version_suspended: true,
..Default::default()
},
)
.await
.expect_err("suspended single delete must also judge the retained null version");
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
assert_retained_null_version_body(&store, &bucket, &object).await;
if protection == "GOVERNANCE" {
let (_, errors) = store
.delete_objects(
&bucket,
vec![ObjectToDelete {
object_name: object.clone(),
..Default::default()
}],
ObjectOptions {
delete_replication_config_snapshot: Some(Arc::clone(&snapshot)),
object_lock_delete: Some(crate::storage_api_contracts::object::ObjectLockDeleteOptions {
bypass_governance: true,
}),
..Default::default()
},
)
.await;
assert!(errors.iter().all(Option::is_none), "authorized governance bypass: {errors:?}");
assert!(
store
.get_object_reader(
&bucket,
&object,
None,
HeaderMap::new(),
&ObjectOptions {
version_id: Some(Uuid::nil().to_string()),
..Default::default()
}
)
.await
.is_err(),
"authorized deletion must make the retained null payload unreadable"
);
}
}
}
}
}
/// A versionless PUT or multipart completion replaces the null version in
/// place, so the commit-time WORM gate must judge that version even when a
/// UUID version or delete marker is the key's latest entry
/// (rustfs/backlog#3019).
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn null_version_object_lock_put_and_multipart_preserve_retained_target() {
let temp = tempfile::tempdir().expect("create null-version overwrite store");
let (_ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp.path(), "null-version-overwrite-lock", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = format!("null-overwrite-lock-{}", Uuid::new_v4());
store
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("create Object Lock bucket");
for protection in ["COMPLIANCE", "GOVERNANCE", "legal-hold"] {
for latest_is_marker in [false, true] {
let object = format!("{protection}-{latest_is_marker}");
seed_retained_null_version(&store, &bucket, &object, protection, latest_is_marker).await;
let mut replacement = PutObjReader::from_vec(b"replacement".to_vec());
let error = store
.put_object(&bucket, &object, &mut replacement, &ObjectOptions::default())
.await
.expect_err("PUT must reject replacement of a retained null version");
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
let upload = store
.clone()
.new_multipart_upload(&bucket, &object, &ObjectOptions::default())
.await
.expect("initiate multipart upload");
let mut part_reader = PutObjReader::from_vec(b"multipart replacement".to_vec());
let part = store
.clone()
.put_object_part(&bucket, &object, &upload.upload_id, 1, &mut part_reader, &ObjectOptions::default())
.await
.expect("stage multipart replacement");
let error = store
.clone()
.complete_multipart_upload(
&bucket,
&object,
&upload.upload_id,
vec![crate::storage_api_contracts::multipart::CompletePart {
part_num: part.part_num,
etag: part.etag,
..Default::default()
}],
&ObjectOptions::default(),
)
.await
.expect_err("multipart completion must reject replacement of a retained null version");
assert!(matches!(error, StorageError::PrefixAccessDenied(_, _)), "{object}: {error:?}");
assert_retained_null_version_body(&store, &bucket, &object).await;
let mut next_version = PutObjReader::from_vec(b"new UUID version".to_vec());
store
.put_object(
&bucket,
&object,
&mut next_version,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("a new UUID version must remain allowed");
assert_retained_null_version_body(&store, &bucket, &object).await;
}
}
}
} }
@@ -318,10 +318,7 @@ mod multipart_copy_heal_tests {
let mut corrupt = original.clone(); let mut corrupt = original.clone();
corrupt[0] ^= 0x80; corrupt[0] ^= 0x80;
std::fs::write(&shard, &corrupt).expect("actual bitrot corruption in shared physical shard"); std::fs::write(&shard, &corrupt).expect("actual bitrot corruption in shared physical shard");
// Windows only flushes a handle opened for writing. std::fs::File::open(&shard)
std::fs::OpenOptions::new()
.write(true)
.open(&shard)
.expect("open corrupted actual shard") .expect("open corrupted actual shard")
.sync_all() .sync_all()
.expect("persist actual fault bytes"); .expect("persist actual fault bytes");
+27
View File
@@ -1210,7 +1210,34 @@ impl ECStore {
opts: &ObjectOptions, opts: &ObjectOptions,
) -> Result<ObjectInfo> { ) -> Result<ObjectInfo> {
check_complete_multipart_args(bucket, object, upload_id)?; check_complete_multipart_args(bucket, object, upload_id)?;
let encoded_object = encode_dir_object_ref(object);
let object = encoded_object.as_ref();
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?; let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
if opts.overwrites_existing_version() && !is_meta_bucketname(bucket) {
// The set-level commit judges the null version this completion
// replaces; give it the same authoritative snapshot a PUT carries.
let expected_incarnation_id = opts
.expected_bucket_incarnation_id
.ok_or_else(|| Error::other("multipart completion is missing its bucket incarnation"))?;
let lifecycle_fence = opts
.bucket_lifecycle_lock_fence
.as_ref()
.ok_or_else(|| Error::other("multipart completion is missing its bucket lifecycle fence"))?;
let snapshot = match opts.object_lock_config_snapshot.as_ref() {
Some(snapshot) => Arc::clone(snapshot),
None => {
self.object_lock_config_snapshot_under_lifecycle_fence(bucket, lifecycle_fence)
.await?
}
};
if !snapshot.is_valid_for_destructive_put(self.id, bucket, expected_incarnation_id) {
return Err(Error::other(
"Object Lock snapshot does not hold valid target bucket generation and configuration fences",
));
}
snapshot.add_lock_fences(&mut opts);
opts.object_lock_config_snapshot = Some(snapshot);
}
if !self.single_pool() { if !self.single_pool() {
opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await; opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
} }
+199 -16
View File
@@ -1352,8 +1352,8 @@ impl RestoreWorkerGuard {
} }
} }
impl Drop for ObjectLockDiagGuard { impl ObjectLockDiagGuard {
fn drop(&mut self) { fn record_hold_diag(&self) {
if !self.enabled || self.guard.as_ref().is_released() { if !self.enabled || self.guard.as_ref().is_released() {
return; return;
} }
@@ -1378,6 +1378,12 @@ impl Drop for ObjectLockDiagGuard {
} }
} }
impl Drop for ObjectLockDiagGuard {
fn drop(&mut self) {
self.record_hold_diag();
}
}
/// A failure to preserve one object generation for a SelectObjectContent read. /// A failure to preserve one object generation for a SelectObjectContent read.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[non_exhaustive] #[non_exhaustive]
@@ -3467,22 +3473,53 @@ impl ECStore {
} }
} }
/// Acquire the per-object write locks a bulk delete needs before any
/// mutation, one batched lock round for the whole key set. Bulk delete is
/// the only multi-key write-lock holder, and the batched round is
/// deadlock-free by construction (one `lock_batch` RPC per lock client,
/// global key order at each server), so no optimistic wave or serial
/// fallback is needed. Outcomes are per key: a locked-out key fails alone
/// and the rest of the batch proceeds.
async fn acquire_delete_objects_write_locks( async fn acquire_delete_objects_write_locks(
&self, &self,
bucket: &str, bucket: &str,
objects: &[ObjectToDelete], objects: &[ObjectToDelete],
opts: &mut ObjectOptions, opts: &mut ObjectOptions,
) -> Result<Vec<ObjectLockDiagGuard>> { ) -> Result<(Vec<ObjectLockDiagGuard>, HashMap<String, StorageError>)> {
if opts.no_lock || objects.is_empty() { if opts.no_lock || objects.is_empty() {
return Ok(Vec::new()); return Ok((Vec::new(), HashMap::new()));
} }
let object_names = sorted_unique_delete_object_names(objects); let object_names = sorted_unique_delete_object_names(objects);
// Lock order: encoded object names are acquired in ascending order, then let acquired = self.pools[0].disk_set[0]
// the set-layer calls receive no_lock so they do not reacquire them. .acquire_object_write_locks_batch(bucket, &object_names, get_lock_acquire_timeout())
.await;
let diag_enabled = is_object_lock_diag_enabled();
let mut guards = Vec::with_capacity(object_names.len()); let mut guards = Vec::with_capacity(object_names.len());
for object in object_names { let mut failures = HashMap::new();
guards.push(self.acquire_object_write_lock("delete_objects", bucket, object).await?); for (object, result) in object_names.iter().zip(acquired) {
match result {
Ok(guard) => {
#[cfg(any(test, feature = "test-util"))]
notify_delete_namespace_pending(bucket);
guards.push(
ObjectLockDiagGuard::new(
guard,
diag_enabled,
"delete_objects",
diag_enabled.then(|| bucket.to_string()),
diag_enabled.then(|| (*object).to_string()),
diag_enabled.then(|| self.pools[0].disk_set[0].locker_owner.clone()),
ObjectLockDiagMode::Write,
)
.with_write_key(bucket, object),
);
}
Err(error) => {
failures.insert((*object).to_owned(), error);
}
}
} }
opts.no_lock = true; opts.no_lock = true;
for signal in guards.iter().filter_map(ObjectLockDiagGuard::lock_lost_signal) { for signal in guards.iter().filter_map(ObjectLockDiagGuard::lock_lost_signal) {
@@ -3490,7 +3527,7 @@ impl ECStore {
} }
opts.ensure_namespace_lock_fence(); opts.ensure_namespace_lock_fence();
Ok(guards) Ok((guards, failures))
} }
pub(super) async fn acquire_object_read_lock_if_needed( pub(super) async fn acquire_object_read_lock_if_needed(
@@ -6165,10 +6202,21 @@ impl ECStore {
if current_bucket_incarnation_id.is_some() { if current_bucket_incarnation_id.is_some() {
pause_delete_after_object_lock_snapshot(bucket).await; pause_delete_after_object_lock_snapshot(bucket).await;
} }
let _object_lock_guards = Arc::new(match self.acquire_delete_objects_write_locks(bucket, &objects, &mut opts).await { let (object_lock_guards, object_lock_failures) =
Ok(guards) => guards, match self.acquire_delete_objects_write_locks(bucket, &objects, &mut opts).await {
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err), Ok(result) => result,
}); Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
};
// Lock outcomes are per key: a locked-out key fails alone and the rest
// of the batch proceeds, matching the set layer's per-key lock errors.
if !object_lock_failures.is_empty() {
for (index, object) in objects.iter().enumerate() {
if let Some(error) = object_lock_failures.get(&object.object_name) {
del_errs[index] = Some(error.clone());
}
}
}
let _object_lock_guards = Arc::new(object_lock_guards);
opts.delete_mutation_owner = Some(Arc::new((opts.delete_mutation_owner.take(), _object_lock_guards.clone()))); opts.delete_mutation_owner = Some(Arc::new((opts.delete_mutation_owner.take(), _object_lock_guards.clone())));
#[cfg(any(test, feature = "test-util"))] #[cfg(any(test, feature = "test-util"))]
if !_object_lock_guards.is_empty() { if !_object_lock_guards.is_empty() {
@@ -6288,8 +6336,11 @@ impl ECStore {
inject_conditional_delete_post_admission_mutations(self, bucket).await; inject_conditional_delete_post_admission_mutations(self, bucket).await;
if !has_conditions { if !has_conditions {
let indices = (0..objects.len()).collect::<Vec<_>>(); // Keys whose write lock failed are already reported in `del_errs`;
(del_objects, del_errs, accounting) = self // the phase only runs for the keys this batch actually holds, and
// only its outcomes replace the outer per-key state.
let indices = (0..objects.len()).filter(|idx| del_errs[*idx].is_none()).collect::<Vec<_>>();
let (phase_deleted, mut phase_errors, mut phase_accounting) = self
.run_batch_delete_phase( .run_batch_delete_phase(
bucket, bucket,
&objects, &objects,
@@ -6301,6 +6352,11 @@ impl ECStore {
}, },
) )
.await; .await;
for idx in indices {
del_objects[idx] = phase_deleted[idx].clone();
del_errs[idx] = phase_errors[idx].take();
accounting[idx] = phase_accounting[idx].take();
}
} else { } else {
let mut conditional_actions = HashMap::with_capacity(objects.len()); let mut conditional_actions = HashMap::with_capacity(objects.len());
let mut conditional_indices = Vec::with_capacity(objects.len()); let mut conditional_indices = Vec::with_capacity(objects.len());
@@ -12944,11 +13000,12 @@ mod tests {
]; ];
let mut opts = ObjectOptions::default(); let mut opts = ObjectOptions::default();
let guards = store let (guards, failures) = store
.acquire_delete_objects_write_locks("bucket", &objects, &mut opts) .acquire_delete_objects_write_locks("bucket", &objects, &mut opts)
.await .await
.expect("delete object locks should be acquired"); .expect("delete object locks should be acquired");
assert!(failures.is_empty(), "uncontended keys must all lock: {failures:?}");
assert_eq!(guards.len(), 2, "duplicate object names should share one namespace lock"); assert_eq!(guards.len(), 2, "duplicate object names should share one namespace lock");
assert!(opts.no_lock, "set layer should not reacquire locks already held by ECStore"); assert!(opts.no_lock, "set layer should not reacquire locks already held by ECStore");
assert!( assert!(
@@ -13580,3 +13637,129 @@ mod missing_key_delete_quorum_tests {
.await; .await;
} }
} }
/// The bulk-delete lock rounds are batched and per key: one `lock_batch` wave
/// per lock layer replaces one quorum round-trip per key, a locked-out key
/// fails alone, and the rest of the batch proceeds (rustfs/backlog#3146).
#[cfg(test)]
mod batch_delete_lock_tests {
use super::*;
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
async fn seeded_store(bucket: &str) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
let (dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("fixture bucket");
for key in ["a-object", "b-object"] {
let mut reader = PutObjReader::from_vec(vec![0x53; 4096]);
store
.put_object(
bucket,
key,
&mut reader,
&ObjectOptions {
write_completion: crate::object_api::WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("seed object");
}
(dirs, store)
}
fn two_key_batch() -> Vec<ObjectToDelete> {
["b-object", "a-object"]
.into_iter()
.map(|object_name| ObjectToDelete {
object_name: object_name.to_owned(),
..Default::default()
})
.collect()
}
#[tokio::test]
async fn uncontended_batch_deletes_every_key() {
let (_dirs, store) = seeded_store("lock-batch-success").await;
let (deleted, errors, _) = store
.delete_objects_with_tier_delete_journal_and_accounting(
"lock-batch-success",
two_key_batch(),
ObjectOptions::default(),
)
.await;
assert!(errors.iter().all(Option::is_none), "batch outcome: {errors:?}");
assert_eq!(deleted.len(), 2);
}
#[tokio::test]
async fn contended_key_fails_alone_and_the_rest_of_the_batch_proceeds() {
let (_dirs, store) = seeded_store("lock-batch-partial").await;
// Hold one key's write lock: the batched round must fail that key
// alone (per-key outcome) while the other key still deletes.
let held = store
.acquire_object_write_lock("delete_objects", "lock-batch-partial", "a-object")
.await
.expect("hold contended key");
let (deleted, errors, _) = tokio::time::timeout(
Duration::from_secs(30),
store.delete_objects_with_tier_delete_journal_and_accounting(
"lock-batch-partial",
two_key_batch(),
ObjectOptions::default(),
),
)
.await
.expect("per-key lock outcomes must not block the whole batch");
assert!(errors[1].is_some(), "contended a-object must fail alone: {errors:?}");
assert!(errors[0].is_none(), "b-object must proceed: {errors:?}");
assert!(deleted[1].object_name.is_empty(), "failed key must not report success");
assert_eq!(deleted[0].object_name, "b-object");
drop(held);
}
#[tokio::test]
async fn overlapping_batches_reach_per_key_order_without_deadlock() {
let (_dirs, store) = seeded_store("lock-batch-overlap").await;
let first = store.clone();
let second = store.clone();
let run = |store: Arc<ECStore>| {
tokio::spawn(async move {
store
.delete_objects_with_tier_delete_journal_and_accounting(
"lock-batch-overlap",
two_key_batch(),
ObjectOptions::default(),
)
.await
})
};
let first = run(first);
let second = run(second);
// Whatever mix of per-key lock outcomes the overlap produces, both
// batches must finish: the batched round is deadlock-free by
// construction (global key order at each lock server).
for batch in [first, second] {
let (_, errors, _) = tokio::time::timeout(Duration::from_secs(30), batch)
.await
.expect("overlapping batches must not deadlock")
.expect("delete task");
for error in errors.iter().flatten() {
let message = error.to_string();
assert!(
message.contains("lock") || message.contains("Lock") || message.contains("namespace"),
"overlap may only surface lock outcomes: {message}"
);
}
}
}
}
+81 -2
View File
@@ -925,6 +925,13 @@ impl MetaCacheEntriesSorted {
const METACACHE_STREAM_VERSION: u8 = 2; const METACACHE_STREAM_VERSION: u8 = 2;
const METACACHE_READ_CHUNK_SIZE: usize = 64 << 10; const METACACHE_READ_CHUNK_SIZE: usize = 64 << 10;
/// Upper bound on one incremental reservation while accumulating a single
/// metacache element. Read rounds double from [`METACACHE_READ_CHUNK_SIZE`]
/// up to this bound so a legitimately large xl.meta does not pay one small
/// read per chunk, while a corrupt length prefix still cannot force a
/// reservation larger than the payload that has already arrived (see
/// rustfs/rustfs#2715 and rustfs/rustfs#9120).
const METACACHE_READ_MAX_CHUNK_SIZE: usize = 4 << 20;
/// msgpack `false`: the entry-stream terminator, and the only byte a legacy /// msgpack `false`: the entry-stream terminator, and the only byte a legacy
/// reader accepts after it (see `MetacacheWriter::close_with_limit_reached`). /// reader accepts after it (see `MetacacheWriter::close_with_limit_reached`).
const METACACHE_TERMINATOR: u8 = 0xc2; const METACACHE_TERMINATOR: u8 = 0xc2;
@@ -1157,13 +1164,14 @@ impl<R: AsyncRead + Unpin> MetacacheReader<R> {
// A whole xl.meta can legitimately exceed the limit on its individual // A whole xl.meta can legitimately exceed the limit on its individual
// msgpack values. Grow only as payload arrives, so a corrupt length // msgpack values. Grow only as payload arrives, so a corrupt length
// prefix cannot cause a speculative allocation of the advertised size. // prefix cannot cause a speculative allocation of the advertised size.
let mut chunk = METACACHE_READ_CHUNK_SIZE;
while self.buf.len() < read_size { while self.buf.len() < read_size {
let start = self.buf.len(); let start = self.buf.len();
let extra = if lookahead { let extra = if lookahead {
let min_chunk = METACACHE_READ_CHUNK_SIZE.saturating_sub(start); let min_chunk = METACACHE_READ_CHUNK_SIZE.saturating_sub(start);
(read_size - start).clamp(min_chunk, METACACHE_READ_CHUNK_SIZE) (read_size - start).clamp(min_chunk, chunk)
} else { } else {
(read_size - start).min(METACACHE_READ_CHUNK_SIZE) (read_size - start).min(chunk)
}; };
if let Err(e) = self.buf.try_reserve(extra) { if let Err(e) = self.buf.try_reserve(extra) {
let err = Error::other(format!("metacache stream: buffer allocation of {extra} bytes failed: {e}")); let err = Error::other(format!("metacache stream: buffer allocation of {extra} bytes failed: {e}"));
@@ -1194,6 +1202,7 @@ impl<R: AsyncRead + Unpin> MetacacheReader<R> {
return Err(err); return Err(err);
} }
} }
chunk = chunk.saturating_mul(2).min(METACACHE_READ_MAX_CHUNK_SIZE);
} }
self.pos = read_size; self.pos = read_size;
@@ -2155,6 +2164,76 @@ mod tests {
} }
} }
/// rustfs/rustfs#9120: a listing stream made of many ordinary entries must
/// decode completely even when the aggregate stream exceeds the per-value
/// `MAX_MSGP_ELEMENT_SIZE` bound that release 1.0.4 applied to every
/// element.
#[tokio::test]
async fn test_reader_wide_listing_aggregate_exceeds_per_value_limit() {
// ~2.9 KiB of metadata per entry matches the per-entry cost observed in
// the #9120 production report; 6,000 entries push the stream past 16 MiB.
const ENTRIES: usize = 6_000;
let metadata = vec![0xab; 2_880];
let entries: Vec<MetaCacheEntry> = (0..ENTRIES)
.map(|i| MetaCacheEntry {
name: format!("bigdir/object-{i:05}"),
metadata: metadata.clone(),
..Default::default()
})
.collect();
let mut output = Cursor::new(Vec::new());
let mut writer = MetacacheWriter::new(&mut output);
writer.write(&entries).await.expect("wide listing must encode");
writer.close().await.expect("stream must close");
let bytes = output.into_inner();
assert!(
bytes.len() > 16 << 20,
"fixture must exceed the per-value limit, got {} bytes",
bytes.len()
);
let mut reader = MetacacheReader::new(Cursor::new(bytes));
let decoded = reader.read_all().await.expect("wide listing must decode");
assert_eq!(decoded, entries);
}
/// The #9120 failure shape seen in production: one entry whose metadata is
/// an xl.meta that grew past 16 MiB with its version history, listed among
/// ordinary neighbors and skipped past without losing the framing.
#[tokio::test]
async fn test_reader_oversized_entry_among_ordinary_neighbors_stays_listable() {
let small = |i: u8| MetaCacheEntry {
name: format!("bigdir/object-{i}"),
metadata: vec![i; 64],
..Default::default()
};
let oversized = MetaCacheEntry {
name: "bigdir/huge-versions".to_string(),
metadata: vec![0xcd; (16 << 20) + 12_345],
..Default::default()
};
let entries = vec![small(0), small(1), oversized.clone(), small(2), small(3)];
let mut output = Cursor::new(Vec::new());
let mut writer = MetacacheWriter::new(&mut output);
writer.write(&entries).await.expect("mixed listing must encode");
writer.close().await.expect("stream must close");
let mut reader = MetacacheReader::new(Cursor::new(output.into_inner()));
assert_eq!(
reader.peek().await.expect("first entry").expect("present"),
entries[0],
"an ordinary entry before the oversized one must decode"
);
reader.skip(1).await.expect("skip the first entry");
assert_eq!(reader.peek().await.expect("second entry").expect("present"), entries[1]);
assert_eq!(
reader.peek().await.expect("oversized entry").expect("present"),
oversized,
"an entry past the per-value limit must round-trip whole"
);
reader.skip(1).await.expect("skipping the oversized entry must stay aligned");
assert_eq!(reader.read_all().await.expect("tail must decode"), entries[3..]);
}
#[test] #[test]
fn file_info_versions_with_free_versions_includes_persisted_tier_cleanup_records() { fn file_info_versions_with_free_versions_includes_persisted_tier_cleanup_records() {
let version_id = Uuid::new_v4(); let version_id = Uuid::new_v4();
+30
View File
@@ -128,6 +128,20 @@ impl Error {
Error::TransientSkip { message: message.into() } Error::TransientSkip { message: message.into() }
} }
/// S3 `SlowDown` ("Please reduce your request rate") is a rate signal.
/// Callers must cut their own concurrency instead of retrying at full speed.
pub(crate) fn is_slow_down(&self) -> bool {
match self {
Error::Storage(error) => matches!(error, EcstoreError::SlowDown | EcstoreError::TooManyOpenFiles),
Error::Disk(error) => matches!(error, DiskError::TooManyOpenFiles),
Error::TransientSkip { message } | Error::Other(message) | Error::TaskExecutionFailed { message } => {
message.contains("Please reduce your request rate")
}
Error::Io(error) => error.kind() == std::io::ErrorKind::WouldBlock,
_ => false,
}
}
/// Whether a heal operation can be retried without changing its inputs. /// Whether a heal operation can be retried without changing its inputs.
pub(crate) fn is_recoverable_heal(&self) -> bool { pub(crate) fn is_recoverable_heal(&self) -> bool {
match classify_heal_error(self) { match classify_heal_error(self) {
@@ -449,6 +463,22 @@ mod tests {
use super::Error; use super::Error;
use crate::heal::{DiskError, EcstoreError}; use crate::heal::{DiskError, EcstoreError};
#[test]
fn slow_down_is_a_rate_signal_across_wrapped_shapes() {
assert!(Error::Storage(EcstoreError::SlowDown).is_slow_down());
assert!(Error::Storage(EcstoreError::TooManyOpenFiles).is_slow_down());
assert!(Error::Disk(DiskError::TooManyOpenFiles).is_slow_down());
assert!(Error::transient_skip("Please reduce your request rate").is_slow_down());
assert!(
Error::TaskExecutionFailed {
message: "Failed to recreate missing object b/o: Storage error: Please reduce your request rate".to_owned(),
}
.is_slow_down()
);
assert!(!Error::Storage(EcstoreError::FileNotFound).is_slow_down());
assert!(!Error::transient_skip("DiskNotFound").is_slow_down());
}
#[test] #[test]
fn pool_metadata_failures_keep_their_typed_recovery_classification() { fn pool_metadata_failures_keep_their_typed_recovery_classification() {
use crate::heal::storage_api::owner::{EcstorePoolMetadataError, EcstorePoolMetadataFailure}; use crate::heal::storage_api::owner::{EcstorePoolMetadataError, EcstorePoolMetadataFailure};
+92 -92
View File
@@ -14,6 +14,7 @@
use crate::error::{HealErrorClass, classify_heal_error}; use crate::error::{HealErrorClass, classify_heal_error};
use crate::heal::{ use crate::heal::{
pacing::HealPageRate,
progress::{HealProgress, add_bytes, increment_counter}, progress::{HealProgress, add_bytes, increment_counter},
resume::{ resume::{
CheckpointManager, CheckpointObjectOutcome, CheckpointObjectOutcomeRecord, ReplacementTargetIdentity, ResumeManager, CheckpointManager, CheckpointObjectOutcome, CheckpointObjectOutcomeRecord, ReplacementTargetIdentity, ResumeManager,
@@ -128,12 +129,12 @@ pub struct ErasureSetHealer {
replacement_target_identities: Option<Arc<[ReplacementTargetIdentity]>>, replacement_target_identities: Option<Arc<[ReplacementTargetIdentity]>>,
replacement_execution: Option<Arc<super::storage::ReplacementExecution>>, replacement_execution: Option<Arc<super::storage::ReplacementExecution>>,
work_parent: Option<super::manager::HealWork>, work_parent: Option<super::manager::HealWork>,
mainline_pacer: Option<Arc<super::pacing::MainlinePacer>>, mainline_pacer: Option<super::pacing::ForegroundPacing>,
} }
async fn acquire_page_permit( async fn acquire_page_permit(
semaphore: Arc<Semaphore>, semaphore: Arc<Semaphore>,
pacer: Option<&super::pacing::MainlinePacer>, rate: &HealPageRate,
cancel: &tokio_util::sync::CancellationToken, cancel: &tokio_util::sync::CancellationToken,
) -> Result<tokio::sync::OwnedSemaphorePermit> { ) -> Result<tokio::sync::OwnedSemaphorePermit> {
let acquire = || async { let acquire = || async {
@@ -146,18 +147,16 @@ async fn acquire_page_permit(
let mut paid_pause = false; let mut paid_pause = false;
loop { loop {
let permit = acquire().await?; let permit = acquire().await?;
if let Some(pacer) = pacer { // Keep the real permit on the low-pressure path. Every acquisition
// Keep the real permit on the low-pressure path. Every acquisition // gets a fresh decision, including a waiter that queued a second
// gets a fresh decision, including a waiter that queued a second // time. One completed pause is a bounded minimum-progress grant.
// time. One completed pause is a bounded minimum-progress grant. match rate.admission_decision() {
match pacer.admission_decision() { super::pacing::PacingDecision::Wait(pressure) if !paid_pause => {
super::pacing::PacingDecision::Wait(pressure) if !paid_pause => { drop(permit);
drop(permit); paid_pause = rate.wait_after_admission(cancel, pressure).await?;
paid_pause = pacer.wait_after_admission(cancel, pressure).await?; continue;
continue;
}
_ => {}
} }
_ => {}
} }
return Ok(permit); return Ok(permit);
} }
@@ -166,7 +165,7 @@ async fn acquire_page_permit(
#[cfg(test)] #[cfg(test)]
mod mainline_pacing_tests { mod mainline_pacing_tests {
use super::*; use super::*;
use crate::heal::pacing::{MainlinePacer, TestPressure}; use crate::heal::pacing::{ForegroundPacing, HealPageRate, TestPressure};
use rustfs_concurrency::WorkloadClass; use rustfs_concurrency::WorkloadClass;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -180,15 +179,15 @@ mod mainline_pacing_tests {
.await .await
.expect("existing object owns capacity"); .expect("existing object owns capacity");
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0)); let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0));
let pacer = Arc::new( let pacer =
MainlinePacer::new(provider.clone(), 80, 80, Duration::from_millis(250), HealRequestSource::Admin).expect("pacer"), ForegroundPacing::new(provider.clone(), 80, 80, Duration::from_millis(250), HealRequestSource::Admin).expect("pacer");
); let rate = Arc::new(HealPageRate::new(1, Some(pacer)));
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
let waiting = tokio::spawn({ let waiting = tokio::spawn({
let semaphore = semaphore.clone(); let semaphore = semaphore.clone();
let pacer = pacer.clone(); let rate = rate.clone();
let cancel = cancel.clone(); let cancel = cancel.clone();
async move { acquire_page_permit(semaphore, Some(&pacer), &cancel).await } async move { acquire_page_permit(semaphore, &rate, &cancel).await }
}); });
tokio::task::yield_now().await; tokio::task::yield_now().await;
provider.active.store(100, Ordering::SeqCst); provider.active.store(100, Ordering::SeqCst);
@@ -200,12 +199,13 @@ mod mainline_pacing_tests {
assert_eq!(semaphore.available_permits(), 1); assert_eq!(semaphore.available_permits(), 1);
let deadline = tokio::time::timeout( let deadline = tokio::time::timeout(
Duration::from_millis(10), Duration::from_millis(10),
acquire_page_permit(semaphore.clone(), Some(&pacer), &CancellationToken::new()), acquire_page_permit(semaphore.clone(), &rate, &CancellationToken::new()),
) )
.await; .await;
assert!(deadline.is_err()); assert!(deadline.is_err());
assert_eq!(semaphore.available_permits(), 1, "deadline must release all permits"); assert_eq!(semaphore.available_permits(), 1, "deadline must release all permits");
let permit = acquire_page_permit(semaphore.clone(), None, &CancellationToken::new()) let unpaced = HealPageRate::new(1, None);
let permit = acquire_page_permit(semaphore.clone(), &unpaced, &CancellationToken::new())
.await .await
.expect("unpaced admission"); .expect("unpaced admission");
assert_eq!(semaphore.available_permits(), 0, "disabling pacing cannot disable the hard cap"); assert_eq!(semaphore.available_permits(), 0, "disabling pacing cannot disable the hard cap");
@@ -221,10 +221,11 @@ mod mainline_pacing_tests {
let occupied = semaphore.clone().acquire_owned().await.expect("queue both waiters"); let occupied = semaphore.clone().acquire_owned().await.expect("queue both waiters");
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0)); let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0));
let pause = Duration::from_millis(250); let pause = Duration::from_millis(250);
let pacer = MainlinePacer::new(provider.clone(), 80, 80, pause, HealRequestSource::Admin).expect("pacer"); let pacer = ForegroundPacing::new(provider.clone(), 80, 80, pause, HealRequestSource::Admin).expect("pacer");
let rate = HealPageRate::new(1, Some(pacer));
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
let mut first = Box::pin(acquire_page_permit(semaphore.clone(), Some(&pacer), &cancel)); let mut first = Box::pin(acquire_page_permit(semaphore.clone(), &rate, &cancel));
let mut second = Box::pin(acquire_page_permit(semaphore.clone(), Some(&pacer), &cancel)); let mut second = Box::pin(acquire_page_permit(semaphore.clone(), &rate, &cancel));
assert!(futures::poll!(first.as_mut()).is_pending()); assert!(futures::poll!(first.as_mut()).is_pending());
assert!(futures::poll!(second.as_mut()).is_pending()); assert!(futures::poll!(second.as_mut()).is_pending());
drop(occupied); drop(occupied);
@@ -329,6 +330,20 @@ impl ErasureSetHealer {
} }
} }
/// Page fan-out for one whole erasure-set pass (stable across buckets).
fn pass_page_concurrency_limit(&self) -> usize {
if self.source == HealRequestSource::AutoHeal && self.replacement_task_id.is_some() && self.heal_opts.no_lock {
// Versions of the same object must not race without its namespace lock.
1
} else {
Self::effective_heal_page_object_concurrency_for_source(
self.source,
self.heal_opts.scan_mode,
self.replacement_task_id.is_some(),
)
}
}
/// Map the shared heal error class onto this healer's per-object actions. /// Map the shared heal error class onto this healer's per-object actions.
/// ///
/// `classify_heal_error` is the only classifier; this adapter decides what /// `classify_heal_error` is the only classifier; this adapter decides what
@@ -373,7 +388,7 @@ impl ErasureSetHealer {
} }
} }
pub(crate) fn with_mainline_pacer(mut self, pacer: Option<Arc<super::pacing::MainlinePacer>>) -> Self { pub(crate) fn with_mainline_pacer(mut self, pacer: Option<super::pacing::ForegroundPacing>) -> Self {
self.mainline_pacer = pacer; self.mainline_pacer = pacer;
self self
} }
@@ -464,12 +479,7 @@ impl ErasureSetHealer {
None None
}; };
let buckets = admin_buckets.as_deref().unwrap_or(buckets); let buckets = admin_buckets.as_deref().unwrap_or(buckets);
if checkpoint_manager if checkpoint_manager.admin_completed().await {
.get_checkpoint()
.await
.admin
.is_some_and(|admin| admin.completed)
{
self.initialize_progress(buckets, &resume_manager.get_state().await).await; self.initialize_progress(buckets, &resume_manager.get_state().await).await;
return Ok(()); return Ok(());
} }
@@ -490,14 +500,7 @@ impl ErasureSetHealer {
} }
if self.admin_task.is_some() { if self.admin_task.is_some() {
let mut admin = checkpoint_manager checkpoint_manager.complete_admin().await?;
.get_checkpoint()
.await
.admin
.ok_or_else(|| Error::InvalidCheckpoint("Missing administrator outcome at completion".to_string()))?;
admin.completed = true;
admin.outcome.finish(None);
checkpoint_manager.set_admin(admin).await?;
} else { } else {
checkpoint_manager.cleanup().await?; checkpoint_manager.cleanup().await?;
resume_manager.cleanup().await?; resume_manager.cleanup().await?;
@@ -718,7 +721,10 @@ impl ErasureSetHealer {
} }
return Err(Error::DanglingDeleteDeferred { retry_not_before }); return Err(Error::DanglingDeleteDeferred { retry_not_before });
} }
let checkpoint = checkpoint_manager.get_checkpoint().await; // One rate controller for the whole pass: SlowDown cool-down and page
// fan-out persist across buckets instead of resetting at each one.
let page_rate = Arc::new(HealPageRate::new(self.pass_page_concurrency_limit(), self.mainline_pacer.clone()));
let (current_bucket_index, current_object_index) = checkpoint_manager.resume_position().await;
debug!( debug!(
target: "rustfs::heal::erasure_healer", target: "rustfs::heal::erasure_healer",
@@ -726,14 +732,17 @@ impl ErasureSetHealer {
component = LOG_COMPONENT_HEAL, component = LOG_COMPONENT_HEAL,
subsystem = LOG_SUBSYSTEM_ERASURE_HEALER, subsystem = LOG_SUBSYSTEM_ERASURE_HEALER,
set_disk_id, set_disk_id,
current_bucket_index = checkpoint.current_bucket_index, current_bucket_index,
current_object_index = checkpoint.current_object_index, current_object_index,
state = "resuming", state = "resuming",
"Erasure set resumed" "Erasure set resumed"
); );
// 2. initialize progress // 2. initialize progress
self.initialize_progress(buckets, &state).await; self.initialize_progress(buckets, &state).await;
// Snapshot before any baseline write: set_progress_baseline overwrites
// the saved generation, which would hide a real generation mismatch.
let checkpoint = checkpoint_manager.progress_snapshot().await;
let (baseline_known, baseline_count, baseline_size, baseline_generation) = { let (baseline_known, baseline_count, baseline_size, baseline_generation) = {
let baseline = self.progress.read().await; let baseline = self.progress.read().await;
( (
@@ -760,17 +769,7 @@ impl ErasureSetHealer {
let mut successful_objects = state.successful_objects; let mut successful_objects = state.successful_objects;
let mut failed_objects = state.failed_objects; let mut failed_objects = state.failed_objects;
let mut skipped_objects = state.skipped_objects; let mut skipped_objects = state.skipped_objects;
let checkpoint_has_progress = checkpoint.baseline_known let checkpoint_has_progress = checkpoint.has_progress();
|| checkpoint.successful_objects > 0
|| checkpoint.failed_object_count > 0
|| checkpoint.skipped_object_count > 0
|| checkpoint.skipped_new_versions > 0
|| checkpoint.skipped_ilm_expired > 0
|| checkpoint.processed_bytes > 0
|| checkpoint.total_objects > 0
|| checkpoint.total_bytes > 0
|| checkpoint.baseline_generation.is_some()
|| checkpoint.counter_unknown;
let checkpoint_generation_mismatch = checkpoint.baseline_known && checkpoint.baseline_generation != baseline_generation; let checkpoint_generation_mismatch = checkpoint.baseline_known && checkpoint.baseline_generation != baseline_generation;
let mut restored_counter_unknown = state.counter_unknown || checkpoint.counter_unknown; let mut restored_counter_unknown = state.counter_unknown || checkpoint.counter_unknown;
if checkpoint_has_progress { if checkpoint_has_progress {
@@ -854,6 +853,7 @@ impl ErasureSetHealer {
resume_manager, resume_manager,
checkpoint_manager, checkpoint_manager,
state.start_time, state.start_time,
&page_rate,
) )
.await; .await;
@@ -1020,14 +1020,14 @@ impl ErasureSetHealer {
if failed_objects > 0 || skipped_objects > 0 || failed_buckets > 0 { if failed_objects > 0 || skipped_objects > 0 || failed_buckets > 0 {
if self.replacement_task_id.is_some() { if self.replacement_task_id.is_some() {
let state = resume_manager.get_state().await; let state = resume_manager.get_state().await;
let checkpoint = checkpoint_manager.get_checkpoint().await; let (grace_objects, retry_not_before, counter_unknown) = checkpoint_manager.dangling_delete_grace().await;
if state.can_retry() if state.can_retry()
&& failed_objects == 0 && failed_objects == 0
&& failed_buckets == 0 && failed_buckets == 0
&& skipped_objects > 0 && skipped_objects > 0
&& skipped_objects == checkpoint.dangling_delete_grace_objects && skipped_objects == grace_objects
&& !checkpoint.counter_unknown && !counter_unknown
&& let Some(retry_not_before) = checkpoint.dangling_delete_retry_not_before && let Some(retry_not_before) = retry_not_before
{ {
// Publish the generation's rewind and wait first. If the // Publish the generation's rewind and wait first. If the
// checkpoint reset is interrupted, initialization reapplies // checkpoint reset is interrupted, initialization reapplies
@@ -1167,14 +1167,9 @@ impl ErasureSetHealer {
let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}"); let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}");
let checkpoint_key = compose_key(&object_key, None); let checkpoint_key = compose_key(&object_key, None);
let checkpoint = checkpoint_manager.get_checkpoint().await; if checkpoint_manager.is_object_settled(&checkpoint_key).await {
if checkpoint.processed_objects.contains(&checkpoint_key)
|| checkpoint.failed_objects.contains(&checkpoint_key)
|| checkpoint.skipped_objects.contains(&checkpoint_key)
{
return Ok(()); return Ok(());
} }
drop(checkpoint);
self.verify_replacement_identity_fence("pool metadata").await?; self.verify_replacement_identity_fence("pool metadata").await?;
resume_manager resume_manager
@@ -1428,7 +1423,7 @@ impl ErasureSetHealer {
/// Heal one bucket's ordinary objects and report unresolved delete recovery separately. /// Heal one bucket's ordinary objects and report unresolved delete recovery separately.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[tracing::instrument(skip(self, current_object_index, processed_objects, successful_objects, failed_objects, skipped_objects, resume_manager, checkpoint_manager), fields(bucket = %bucket, bucket_index = bucket_index))] #[tracing::instrument(skip(self, current_object_index, processed_objects, successful_objects, failed_objects, skipped_objects, resume_manager, checkpoint_manager, page_rate), fields(bucket = %bucket, bucket_index = bucket_index))]
async fn heal_bucket_with_resume( async fn heal_bucket_with_resume(
&self, &self,
bucket: &str, bucket: &str,
@@ -1442,6 +1437,7 @@ impl ErasureSetHealer {
resume_manager: &ResumeManager, resume_manager: &ResumeManager,
checkpoint_manager: &CheckpointManager, checkpoint_manager: &CheckpointManager,
started_at_secs: u64, started_at_secs: u64,
page_rate: &Arc<HealPageRate>,
) -> Result<bool> { ) -> Result<bool> {
debug!( debug!(
target: "rustfs::heal::erasure_healer", target: "rustfs::heal::erasure_healer",
@@ -1491,17 +1487,6 @@ impl ErasureSetHealer {
// detects a non-advancing cursor even though next_marker embeds a // detects a non-advancing cursor even though next_marker embeds a
// transient cache id that changes between identical pages. // transient cache id that changes between identical pages.
let mut previous_page_last: Option<(String, Option<String>)> = None; let mut previous_page_last: Option<(String, Option<String>)> = None;
let page_concurrency_limit =
if self.source == HealRequestSource::AutoHeal && self.replacement_task_id.is_some() && self.heal_opts.no_lock {
// Versions of the same object must not race without its namespace lock.
1
} else {
Self::effective_heal_page_object_concurrency_for_source(
self.source,
self.heal_opts.scan_mode,
self.replacement_task_id.is_some(),
)
};
let in_flight = Arc::new(AtomicUsize::new(0)); let in_flight = Arc::new(AtomicUsize::new(0));
// Per-bucket sample caps for per-object warn! lines: a flapping rebuild // Per-bucket sample caps for per-object warn! lines: a flapping rebuild
// disk can fail/skip hundreds of thousands of versions in one sweep, so // disk can fail/skip hundreds of thousands of versions in one sweep, so
@@ -1523,9 +1508,7 @@ impl ErasureSetHealer {
let include_lifecycle_object_info = lifecycle_expiry_context.is_some(); let include_lifecycle_object_info = lifecycle_expiry_context.is_some();
loop { loop {
if let Some(pacer) = &self.mainline_pacer { page_rate.wait_before_page(&self.cancel_token).await?;
pacer.wait(&self.cancel_token).await?;
}
self.verify_replacement_identity_fence("page scan").await?; self.verify_replacement_identity_fence("page scan").await?;
if let Some(expected) = incarnation { if let Some(expected) = incarnation {
self.storage.validate_bucket_incarnation(bucket, Some(expected)).await?; self.storage.validate_bucket_incarnation(bucket, Some(expected)).await?;
@@ -1547,9 +1530,8 @@ impl ErasureSetHealer {
.await? .await?
}; };
let page_is_empty = objects.is_empty(); let page_is_empty = objects.is_empty();
let checkpoint = checkpoint_manager.get_checkpoint().await;
let page_resume_index = *current_object_index; let page_resume_index = *current_object_index;
let semaphore = Arc::new(Semaphore::new(page_concurrency_limit)); let semaphore = Arc::new(Semaphore::new(page_rate.effective_concurrency()));
let mut page_tasks = FuturesUnordered::new(); let mut page_tasks = FuturesUnordered::new();
let mut completed_in_page = 0usize; let mut completed_in_page = 0usize;
@@ -1563,10 +1545,7 @@ impl ErasureSetHealer {
// Per-version dedup identity — the single canonical key. // Per-version dedup identity — the single canonical key.
let key = compose_key(&item.name, item.version_id.as_deref()); let key = compose_key(&item.name, item.version_id.as_deref());
if checkpoint.processed_objects.contains(&key) if checkpoint_manager.is_object_settled(&key).await {
|| checkpoint.failed_objects.contains(&key)
|| checkpoint.skipped_objects.contains(&key)
{
continue; continue;
} }
@@ -1724,7 +1703,7 @@ impl ErasureSetHealer {
let target_endpoints = self.target_endpoints.clone(); let target_endpoints = self.target_endpoints.clone();
let replacement_commit_evidence_required = self.replacement_task_id.is_some(); let replacement_commit_evidence_required = self.replacement_task_id.is_some();
let retry_replacement = replacement_commit_evidence_required && self.source == HealRequestSource::AutoHeal; let retry_replacement = replacement_commit_evidence_required && self.source == HealRequestSource::AutoHeal;
let mainline_pacer = self.mainline_pacer.clone(); let page_rate = page_rate.clone();
let admin_identity = self.admin_identity(bucket, &object_name, version_id.as_deref(), incarnation); let admin_identity = self.admin_identity(bucket, &object_name, version_id.as_deref(), incarnation);
let admin_task_id = self.admin_task.as_ref().map(|task| task.id.clone()); let admin_task_id = self.admin_task.as_ref().map(|task| task.id.clone());
@@ -1736,14 +1715,15 @@ impl ErasureSetHealer {
.map(|_| (dedup_key.clone(), object_name.clone(), version_id.clone())); .map(|_| (dedup_key.clone(), object_name.clone(), version_id.clone()));
let work = async move { let work = async move {
let mut retries = 0; let mut retries = 0;
let mut saw_slow_down = false;
let mut admin_identity = admin_identity; let mut admin_identity = admin_identity;
let mut admin_task_id = admin_task_id; let mut admin_task_id = admin_task_id;
loop { loop {
let permit = acquire_page_permit(semaphore.clone(), mainline_pacer.as_deref(), &cancel_token).await; let permit = acquire_page_permit(semaphore.clone(), &page_rate, &cancel_token).await;
let permit = match permit { let permit = match permit {
Ok(permit) => permit, Ok(permit) => permit,
Err(err) => return (dedup_key, object_name, version_id, (0, Err(err)), None), Err(err) => return (dedup_key, object_name, version_id, (0, Err(err), saw_slow_down), None),
}; };
let in_flight_guard = PageConcurrencyGuard::new(in_flight.clone(), set_label.clone()); let in_flight_guard = PageConcurrencyGuard::new(in_flight.clone(), set_label.clone());
@@ -1751,7 +1731,8 @@ impl ErasureSetHealer {
if let (Some(identity), Some(task_id)) = (admin_identity.take(), admin_task_id.take()) { if let (Some(identity), Some(task_id)) = (admin_identity.take(), admin_task_id.take()) {
let (result, canonical) = let (result, canonical) =
admin::heal_object(storage.as_ref(), &heal_opts, identity, &task_id, &cancel_token).await; admin::heal_object(storage.as_ref(), &heal_opts, identity, &task_id, &cancel_token).await;
return (dedup_key, object_name, version_id, result, canonical); let saw_slow_down = saw_slow_down || matches!(&result.1, Err(err) if err.is_slow_down());
return (dedup_key, object_name, version_id, (result.0, result.1, saw_slow_down), canonical);
} }
// Always go through heal_object. Genuine absence flows through // Always go through heal_object. Genuine absence flows through
@@ -1853,18 +1834,23 @@ impl ErasureSetHealer {
drop(in_flight_guard); drop(in_flight_guard);
drop(permit); drop(permit);
if matches!(&result.1, Err(err) if err.is_slow_down()) {
saw_slow_down = true;
}
if retry_replacement if retry_replacement
&& retries < REPLACEMENT_OBJECT_MAX_RETRIES && retries < REPLACEMENT_OBJECT_MAX_RETRIES
&& matches!(&result.1, Err(Error::TransientSkip { .. })) && matches!(&result.1, Err(Error::TransientSkip { .. }))
{ {
retries += 1; retries += 1;
tokio::select! { tokio::select! {
_ = cancel_token.cancelled() => return (dedup_key, object_name, version_id, (0, Err(Error::TaskCancelled)), None), _ = cancel_token.cancelled() => {
return (dedup_key, object_name, version_id, (0, Err(Error::TaskCancelled), saw_slow_down), None);
}
_ = tokio::time::sleep(Duration::from_millis(250 * u64::from(retries))) => {} _ = tokio::time::sleep(Duration::from_millis(250 * u64::from(retries))) => {}
} }
continue; continue;
} }
return (dedup_key, object_name, version_id, result, None); return (dedup_key, object_name, version_id, (result.0, result.1, saw_slow_down), None);
} }
}; };
page_tasks.push(async move { page_tasks.push(async move {
@@ -1875,7 +1861,7 @@ impl ErasureSetHealer {
}; };
match result { match result {
Ok(result) => result, Ok(result) => result,
Err(error) => (key, object, version, (0, Err(error)), None), Err(error) => (key, object, version, (0, Err(error), false), None),
} }
} else { } else {
work.await work.await
@@ -1884,7 +1870,16 @@ impl ErasureSetHealer {
} }
while let Some((key, object, version_id, result, canonical)) = page_tasks.next().await { while let Some((key, object, version_id, result, canonical)) = page_tasks.next().await {
let (object_size, result) = result; let (object_size, result, saw_slow_down) = result;
// Exactly one rate observation per finished object, including
// SlowDown that later succeeded on a replacement retry.
page_rate.observe_object_outcome(
&match &result {
Ok(_) => Ok(true),
Err(err) => Err(err),
},
saw_slow_down,
);
let mut telemetry_unknown = false; let mut telemetry_unknown = false;
let checkpoint_outcome = match result { let checkpoint_outcome = match result {
Ok(true) => { Ok(true) => {
@@ -2482,6 +2477,7 @@ mod resume_loop_tests {
use super::{ use super::{
ErasureSetHealer, NANOS_PER_SECOND, NEW_VERSION_SKIP_GRACE_SECS, should_skip_new_version, target_outcomes_complete, ErasureSetHealer, NANOS_PER_SECOND, NEW_VERSION_SKIP_GRACE_SECS, should_skip_new_version, target_outcomes_complete,
}; };
use crate::heal::pacing::HealPageRate;
use crate::heal::progress::HealProgress; use crate::heal::progress::HealProgress;
use crate::heal::resume::{ use crate::heal::resume::{
CheckpointManager, CheckpointObjectOutcome, CheckpointObjectOutcomeRecord, RESUME_CHECKPOINT_FILE, CheckpointManager, CheckpointObjectOutcome, CheckpointObjectOutcomeRecord, RESUME_CHECKPOINT_FILE,
@@ -3063,6 +3059,7 @@ mod resume_loop_tests {
let mut successful = 0u64; let mut successful = 0u64;
let mut failed = 0u64; let mut failed = 0u64;
let mut skipped = 0u64; let mut skipped = 0u64;
let page_rate = Arc::new(HealPageRate::new(env.healer.pass_page_concurrency_limit(), None));
let result = env let result = env
.healer .healer
.heal_bucket_with_resume( .heal_bucket_with_resume(
@@ -3077,6 +3074,7 @@ mod resume_loop_tests {
&env.resume, &env.resume,
&env.checkpoint, &env.checkpoint,
state.start_time, state.start_time,
&page_rate,
) )
.await; .await;
(processed, successful, failed, skipped, result.map(|_| ())) (processed, successful, failed, skipped, result.map(|_| ()))
@@ -3144,6 +3142,7 @@ mod resume_loop_tests {
let mut skipped = 0; let mut skipped = 0;
let started_at = env.resume.get_state().await.start_time; let started_at = env.resume.get_state().await.start_time;
let page_rate = Arc::new(HealPageRate::new(healer.pass_page_concurrency_limit(), None));
let error = healer let error = healer
.heal_bucket_with_resume( .heal_bucket_with_resume(
"b", "b",
@@ -3157,6 +3156,7 @@ mod resume_loop_tests {
&env.resume, &env.resume,
&env.checkpoint, &env.checkpoint,
started_at, started_at,
&page_rate,
) )
.await .await
.expect_err("a remounted target must not begin a new page scan"); .expect_err("a remounted target must not begin a new page scan");
+8 -1
View File
@@ -810,7 +810,14 @@ fn publish_heal_queue_length(queue: &PriorityHealQueue) {
fn active_heal_for_dedup_key(active_heals: &HashMap<String, Arc<HealTask>>, key: &str) -> Option<(String, HealType)> { fn active_heal_for_dedup_key(active_heals: &HashMap<String, Arc<HealTask>>, key: &str) -> Option<(String, HealType)> {
active_heals active_heals
.iter() .iter()
.find(|(_, task)| PriorityHealQueue::make_dedup_key_for_scope(&task.heal_type, &task.options) == key) .find(|(_, task)| {
PriorityHealQueue::make_dedup_key_for_owner(
&task.heal_type,
&task.options,
task.source,
task.expected_mrf_bucket_incarnation_id,
) == key
})
.map(|(task_id, task)| (task_id.clone(), task.heal_type.clone())) .map(|(task_id, task)| (task_id.clone(), task.heal_type.clone()))
} }
+98 -1
View File
@@ -611,7 +611,104 @@ impl PriorityHealQueue {
/// Create a deduplication key from a heal request /// Create a deduplication key from a heal request
pub(super) fn make_dedup_key(request: &HealRequest) -> String { pub(super) fn make_dedup_key(request: &HealRequest) -> String {
Self::make_dedup_key_for_scope(&request.heal_type, &request.options) Self::make_dedup_key_for_owner(
&request.heal_type,
&request.options,
request.source,
request.expected_mrf_bucket_incarnation_id,
)
}
/// Bound MRF work can only merge with work for the same bucket lifetime.
/// Length-prefix user-controlled components so a key cannot forge another
/// version, scope, or incarnation through delimiter characters.
pub(super) fn make_dedup_key_for_owner(
heal_type: &HealType,
options: &HealOptions,
source: HealRequestSource,
incarnation: Option<Uuid>,
) -> String {
if source == HealRequestSource::Mrf
&& let Some(incarnation) = incarnation
{
use std::fmt::Write as _;
let (pool, set) = heal_scope_indices(heal_type, options);
let mut key = format!("mrf-bound-v1:{incarnation}:{pool:?}:{set:?}:");
let mut part = |value: &str| {
// Formatting into a String is infallible.
let _ = write!(key, "{}:{value}", value.len());
};
match heal_type {
HealType::Object {
bucket,
object,
version_id,
} => {
part("object");
part(bucket);
part(object);
match version_id {
None => part("none"),
Some(version_id) => {
part("some");
part(version_id);
}
}
}
HealType::Metadata { bucket, object } => {
part("metadata");
part(bucket);
part(object);
}
HealType::ECDecode {
bucket,
object,
version_id,
} => {
part("ecdecode");
part(bucket);
part(object);
match version_id {
None => part("none"),
Some(version_id) => {
part("some");
part(version_id);
}
}
}
HealType::DeleteMarkerPurge {
bucket,
object,
version_id,
purge,
} => {
part("delete-marker-purge");
part(bucket);
part(object);
part(version_id);
part(&purge.bucket_incarnation_id.to_string());
part(&purge.marker_incarnation_id.to_string());
part(&base64_simd::URL_SAFE_NO_PAD.encode_to_string(purge.marker_identity));
}
HealType::Bucket { bucket } => {
part("bucket");
part(bucket);
}
HealType::Prefix { bucket, prefix } => {
part("prefix");
part(bucket);
part(prefix);
}
// Cluster and erasure-set work are not bucket-bound; preserve
// their existing keys, including contains_erasure_set lookups.
HealType::Cluster | HealType::ErasureSet { .. } => {
return Self::make_dedup_key_for_scope(heal_type, options);
}
}
return key;
}
Self::make_dedup_key_for_scope(heal_type, options)
} }
pub(super) fn make_dedup_key_for_scope(heal_type: &HealType, options: &HealOptions) -> String { pub(super) fn make_dedup_key_for_scope(heal_type: &HealType, options: &HealOptions) -> String {
+2 -3
View File
@@ -34,7 +34,7 @@ pub(super) fn running_mainline_pacer(
request: &HealRequest, request: &HealRequest,
config: &HealConfig, config: &HealConfig,
provider: &Option<WorkloadSnapshotProviderRef>, provider: &Option<WorkloadSnapshotProviderRef>,
) -> Option<Arc<crate::heal::pacing::MainlinePacer>> { ) -> Option<crate::heal::pacing::ForegroundPacing> {
let pace_running = request.source == HealRequestSource::Admin let pace_running = request.source == HealRequestSource::Admin
|| (request.source == HealRequestSource::AutoHeal || (request.source == HealRequestSource::AutoHeal
&& matches!(&request.heal_type, HealType::ErasureSet { .. }) && matches!(&request.heal_type, HealType::ErasureSet { .. })
@@ -43,14 +43,13 @@ pub(super) fn running_mainline_pacer(
return None; return None;
} }
provider.as_ref().and_then(|provider| { provider.as_ref().and_then(|provider| {
crate::heal::pacing::MainlinePacer::new( crate::heal::pacing::ForegroundPacing::new(
provider.clone(), provider.clone(),
config.mainline_read_utilization_high_percent, config.mainline_read_utilization_high_percent,
config.mainline_write_utilization_high_percent, config.mainline_write_utilization_high_percent,
config.mainline_max_sleep, config.mainline_max_sleep,
request.source, request.source,
) )
.map(Arc::new)
}) })
} }
+447
View File
@@ -117,6 +117,453 @@ async fn process_manager_queue_once(manager: &HealManager) {
struct MockStorage; struct MockStorage;
fn incarnation_bound_mrf_fallback(incarnation: Uuid) -> HealRequest {
let mut request = HealRequest::object("mrf-recreated-bucket".to_owned(), "object".to_owned(), None);
request.source = HealRequestSource::Mrf;
request.expected_mrf_bucket_incarnation_id = Some(incarnation);
request.options.pool_index = Some(0);
request.options.set_index = Some(0);
request
}
async fn assert_recreated_bucket_fallback_has_its_own_owner(existing_state: &str) {
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
let manager = HealManager::new_without_root_recovery_for_test(Arc::clone(&storage), None);
let old_incarnation = Uuid::new_v4();
let new_incarnation = Uuid::new_v4();
let old_request = incarnation_bound_mrf_fallback(old_incarnation);
let old_id = old_request.id.clone();
match existing_state {
"queued" => {
let receipt = manager
.submit_heal_request_with_receipt(old_request)
.await
.expect("old fallback must be admitted");
assert_eq!(receipt.result, HealAdmissionResult::Accepted);
}
"active" => {
manager
.active_heals
.lock()
.await
.insert(old_id.clone(), Arc::new(HealTask::from_request(old_request, Arc::clone(&storage))));
}
"retrying" => {
manager.retrying_heals.lock().await.insert(
old_id.clone(),
RetryingHeal {
request: old_request,
error: "retry pending".to_owned(),
cancel_token: CancellationToken::new(),
},
);
}
other => panic!("unknown fixture state: {other}"),
}
let new_request = incarnation_bound_mrf_fallback(new_incarnation);
let new_id = new_request.id.clone();
assert_ne!(new_id, old_id, "the fallback must use a new request ID");
let receipt = manager
.submit_heal_request_with_receipt(new_request)
.await
.expect("new incarnation admission must be decided");
assert_eq!(
receipt.result,
HealAdmissionResult::Accepted,
"a {existing_state} fallback for an older bucket incarnation cannot own the new repair"
);
assert_eq!(receipt.task_id, new_id, "the new incarnation must retain its own task identity");
let queue = manager.heal_queue.lock().await;
assert!(
queue
.requests()
.any(|request| { request.id == new_id && request.expected_mrf_bucket_incarnation_id == Some(new_incarnation) })
);
if existing_state == "queued" {
assert!(
queue
.requests()
.any(|request| { request.id == old_id && request.expected_mrf_bucket_incarnation_id == Some(old_incarnation) })
);
}
}
#[tokio::test]
async fn mrf_fallback_recreated_bucket_does_not_merge_into_queued_old_incarnation() {
assert_recreated_bucket_fallback_has_its_own_owner("queued").await;
}
#[tokio::test]
async fn mrf_fallback_recreated_bucket_does_not_merge_into_active_old_incarnation() {
assert_recreated_bucket_fallback_has_its_own_owner("active").await;
}
#[tokio::test]
async fn mrf_fallback_recreated_bucket_does_not_merge_into_retrying_old_incarnation() {
assert_recreated_bucket_fallback_has_its_own_owner("retrying").await;
}
fn incarnation_bound_mrf_fallback_for_version(incarnation: Uuid, version_id: Option<&str>) -> HealRequest {
let mut request = incarnation_bound_mrf_fallback(incarnation);
if let HealType::Object { version_id: id, .. } = &mut request.heal_type {
*id = version_id.map(str::to_owned);
} else {
unreachable!("fallback fixture must be an object request");
}
request
}
#[tokio::test]
async fn versioned_mrf_fallbacks_merge_into_queued_owner() {
for version in [Uuid::nil(), Uuid::new_v4()] {
let manager = HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None);
let incarnation = Uuid::new_v4();
let version_id = version.to_string();
let original = incarnation_bound_mrf_fallback_for_version(incarnation, Some(&version_id));
let original_id = original.id.clone();
let accepted = manager
.submit_heal_request_with_receipt(original)
.await
.expect("first queued fallback");
assert_eq!(accepted.result, HealAdmissionResult::Accepted);
let merged = manager
.submit_heal_request_with_receipt(incarnation_bound_mrf_fallback_for_version(incarnation, Some(&version_id)))
.await
.expect("duplicate queued fallback");
assert_eq!(merged.result, HealAdmissionResult::Merged);
assert_eq!(merged.task_id, original_id);
assert_eq!(manager.heal_queue.lock().await.len(), 1);
}
}
#[tokio::test]
async fn mrf_fallback_same_incarnation_merges_but_version_and_scope_remain_distinct() {
let manager = HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None);
let incarnation = Uuid::new_v4();
let original = incarnation_bound_mrf_fallback(incarnation);
let original_id = original.id.clone();
assert_eq!(
manager
.submit_heal_request_with_receipt(original)
.await
.expect("first fallback")
.result,
HealAdmissionResult::Accepted
);
let same = manager
.submit_heal_request_with_receipt(incarnation_bound_mrf_fallback(incarnation))
.await
.expect("same-incarnation fallback");
assert_eq!(same.result, HealAdmissionResult::Merged);
assert_eq!(same.task_id, original_id);
let mut versioned = incarnation_bound_mrf_fallback(incarnation);
versioned.heal_type = HealType::Object {
bucket: "mrf-recreated-bucket".to_owned(),
object: "object".to_owned(),
version_id: Some(Uuid::new_v4().to_string()),
};
assert_eq!(
manager
.submit_heal_request_with_receipt(versioned)
.await
.expect("different version")
.result,
HealAdmissionResult::Accepted
);
let mut other_scope = incarnation_bound_mrf_fallback(incarnation);
other_scope.options.pool_index = Some(1);
assert_eq!(
manager
.submit_heal_request_with_receipt(other_scope)
.await
.expect("different pool")
.result,
HealAdmissionResult::Accepted
);
let mut same_id_other_incarnation = incarnation_bound_mrf_fallback(Uuid::new_v4());
same_id_other_incarnation.id = original_id;
assert_eq!(
manager
.submit_heal_request_with_receipt(same_id_other_incarnation)
.await
.expect("same-ID conflict must be decided")
.result,
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
);
}
#[test]
fn mrf_bound_dedup_keys_separate_incarnation_source_and_untrusted_components() {
let incarnation = Uuid::new_v4();
let bound = incarnation_bound_mrf_fallback(incarnation);
let mut unbound = bound.clone();
unbound.expected_mrf_bucket_incarnation_id = None;
let mut non_mrf = bound.clone();
non_mrf.source = HealRequestSource::ReadRepair;
assert_ne!(PriorityHealQueue::make_dedup_key(&bound), PriorityHealQueue::make_dedup_key(&unbound));
assert_eq!(
PriorityHealQueue::make_dedup_key(&non_mrf),
PriorityHealQueue::make_dedup_key_for_scope(&non_mrf.heal_type, &non_mrf.options),
"an unrelated source retains its previous scope key even if the MRF-only field is set"
);
assert_eq!(PriorityHealQueue::make_dedup_key(&non_mrf), PriorityHealQueue::make_dedup_key(&unbound));
let mut left = bound.clone();
left.heal_type = HealType::Object {
bucket: "mrf-recreated-bucket".to_owned(),
object: "part:a".to_owned(),
version_id: Some("b".to_owned()),
};
let mut right = bound.clone();
right.heal_type = HealType::Object {
bucket: "mrf-recreated-bucket".to_owned(),
object: "part".to_owned(),
version_id: Some("a:b".to_owned()),
};
assert_eq!(
PriorityHealQueue::make_dedup_key_for_scope(&left.heal_type, &left.options),
PriorityHealQueue::make_dedup_key_for_scope(&right.heal_type, &right.options),
"the legacy delimiter-only key demonstrates the collision"
);
assert_ne!(
PriorityHealQueue::make_dedup_key(&left),
PriorityHealQueue::make_dedup_key(&right),
"length-prefixed object and version components must not alias"
);
let mut forged = unbound;
forged.heal_type = HealType::Object {
bucket: "mrf-recreated-bucket".to_owned(),
object: PriorityHealQueue::make_dedup_key(&bound),
version_id: None,
};
assert_ne!(PriorityHealQueue::make_dedup_key(&bound), PriorityHealQueue::make_dedup_key(&forged));
}
#[test]
fn mrf_bound_optional_versions_do_not_alias_empty_or_forged_components() {
let incarnation = Uuid::new_v4();
for decode in [false, true] {
let make_request = |object: &str, version_id: Option<&str>| {
let mut request = incarnation_bound_mrf_fallback(incarnation);
let bucket = "mrf-recreated-bucket".to_owned();
let object = object.to_owned();
let version_id = version_id.map(str::to_owned);
request.heal_type = if decode {
HealType::ECDecode {
bucket,
object,
version_id,
}
} else {
HealType::Object {
bucket,
object,
version_id,
}
};
request
};
let absent = make_request("object", None);
let empty = make_request("object", Some(""));
let forged_object = make_request("object4:some0:", None);
let forged_version = make_request("object", Some("4:none"));
let keys = [&absent, &empty, &forged_object, &forged_version].map(PriorityHealQueue::make_dedup_key);
for (index, key) in keys.iter().enumerate() {
assert!(keys.iter().skip(index + 1).all(|other| key != other));
}
let mut queue = PriorityHealQueue::new();
assert_eq!(queue.push(absent.clone()), QueuePushOutcome::Accepted);
assert_eq!(queue.push(empty.clone()), QueuePushOutcome::Accepted);
assert_eq!(queue.push(make_request("object", None)), QueuePushOutcome::Merged);
assert_eq!(queue.push(make_request("object", Some(""))), QueuePushOutcome::Merged);
assert_eq!(queue.len(), 2);
for source in [HealRequestSource::Mrf, HealRequestSource::ReadRepair] {
let mut legacy_absent = absent.clone();
let mut legacy_empty = empty.clone();
legacy_absent.source = source;
legacy_empty.source = source;
if source == HealRequestSource::Mrf {
legacy_absent.expected_mrf_bucket_incarnation_id = None;
legacy_empty.expected_mrf_bucket_incarnation_id = None;
}
assert_eq!(
PriorityHealQueue::make_dedup_key(&legacy_absent),
PriorityHealQueue::make_dedup_key(&legacy_empty),
"unbound and non-MRF work retain the legacy empty-version key"
);
}
}
}
#[test]
fn mrf_bound_queue_releases_each_incarnation_representative_independently() {
let mut queue = PriorityHealQueue::new();
let old = incarnation_bound_mrf_fallback(Uuid::new_v4());
let old_id = old.id.clone();
let old_key = PriorityHealQueue::make_dedup_key(&old);
let new = incarnation_bound_mrf_fallback(Uuid::new_v4());
let new_id = new.id.clone();
let new_key = PriorityHealQueue::make_dedup_key(&new);
let mut forced_old = old.clone();
forced_old.id = Uuid::new_v4().to_string();
forced_old.force_start = true;
let forced_id = forced_old.id.clone();
assert_eq!(queue.push(old), QueuePushOutcome::Accepted);
assert_eq!(queue.push(new), QueuePushOutcome::Accepted);
assert_eq!(queue.push(forced_old), QueuePushOutcome::Accepted);
assert_eq!(queue.queued_request_id_for_dedup_key(&old_key), Some(old_id.as_str()));
assert_eq!(queue.queued_request_id_for_dedup_key(&new_key), Some(new_id.as_str()));
queue
.remove_request_id(&old_id)
.expect("remove the old incarnation representative");
assert_eq!(queue.queued_request_id_for_dedup_key(&old_key), Some(forced_id.as_str()));
assert_eq!(queue.queued_request_id_for_dedup_key(&new_key), Some(new_id.as_str()));
assert_eq!(queue.pop_next().expect("new incarnation stays queued").id, new_id);
assert!(queue.queued_request_id_for_dedup_key(&new_key).is_none());
assert_eq!(queue.pop_next().expect("forced old owner stays queued").id, forced_id);
assert!(queue.queued_request_id_for_dedup_key(&old_key).is_none());
}
#[tokio::test]
async fn durable_mrf_recreated_bucket_is_not_merged_into_queued_old_fallback() {
use rustfs_common::mrf_channel::{MrfIngressResult, MrfIntent, MrfKind, MrfScope, try_rearm_mrf_replay_intent};
let manager = HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None);
let old = incarnation_bound_mrf_fallback(Uuid::new_v4());
assert_eq!(
manager
.submit_heal_request_with_receipt(old)
.await
.expect("old fallback")
.result,
HealAdmissionResult::Accepted
);
let incarnation = Uuid::new_v4();
let request = incarnation_bound_mrf_fallback(incarnation);
let request_id = request.id.clone();
let mut intent = MrfIntent {
bucket: Arc::from("mrf-recreated-bucket"),
object: Arc::from("object"),
version_id: None,
kind: MrfKind::PartialWrite,
delete_marker_purge: None,
scope: Some(MrfScope {
pool_index: 0,
set_index: 0,
}),
lease: None,
enqueued_at_ms: 1,
attempts: 0,
};
assert_eq!(try_rearm_mrf_replay_intent(&mut intent), MrfIngressResult::Enqueued);
let anchor = rustfs_common::mrf_channel::MrfDurableRepairAnchor::from_intent(&intent, incarnation).expect("durable identity");
let target = MrfRepairNoticeTarget {
bucket: Arc::clone(&intent.bucket),
object: Arc::clone(&intent.object),
version_id: intent.version_id,
kind: intent.kind,
scope: intent.scope,
delete_marker_purge: None,
lease: intent.lease,
durable_anchor: Some(anchor.clone()),
};
let receipt = manager
.submit_mrf_heal_request_with_receipt_and_identity(request, target)
.await
.expect("durable admission must be decided");
assert_eq!(
receipt.result,
HealAdmissionResult::Accepted,
"a durable new incarnation needs its own owner"
);
assert_eq!(receipt.task_id, request_id);
assert!(
manager
.heal_queue
.lock()
.await
.requests()
.any(|queued| { queued.id == request_id && queued.expected_mrf_bucket_incarnation_id == Some(incarnation) })
);
assert!(
manager
.try_in_flight_durable_mrf_anchors()
.expect("stable owner snapshot")
.contains(&anchor)
);
}
#[tokio::test]
async fn durable_mrf_same_incarnation_merge_retains_the_durable_anchor() {
use rustfs_common::mrf_channel::{MrfIngressResult, MrfIntent, MrfKind, MrfScope, try_rearm_mrf_replay_intent};
let manager = HealManager::new_without_root_recovery_for_test(Arc::new(MockStorage), None);
let incarnation = Uuid::new_v4();
let object = format!("same-generation-{}", Uuid::new_v4());
let make_request = || {
let mut request = incarnation_bound_mrf_fallback(incarnation);
request.heal_type = HealType::Object {
bucket: "mrf-recreated-bucket".to_owned(),
object: object.clone(),
version_id: None,
};
request
};
let old = make_request();
let old_id = old.id.clone();
assert_eq!(
manager
.submit_heal_request_with_receipt(old)
.await
.expect("old fallback")
.result,
HealAdmissionResult::Accepted
);
let mut intent = MrfIntent {
bucket: Arc::from("mrf-recreated-bucket"),
object: Arc::from(object.as_str()),
version_id: None,
kind: MrfKind::PartialWrite,
delete_marker_purge: None,
scope: Some(MrfScope {
pool_index: 0,
set_index: 0,
}),
lease: None,
enqueued_at_ms: 1,
attempts: 0,
};
assert_eq!(try_rearm_mrf_replay_intent(&mut intent), MrfIngressResult::Enqueued);
let anchor = rustfs_common::mrf_channel::MrfDurableRepairAnchor::from_intent(&intent, incarnation).expect("durable identity");
let target = MrfRepairNoticeTarget {
bucket: Arc::clone(&intent.bucket),
object: Arc::clone(&intent.object),
version_id: intent.version_id,
kind: intent.kind,
scope: intent.scope,
delete_marker_purge: None,
lease: intent.lease,
durable_anchor: Some(anchor.clone()),
};
let merged = manager
.submit_mrf_heal_request_with_receipt_and_identity(make_request(), target)
.await
.expect("durable same-incarnation admission");
assert_eq!(merged.result, HealAdmissionResult::Merged);
assert_eq!(merged.task_id, old_id);
assert!(
manager
.try_in_flight_durable_mrf_anchors()
.expect("stable owner snapshot")
.contains(&anchor)
);
}
fn completed_retention_fixture(completed_at: SystemTime) -> CompletedHealStatus { fn completed_retention_fixture(completed_at: SystemTime) -> CompletedHealStatus {
CompletedHealStatus { CompletedHealStatus {
task_id: "fixture-token".to_string(), task_id: "fixture-token".to_string(),
+232 -13
View File
@@ -7,8 +7,14 @@ use rustfs_concurrency::{
workload::{ForegroundPressure, foreground_pressure}, workload::{ForegroundPressure, foreground_pressure},
}; };
use rustfs_heal_contracts::heal_channel::HealRequestSource; use rustfs_heal_contracts::heal_channel::HealRequestSource;
use std::{sync::Arc, time::Duration}; use std::{
use tokio::{sync::Mutex, time::Instant}; sync::{
Arc,
atomic::{AtomicU32, Ordering},
},
time::Duration,
};
use tokio::{sync::Mutex as AsyncMutex, time::Instant};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
#[derive(Default)] #[derive(Default)]
@@ -22,17 +28,19 @@ pub(crate) enum PacingDecision {
Wait(Option<ForegroundPressure>), Wait(Option<ForegroundPressure>),
} }
/// Cooperative pacing for one heal execution, not a storage admission permit. /// Foreground watermarks for one heal execution. Constructed once per request;
pub(crate) struct MainlinePacer { /// disabled when watermarks are zero or no workload provider is configured.
#[derive(Clone)]
pub(crate) struct ForegroundPacing {
provider: Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>, provider: Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>,
read_high: usize, read_high: usize,
write_high: usize, write_high: usize,
pause: Duration, pause: Duration,
source: HealRequestSource, source: HealRequestSource,
state: Mutex<PacingState>, state: Arc<AsyncMutex<PacingState>>,
} }
impl MainlinePacer { impl ForegroundPacing {
pub(crate) fn new( pub(crate) fn new(
provider: Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>, provider: Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>,
read_high: usize, read_high: usize,
@@ -49,7 +57,7 @@ impl MainlinePacer {
write_high: write_high.min(100), write_high: write_high.min(100),
pause: pause.min(Duration::from_secs(1)), pause: pause.min(Duration::from_secs(1)),
source, source,
state: Mutex::new(PacingState::default()), state: Arc::new(AsyncMutex::new(PacingState::default())),
}) })
} }
@@ -125,6 +133,142 @@ impl MainlinePacer {
} }
} }
/// Single owner of "how hard may this heal pass push storage?".
///
/// One instance spans the whole erasure-set scan (all buckets and pages):
/// - foreground admission pressure (`ForegroundPacing`)
/// - S3 `SlowDown` ("Please reduce your request rate") self-backpressure
///
/// Call `observe_object_outcome` exactly once per finished object so a single
/// SlowDown cannot double-reduce concurrency.
pub(crate) struct HealPageRate {
base_concurrency: usize,
/// 0 = full base concurrency; each SlowDown increases the reduction.
slow_down_step: AtomicU32,
slow_down_until: std::sync::Mutex<Option<Instant>>,
foreground: Option<ForegroundPacing>,
}
impl HealPageRate {
/// Always-on controller. Foreground yield is optional; SlowDown control is not.
pub(crate) fn new(base_concurrency: usize, foreground: Option<ForegroundPacing>) -> Self {
Self {
base_concurrency: base_concurrency.max(1),
slow_down_step: AtomicU32::new(0),
slow_down_until: std::sync::Mutex::new(None),
foreground,
}
}
/// Concurrency after applying the current SlowDown reduction. Always ≥ 1
/// so maintenance can still make progress under sustained rate pressure.
pub(crate) fn effective_concurrency(&self) -> usize {
let step = self.slow_down_step.load(Ordering::Relaxed).min(8);
(self.base_concurrency >> step).max(1)
}
/// Fold one finished object into the rate state. Call once per outcome.
/// `saw_slow_down` is true when any attempt of this object hit SlowDown,
/// including attempts that later succeeded on retry.
pub(crate) fn observe_object_outcome(&self, result: &std::result::Result<bool, &Error>, saw_slow_down: bool) {
if saw_slow_down || matches!(result, Err(err) if err.is_slow_down()) {
self.observe_slow_down();
return;
}
if result.is_ok() {
self.observe_success();
}
}
/// S3 `SlowDown` is a rate signal: cut fan-out and arm exponential cool-down.
fn observe_slow_down(&self) {
// fetch_add returns the previous step; +1 so the first SlowDown uses
// exponent 1 (2s), not exponent 0 (1s).
let step = self.slow_down_step.fetch_add(1, Ordering::Relaxed).saturating_add(1).min(8);
let delay = Self::backoff(step);
let ready = Instant::now() + delay;
if let Ok(mut until) = self.slow_down_until.lock()
&& until.is_none_or(|current| current < ready)
{
*until = Some(ready);
}
}
/// Restore one concurrency step only after the SlowDown cool-down expires.
/// A same-page success must not undo the reduction while the pass is still
/// in the rate-limited window (the page semaphore is fixed until the next page).
fn observe_success(&self) {
let cooling = self
.slow_down_until
.lock()
.ok()
.and_then(|until| *until)
.is_some_and(|ready| ready > Instant::now());
if cooling {
return;
}
self.slow_down_step
.try_update(Ordering::Relaxed, Ordering::Relaxed, |step| Some(step.saturating_sub(1)))
.ok();
}
/// Wait out SlowDown cool-down, then the foreground pacer. Cancellation interrupts.
pub(crate) async fn wait_before_page(&self, cancel: &CancellationToken) -> Result<()> {
self.wait_slow_down(cancel).await?;
if let Some(foreground) = &self.foreground {
foreground.wait(cancel).await?;
}
Ok(())
}
/// Foreground admission decision while the caller owns page capacity.
pub(crate) fn admission_decision(&self) -> PacingDecision {
match &self.foreground {
Some(foreground) => foreground.admission_decision(),
None => PacingDecision::Ready,
}
}
/// Foreground wait after a rejected admission. Returns whether a pause was paid.
pub(crate) async fn wait_after_admission(
&self,
cancel: &CancellationToken,
observed: Option<ForegroundPressure>,
) -> Result<bool> {
match &self.foreground {
Some(foreground) => foreground.wait_after_admission(cancel, observed).await,
None => Ok(false),
}
}
async fn wait_slow_down(&self, cancel: &CancellationToken) -> Result<()> {
let delay = self
.slow_down_until
.lock()
.ok()
.and_then(|until| *until)
.map(|ready| ready.saturating_duration_since(Instant::now()));
let Some(delay) = delay else {
return Ok(());
};
if delay.is_zero() {
return Ok(());
}
tokio::select! {
biased;
_ = cancel.cancelled() => Err(Error::TaskCancelled),
_ = tokio::time::sleep(delay) => Ok(()),
}
}
fn backoff(step: u32) -> Duration {
// step 1 => 2s, 2 => 4s, … capped at 30s — S3 clients are told to reduce
// rate with exponential backoff, and heal must do the same to itself.
let exponent = step.clamp(1, 5);
Duration::from_secs(2_u64.saturating_pow(exponent)).min(Duration::from_secs(30))
}
}
#[cfg(test)] #[cfg(test)]
pub(crate) struct TestPressure { pub(crate) struct TestPressure {
pub(crate) active: std::sync::atomic::AtomicUsize, pub(crate) active: std::sync::atomic::AtomicUsize,
@@ -164,12 +308,87 @@ mod tests {
use rustfs_concurrency::WorkloadClass; use rustfs_concurrency::WorkloadClass;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
#[tokio::test(start_paused = true)]
async fn heal_page_rate_first_slow_down_waits_two_seconds() {
let rate = HealPageRate::new(8, None);
rate.observe_object_outcome(&Err(&Error::transient_skip("Please reduce your request rate")), false);
let cancel = CancellationToken::new();
let before = Instant::now();
rate.wait_before_page(&cancel).await.expect("cool-down wait");
assert_eq!(Instant::now() - before, Duration::from_secs(2));
}
#[tokio::test(start_paused = true)]
async fn heal_page_rate_reduces_concurrency_and_backs_off() {
let rate = HealPageRate::new(8, None);
assert_eq!(rate.effective_concurrency(), 8);
rate.observe_object_outcome(&Err(&Error::transient_skip("Please reduce your request rate")), false);
assert_eq!(rate.effective_concurrency(), 4);
rate.observe_object_outcome(&Err(&Error::Storage(crate::heal::EcstoreError::SlowDown)), true);
assert_eq!(rate.effective_concurrency(), 2);
// Floor is one so maintenance never stops entirely.
for _ in 0..8 {
rate.observe_object_outcome(&Err(&Error::Storage(crate::heal::EcstoreError::SlowDown)), true);
}
assert_eq!(rate.effective_concurrency(), 1);
let cancel = CancellationToken::new();
assert!(rate.wait_before_page(&cancel).await.is_ok());
// After cool-down, successes restore concurrency one step at a time.
for _ in 0..12 {
rate.observe_object_outcome(&Ok(true), false);
}
assert!(rate.effective_concurrency() > 1);
}
#[tokio::test(start_paused = true)]
async fn heal_page_rate_same_page_success_does_not_undo_slow_down() {
let rate = HealPageRate::new(8, None);
rate.observe_object_outcome(&Err(&Error::Storage(crate::heal::EcstoreError::SlowDown)), false);
assert_eq!(rate.effective_concurrency(), 4);
// Same-page successes while cool-down is armed must not restore fan-out.
for _ in 0..4 {
rate.observe_object_outcome(&Ok(true), false);
}
assert_eq!(rate.effective_concurrency(), 4);
}
#[tokio::test(start_paused = true)]
async fn heal_page_rate_slow_down_then_retry_success_still_reduces() {
let rate = HealPageRate::new(8, None);
// Replacement retry: SlowDown on attempt 1, success on attempt 2.
rate.observe_object_outcome(&Ok(true), true);
assert_eq!(rate.effective_concurrency(), 4);
}
#[tokio::test(start_paused = true)]
async fn heal_page_rate_wait_is_cancellable() {
let rate = HealPageRate::new(4, None);
rate.observe_object_outcome(&Err(&Error::Storage(crate::heal::EcstoreError::SlowDown)), false);
let cancel = CancellationToken::new();
cancel.cancel();
assert!(matches!(rate.wait_before_page(&cancel).await, Err(Error::TaskCancelled)));
}
#[tokio::test(start_paused = true)]
async fn heal_page_rate_still_waits_for_foreground_after_slow_down_clears() {
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundWrite, 100));
let foreground = ForegroundPacing::new(provider, 0, 80, Duration::from_millis(250), HealRequestSource::AutoHeal)
.expect("foreground pacer");
let rate = HealPageRate::new(4, Some(foreground));
let cancel = CancellationToken::new();
let before = Instant::now();
rate.wait_before_page(&cancel).await.expect("bounded foreground pause");
assert_eq!(Instant::now() - before, Duration::from_millis(250));
}
#[tokio::test(start_paused = true)] #[tokio::test(start_paused = true)]
async fn running_mainline_hysteresis_uses_configured_watermarks_and_stable_low_window() { async fn running_mainline_hysteresis_uses_configured_watermarks_and_stable_low_window() {
for class in [WorkloadClass::ForegroundRead, WorkloadClass::ForegroundWrite] { for class in [WorkloadClass::ForegroundRead, WorkloadClass::ForegroundWrite] {
let provider = Arc::new(TestPressure::new(class, 0)); let provider = Arc::new(TestPressure::new(class, 0));
let pause = Duration::from_millis(250); let pause = Duration::from_millis(250);
let pacer = MainlinePacer::new( let pacer = ForegroundPacing::new(
provider.clone(), provider.clone(),
if class == WorkloadClass::ForegroundRead { 40 } else { 0 }, if class == WorkloadClass::ForegroundRead { 40 } else { 0 },
if class == WorkloadClass::ForegroundWrite { 40 } else { 0 }, if class == WorkloadClass::ForegroundWrite { 40 } else { 0 },
@@ -197,9 +416,9 @@ mod tests {
#[tokio::test(start_paused = true)] #[tokio::test(start_paused = true)]
async fn running_mainline_huge_pause_is_capped_and_disabled_classes_do_not_sleep() { async fn running_mainline_huge_pause_is_capped_and_disabled_classes_do_not_sleep() {
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100)); let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100));
assert!(MainlinePacer::new(provider.clone(), 0, 0, Duration::from_secs(1), HealRequestSource::Admin).is_none()); assert!(ForegroundPacing::new(provider.clone(), 0, 0, Duration::from_secs(1), HealRequestSource::Admin).is_none());
assert!(MainlinePacer::new(provider.clone(), 80, 80, Duration::ZERO, HealRequestSource::Admin).is_none()); assert!(ForegroundPacing::new(provider.clone(), 80, 80, Duration::ZERO, HealRequestSource::Admin).is_none());
let pacer = MainlinePacer::new(provider, 80, 80, Duration::from_secs(3600), HealRequestSource::Admin).expect("pacer"); let pacer = ForegroundPacing::new(provider, 80, 80, Duration::from_secs(3600), HealRequestSource::Admin).expect("pacer");
let before = Instant::now(); let before = Instant::now();
pacer.wait(&CancellationToken::new()).await.expect("hard-capped pause"); pacer.wait(&CancellationToken::new()).await.expect("hard-capped pause");
assert_eq!(Instant::now() - before, Duration::from_secs(1)); assert_eq!(Instant::now() - before, Duration::from_secs(1));
@@ -209,7 +428,7 @@ mod tests {
async fn running_mainline_waiters_cancel_and_task_latches_are_isolated() { async fn running_mainline_waiters_cancel_and_task_latches_are_isolated() {
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100)); let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100));
let paced = Arc::new( let paced = Arc::new(
MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin).expect("pacer"), ForegroundPacing::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin).expect("pacer"),
); );
let cancel_first = CancellationToken::new(); let cancel_first = CancellationToken::new();
let first = tokio::spawn({ let first = tokio::spawn({
@@ -228,7 +447,7 @@ mod tests {
cancel_second.cancel(); cancel_second.cancel();
assert!(matches!(second.await.expect("queued waiter"), Err(Error::TaskCancelled))); assert!(matches!(second.await.expect("queued waiter"), Err(Error::TaskCancelled)));
provider.active.store(0, Ordering::SeqCst); provider.active.store(0, Ordering::SeqCst);
let other_task = MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin) let other_task = ForegroundPacing::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin)
.expect("independent task"); .expect("independent task");
let before = Instant::now(); let before = Instant::now();
other_task other_task
+3 -1
View File
@@ -35,7 +35,9 @@ mod replacement;
mod utils; mod utils;
pub(crate) use checkpoint::AdminErasureCheckpoint; pub(crate) use checkpoint::AdminErasureCheckpoint;
pub use checkpoint::{CheckpointManager, CheckpointObjectOutcome, CheckpointObjectOutcomeRecord, ResumeCheckpoint}; pub use checkpoint::{
CheckpointManager, CheckpointObjectOutcome, CheckpointObjectOutcomeRecord, CheckpointProgressSnapshot, ResumeCheckpoint,
};
pub(crate) use gc::ResumeGc; pub(crate) use gc::ResumeGc;
pub use handoff::{ReplacementHandoff, ReplacementHandoffLink, ReplacementHandoffPhase}; pub use handoff::{ReplacementHandoff, ReplacementHandoffLink, ReplacementHandoffPhase};
pub use legacy_handoff::{LegacyReplacementApproval, LegacyReplacementSource}; pub use legacy_handoff::{LegacyReplacementApproval, LegacyReplacementSource};
+105
View File
@@ -152,6 +152,42 @@ impl AdminErasureCheckpoint {
} }
} }
/// Lightweight progress view for resume decisions. Omits the per-page object
/// ledgers so callers do not clone a million-object checkpoint just to read
/// counters and positions.
#[derive(Debug, Clone, Copy)]
pub struct CheckpointProgressSnapshot {
pub current_bucket_index: usize,
pub current_object_index: usize,
pub successful_objects: u64,
pub failed_object_count: u64,
pub skipped_object_count: u64,
pub skipped_new_versions: u64,
pub skipped_ilm_expired: u64,
pub processed_bytes: u64,
pub total_objects: u64,
pub total_bytes: u64,
pub baseline_generation: Option<u64>,
pub baseline_known: bool,
pub counter_unknown: bool,
}
impl CheckpointProgressSnapshot {
pub fn has_progress(&self) -> bool {
self.baseline_known
|| self.successful_objects > 0
|| self.failed_object_count > 0
|| self.skipped_object_count > 0
|| self.skipped_new_versions > 0
|| self.skipped_ilm_expired > 0
|| self.processed_bytes > 0
|| self.total_objects > 0
|| self.total_bytes > 0
|| self.baseline_generation.is_some()
|| self.counter_unknown
}
}
/// resume checkpoint /// resume checkpoint
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResumeCheckpoint { pub struct ResumeCheckpoint {
@@ -715,6 +751,75 @@ impl CheckpointManager {
self.checkpoint.read().await.clone() self.checkpoint.read().await.clone()
} }
/// True when a page object is already settled in the current ledger.
/// Membership is checked under the lock without cloning the checkpoint,
/// which used to copy every page's dedup sets on the small-object path.
pub async fn is_object_settled(&self, key: &str) -> bool {
let checkpoint = self.checkpoint.read().await;
checkpoint.processed_objects.contains(key)
|| checkpoint.failed_objects.contains(key)
|| checkpoint.skipped_objects.contains(key)
}
/// True when the administrator checkpoint already finished.
pub async fn admin_completed(&self) -> bool {
self.checkpoint
.read()
.await
.admin
.as_ref()
.is_some_and(|admin| admin.completed)
}
/// Current resume position without cloning the object ledgers.
pub async fn resume_position(&self) -> (usize, usize) {
let checkpoint = self.checkpoint.read().await;
(checkpoint.current_bucket_index, checkpoint.current_object_index)
}
/// Dangling-delete grace summary without cloning the object ledgers.
pub async fn dangling_delete_grace(&self) -> (u64, Option<u64>, bool) {
let checkpoint = self.checkpoint.read().await;
(
checkpoint.dangling_delete_grace_objects,
checkpoint.dangling_delete_retry_not_before,
checkpoint.counter_unknown,
)
}
/// Aggregate progress counters without cloning the page dedup sets.
pub async fn progress_snapshot(&self) -> CheckpointProgressSnapshot {
let checkpoint = self.checkpoint.read().await;
CheckpointProgressSnapshot {
current_bucket_index: checkpoint.current_bucket_index,
current_object_index: checkpoint.current_object_index,
successful_objects: checkpoint.successful_objects,
failed_object_count: checkpoint.failed_object_count,
skipped_object_count: checkpoint.skipped_object_count,
skipped_new_versions: checkpoint.skipped_new_versions,
skipped_ilm_expired: checkpoint.skipped_ilm_expired,
processed_bytes: checkpoint.processed_bytes,
total_objects: checkpoint.total_objects,
total_bytes: checkpoint.total_bytes,
baseline_generation: checkpoint.baseline_generation,
baseline_known: checkpoint.baseline_known,
counter_unknown: checkpoint.counter_unknown,
}
}
/// Finish the administrator outcome in place and persist it.
pub async fn complete_admin(&self) -> Result<()> {
let mut checkpoint = self.checkpoint.write().await;
let admin = checkpoint
.admin
.as_mut()
.ok_or_else(|| Error::InvalidCheckpoint("Missing administrator outcome at completion".to_string()))?;
admin.completed = true;
admin.outcome.finish(None);
drop(checkpoint);
self.save_checkpoint().await
}
/// update position /// update position
pub async fn update_position(&self, bucket_index: usize, object_index: usize) -> Result<()> { pub async fn update_position(&self, bucket_index: usize, object_index: usize) -> Result<()> {
let mut checkpoint = self.checkpoint.write().await; let mut checkpoint = self.checkpoint.write().await;
+2 -2
View File
@@ -485,7 +485,7 @@ pub struct HealTask {
pub cancel_token: tokio_util::sync::CancellationToken, pub cancel_token: tokio_util::sync::CancellationToken,
/// Storage layer interface /// Storage layer interface
pub storage: Arc<dyn HealStorageAPI>, pub storage: Arc<dyn HealStorageAPI>,
mainline_pacer: Option<Arc<super::pacing::MainlinePacer>>, mainline_pacer: Option<super::pacing::ForegroundPacing>,
} }
impl HealTask { impl HealTask {
@@ -586,7 +586,7 @@ impl HealTask {
task task
} }
pub(crate) fn with_mainline_pacer(mut self, pacer: Option<Arc<super::pacing::MainlinePacer>>) -> Self { pub(crate) fn with_mainline_pacer(mut self, pacer: Option<super::pacing::ForegroundPacing>) -> Self {
self.mainline_pacer = pacer; self.mainline_pacer = pacer;
self self
} }
+3 -2
View File
@@ -480,8 +480,9 @@ impl HealTask {
message: format!("Missing source bucket incarnation for durable MRF repair {bucket}/{object}"), message: format!("Missing source bucket incarnation for durable MRF repair {bucket}/{object}"),
})?; })?;
if current_bucket_incarnation_id != bucket_incarnation_id { if current_bucket_incarnation_id != bucket_incarnation_id {
return Err(Error::TaskExecutionFailed { return Err(Error::StaleBucketIncarnation {
message: format!("Bucket incarnation changed before durable MRF repair {bucket}/{object}"), bucket: bucket.to_owned(),
expected: Some(bucket_incarnation_id),
}); });
} }
expected.bucket_incarnation_id = Some(bucket_incarnation_id); expected.bucket_incarnation_id = Some(bucket_incarnation_id);
+58 -1
View File
@@ -3375,10 +3375,67 @@ async fn durable_mrf_heal_rejects_a_recreated_bucket_before_storage_heal() {
let result = task.heal_object("bucket", "object", None).await; let result = task.heal_object("bucket", "object", None).await;
assert!(matches!(result, Err(Error::TaskExecutionFailed { .. }))); assert!(matches!(
result,
Err(Error::StaleBucketIncarnation { expected: Some(id), .. }) if id == original_incarnation
));
assert!(storage.heal_object_calls.lock().unwrap().is_empty()); assert!(storage.heal_object_calls.lock().unwrap().is_empty());
} }
#[tokio::test]
async fn durable_mrf_stale_incarnation_object_name_cannot_change_retry_classification() {
for object in ["timed out", "network error", "ordinary"] {
let original_incarnation = Uuid::new_v4();
let recreated_incarnation = Uuid::new_v4();
let storage = Arc::new(MockStorage {
bucket_incarnation_id: Mutex::new(Some(recreated_incarnation)),
..Default::default()
});
let mut request = HealRequest::object("bucket".to_owned(), object.to_owned(), None);
request.source = HealRequestSource::Mrf;
request.expected_mrf_bucket_incarnation_id = Some(original_incarnation);
request.options.pool_index = Some(0);
request.options.set_index = Some(1);
let task = HealTask::from_request(request, storage.clone());
let error = task
.execute()
.await
.expect_err("a recreated bucket must reject the old MRF responsibility");
assert!(
!error.is_recoverable_heal(),
"user-controlled object names must not make a stale incarnation retryable: {object}"
);
assert!(
matches!(
&error,
Error::StaleBucketIncarnation { bucket, expected }
if bucket == "bucket" && *expected == Some(original_incarnation)
),
"the failure must retain the original bucket incarnation: {error:?}"
);
assert!(
matches!(crate::error::classify_heal_error(&error), crate::error::HealErrorClass::Aborted),
"stale responsibility must be classified independently of the object name"
);
assert!(
storage.heal_object_calls.lock().expect("object heal calls").is_empty(),
"an old responsibility must not reach storage repair in the recreated bucket"
);
let outcome = task.get_outcome().await;
assert_eq!(outcome.counters.healed, 0, "a stale task cannot claim a repair");
assert!(
outcome.objects.iter().all(|item| !matches!(
&item.disposition,
HealObjectDisposition::Repaired
| HealObjectDisposition::VerifiedHealthy
| HealObjectDisposition::AuthoritativelyAbsent
)),
"the stale task must not produce a positive repair proof"
);
}
}
#[tokio::test(start_paused = true)] #[tokio::test(start_paused = true)]
async fn read_repair_object_heal_is_not_failed_by_flat_task_timeout() { async fn read_repair_object_heal_is_not_failed_by_flat_task_timeout() {
let storage = Arc::new(MockStorage { let storage = Arc::new(MockStorage {
+387 -81
View File
@@ -15,7 +15,6 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
ops::{Deref, DerefMut}, ops::{Deref, DerefMut},
ptr,
sync::{Arc, Mutex, Weak}, sync::{Arc, Mutex, Weak},
}; };
@@ -64,21 +63,46 @@ impl Default for CacheState {
pub struct Cache { pub struct Cache {
state: ArcSwap<CacheState>, state: ArcSwap<CacheState>,
write_lock: Mutex<()>, write_lock: Mutex<()>,
reload_lock: AsyncMutex<()>,
reload_changes: Mutex<Weak<Mutex<ReloadChanges>>>,
service_account_mutation_lock: AsyncMutex<()>, service_account_mutation_lock: AsyncMutex<()>,
sts_account_mutation_locks: Arc<StsMutationLockRegistry>, sts_account_mutation_locks: Arc<MutationLockRegistry>,
parent_mutation_locks: Arc<MutationLockRegistry>,
} }
struct StsMutationLockRegistry { #[derive(Default)]
locks: Mutex<HashMap<String, Weak<AsyncMutex<StsMutationLockState>>>>, struct MutationLockRegistry {
locks: Mutex<HashMap<String, Weak<AsyncMutex<MutationLockState>>>>,
} }
pub(crate) struct StsMutationLockState { pub(crate) struct MutationLockState {
access_key: String, access_key: String,
registry: Weak<StsMutationLockRegistry>, registry: Weak<MutationLockRegistry>,
lock: Weak<AsyncMutex<StsMutationLockState>>, lock: Weak<AsyncMutex<MutationLockState>>,
} }
impl Drop for StsMutationLockState { impl MutationLockRegistry {
fn lock_for(self: &Arc<Self>, access_key: &str) -> Arc<AsyncMutex<MutationLockState>> {
let mut locks = self.locks.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(lock) = locks.get(access_key).and_then(Weak::upgrade) {
return lock;
}
let registry = Arc::downgrade(self);
let access_key_owned = access_key.to_string();
let lock = Arc::new_cyclic(|lock| {
AsyncMutex::new(MutationLockState {
access_key: access_key_owned,
registry,
lock: lock.clone(),
})
});
locks.insert(access_key.to_string(), Arc::downgrade(&lock));
lock
}
}
impl Drop for MutationLockState {
fn drop(&mut self) { fn drop(&mut self) {
let Some(registry) = self.registry.upgrade() else { let Some(registry) = self.registry.upgrade() else {
return; return;
@@ -95,10 +119,52 @@ impl Default for Cache {
Self { Self {
state: ArcSwap::new(Arc::new(CacheState::default())), state: ArcSwap::new(Arc::new(CacheState::default())),
write_lock: Mutex::new(()), write_lock: Mutex::new(()),
reload_lock: AsyncMutex::new(()),
reload_changes: Mutex::new(Weak::new()),
service_account_mutation_lock: AsyncMutex::new(()), service_account_mutation_lock: AsyncMutex::new(()),
sts_account_mutation_locks: Arc::new(StsMutationLockRegistry { sts_account_mutation_locks: Arc::new(MutationLockRegistry::default()),
locks: Mutex::new(HashMap::new()), parent_mutation_locks: Arc::new(MutationLockRegistry::default()),
}), }
}
}
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
pub(crate) enum CachePartition {
PolicyDocs,
Users,
UserPolicies,
StsAccounts,
StsPolicies,
Groups,
UserGroupMemberships,
GroupPolicies,
}
type ReloadChanges = HashMap<CachePartition, HashSet<String>>;
/// A reload journals only keys changed during its own lifetime, including
/// deletions. Unrelated STS traffic cannot suppress a persisted revocation.
pub(crate) struct CacheReload<'a> {
_guard: tokio::sync::MutexGuard<'a, ()>,
changes: Arc<Mutex<ReloadChanges>>,
}
impl CacheReload<'_> {
pub(crate) fn merge_partition<T: Clone>(
&self,
partition: CachePartition,
loaded: &mut CacheEntity<T>,
current: &CacheEntity<T>,
) {
let changes = self.changes.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(keys) = changes.get(&partition) {
for key in keys {
if let Some(value) = current.get(key) {
loaded.insert(key.clone(), value.clone());
} else {
loaded.remove(key);
}
}
} }
} }
} }
@@ -106,31 +172,27 @@ impl Default for Cache {
pub(crate) type CacheSnapshot = Guard<Arc<CacheState>>; pub(crate) type CacheSnapshot = Guard<Arc<CacheState>>;
impl Cache { impl Cache {
pub(crate) async fn begin_reload(&self) -> CacheReload<'_> {
// Registration and publication both take write_lock before the journal
// lock, so no mutation can fall between the reload and its journal.
let guard = self.reload_lock.lock().await;
let changes = Arc::new(Mutex::new(HashMap::new()));
let _write = self.write_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
*self.reload_changes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Arc::downgrade(&changes);
CacheReload { _guard: guard, changes }
}
pub(crate) fn service_account_mutation_lock(&self) -> &AsyncMutex<()> { pub(crate) fn service_account_mutation_lock(&self) -> &AsyncMutex<()> {
&self.service_account_mutation_lock &self.service_account_mutation_lock
} }
pub(crate) fn sts_account_mutation_lock(&self, access_key: &str) -> Arc<AsyncMutex<StsMutationLockState>> { pub(crate) fn sts_account_mutation_lock(&self, access_key: &str) -> Arc<AsyncMutex<MutationLockState>> {
let mut locks = self self.sts_account_mutation_locks.lock_for(access_key)
.sts_account_mutation_locks }
.locks
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(lock) = locks.get(access_key).and_then(Weak::upgrade) {
return lock;
}
let registry = Arc::downgrade(&self.sts_account_mutation_locks); /// Orders derived-credential publication with changes to its regular parent.
let access_key_owned = access_key.to_string(); pub(crate) fn parent_mutation_lock(&self, access_key: &str) -> Arc<AsyncMutex<MutationLockState>> {
let lock = Arc::new_cyclic(|lock| { self.parent_mutation_locks.lock_for(access_key)
AsyncMutex::new(StsMutationLockState {
access_key: access_key_owned,
registry,
lock: lock.clone(),
})
});
locks.insert(access_key.to_string(), Arc::downgrade(&lock));
lock
} }
pub(crate) fn snapshot(&self) -> CacheSnapshot { pub(crate) fn snapshot(&self) -> CacheSnapshot {
@@ -142,7 +204,13 @@ impl Cache {
let current = self.state.load_full(); let current = self.state.load_full();
let mut locked = LockedCache { let mut locked = LockedCache {
state: CacheState::clone(&current), state: CacheState::clone(&current),
#[cfg(test)]
current_ptr: Arc::as_ptr(&current), current_ptr: Arc::as_ptr(&current),
reload_changes: self
.reload_changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.upgrade(),
dirty: false, dirty: false,
}; };
let ret = f(&mut locked); let ret = f(&mut locked);
@@ -215,7 +283,9 @@ impl Cache {
pub(crate) struct LockedCache { pub(crate) struct LockedCache {
state: CacheState, state: CacheState,
#[cfg(test)]
current_ptr: *const CacheState, current_ptr: *const CacheState,
reload_changes: Option<Arc<Mutex<ReloadChanges>>>,
dirty: bool, dirty: bool,
} }
@@ -224,11 +294,19 @@ impl LockedCache {
&self.state &self.state
} }
#[cfg(test)]
pub(crate) fn matches_snapshot(&self, snapshot: &CacheSnapshot) -> bool { pub(crate) fn matches_snapshot(&self, snapshot: &CacheSnapshot) -> bool {
ptr::eq(self.current_ptr, Arc::as_ptr(snapshot)) std::ptr::eq(self.current_ptr, Arc::as_ptr(snapshot))
} }
fn exec<T: Clone>(target: &mut Arc<CacheEntity<T>>, t: OffsetDateTime, mut op: impl FnMut(&mut CacheEntity<T>)) -> bool { fn exec<T: Clone>(
target: &mut Arc<CacheEntity<T>>,
t: OffsetDateTime,
changes: Option<&Arc<Mutex<ReloadChanges>>>,
partition: CachePartition,
key: &str,
mut op: impl FnMut(&mut CacheEntity<T>),
) -> bool {
if target.load_time >= t { if target.load_time >= t {
return false; return false;
} }
@@ -236,6 +314,14 @@ impl LockedCache {
let mut new = CacheEntity::clone(target); let mut new = CacheEntity::clone(target);
op(&mut new); op(&mut new);
*target = Arc::new(new); *target = Arc::new(new);
if let Some(changes) = changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(partition)
.or_default()
.insert(key.to_string());
}
true true
} }
@@ -244,139 +330,315 @@ impl LockedCache {
} }
pub(crate) fn replace_policy_docs(&mut self, value: CacheEntity<PolicyDoc>) { pub(crate) fn replace_policy_docs(&mut self, value: CacheEntity<PolicyDoc>) {
if let Some(changes) = &self.reload_changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(CachePartition::PolicyDocs)
.or_default()
.extend(self.state.policy_docs.keys().chain(value.keys()).cloned());
}
self.state.policy_docs = Self::replaced(value); self.state.policy_docs = Self::replaced(value);
self.dirty = true; self.dirty = true;
} }
pub(crate) fn replace_users(&mut self, value: CacheEntity<UserIdentity>) { pub(crate) fn replace_users(&mut self, value: CacheEntity<UserIdentity>) {
if let Some(changes) = &self.reload_changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(CachePartition::Users)
.or_default()
.extend(self.state.users.keys().chain(value.keys()).cloned());
}
self.state.users = Self::replaced(value); self.state.users = Self::replaced(value);
self.dirty = true; self.dirty = true;
} }
pub(crate) fn replace_user_policies(&mut self, value: CacheEntity<MappedPolicy>) { pub(crate) fn replace_user_policies(&mut self, value: CacheEntity<MappedPolicy>) {
if let Some(changes) = &self.reload_changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(CachePartition::UserPolicies)
.or_default()
.extend(self.state.user_policies.keys().chain(value.keys()).cloned());
}
self.state.user_policies = Self::replaced(value); self.state.user_policies = Self::replaced(value);
self.dirty = true; self.dirty = true;
} }
pub(crate) fn replace_sts_accounts(&mut self, value: CacheEntity<UserIdentity>) { pub(crate) fn replace_sts_accounts(&mut self, value: CacheEntity<UserIdentity>) {
if let Some(changes) = &self.reload_changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(CachePartition::StsAccounts)
.or_default()
.extend(self.state.sts_accounts.keys().chain(value.keys()).cloned());
}
self.state.sts_accounts = Self::replaced(value); self.state.sts_accounts = Self::replaced(value);
self.dirty = true; self.dirty = true;
} }
pub(crate) fn replace_sts_policies(&mut self, value: CacheEntity<MappedPolicy>) { pub(crate) fn replace_sts_policies(&mut self, value: CacheEntity<MappedPolicy>) {
if let Some(changes) = &self.reload_changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(CachePartition::StsPolicies)
.or_default()
.extend(self.state.sts_policies.keys().chain(value.keys()).cloned());
}
self.state.sts_policies = Self::replaced(value); self.state.sts_policies = Self::replaced(value);
self.dirty = true; self.dirty = true;
} }
pub(crate) fn replace_groups(&mut self, value: CacheEntity<GroupInfo>) { pub(crate) fn replace_groups(&mut self, value: CacheEntity<GroupInfo>) {
if let Some(changes) = &self.reload_changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(CachePartition::Groups)
.or_default()
.extend(self.state.groups.keys().chain(value.keys()).cloned());
}
self.state.groups = Self::replaced(value); self.state.groups = Self::replaced(value);
self.dirty = true; self.dirty = true;
} }
pub(crate) fn replace_group_policies(&mut self, value: CacheEntity<MappedPolicy>) { pub(crate) fn replace_group_policies(&mut self, value: CacheEntity<MappedPolicy>) {
if let Some(changes) = &self.reload_changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(CachePartition::GroupPolicies)
.or_default()
.extend(self.state.group_policies.keys().chain(value.keys()).cloned());
}
self.state.group_policies = Self::replaced(value); self.state.group_policies = Self::replaced(value);
self.dirty = true; self.dirty = true;
} }
pub(crate) fn replace_user_group_memberships(&mut self, value: CacheEntity<HashSet<String>>) { pub(crate) fn replace_user_group_memberships(&mut self, value: CacheEntity<HashSet<String>>) {
if let Some(changes) = &self.reload_changes {
changes
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.entry(CachePartition::UserGroupMemberships)
.or_default()
.extend(self.state.user_group_memberships.keys().chain(value.keys()).cloned());
}
self.state.user_group_memberships = Self::replaced(value); self.state.user_group_memberships = Self::replaced(value);
self.dirty = true; self.dirty = true;
} }
pub(crate) fn add_or_update_policy_doc(&mut self, key: &str, value: &PolicyDoc, t: OffsetDateTime) { pub(crate) fn add_or_update_policy_doc(&mut self, key: &str, value: &PolicyDoc, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.policy_docs, t, |map| { self.dirty |= Self::exec(
map.insert(key.to_string(), value.clone()); &mut self.state.policy_docs,
}); t,
self.reload_changes.as_ref(),
CachePartition::PolicyDocs,
key,
|map| {
map.insert(key.to_string(), value.clone());
},
);
} }
pub(crate) fn add_or_update_user(&mut self, key: &str, value: &UserIdentity, t: OffsetDateTime) { pub(crate) fn add_or_update_user(&mut self, key: &str, value: &UserIdentity, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.users, t, |map| { self.dirty |= Self::exec(
map.insert(key.to_string(), value.clone()); &mut self.state.users,
}); t,
self.reload_changes.as_ref(),
CachePartition::Users,
key,
|map| {
map.insert(key.to_string(), value.clone());
},
);
} }
pub(crate) fn add_or_update_user_policy(&mut self, key: &str, value: &MappedPolicy, t: OffsetDateTime) { pub(crate) fn add_or_update_user_policy(&mut self, key: &str, value: &MappedPolicy, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.user_policies, t, |map| { self.dirty |= Self::exec(
map.insert(key.to_string(), value.clone()); &mut self.state.user_policies,
}); t,
self.reload_changes.as_ref(),
CachePartition::UserPolicies,
key,
|map| {
map.insert(key.to_string(), value.clone());
},
);
} }
pub(crate) fn add_or_update_sts_account(&mut self, key: &str, value: &UserIdentity, t: OffsetDateTime) { pub(crate) fn add_or_update_sts_account(&mut self, key: &str, value: &UserIdentity, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.sts_accounts, t, |map| { self.dirty |= Self::exec(
map.insert(key.to_string(), value.clone()); &mut self.state.sts_accounts,
}); t,
self.reload_changes.as_ref(),
CachePartition::StsAccounts,
key,
|map| {
map.insert(key.to_string(), value.clone());
},
);
} }
pub(crate) fn add_or_update_sts_policy(&mut self, key: &str, value: &MappedPolicy, t: OffsetDateTime) { pub(crate) fn add_or_update_sts_policy(&mut self, key: &str, value: &MappedPolicy, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.sts_policies, t, |map| { self.dirty |= Self::exec(
map.insert(key.to_string(), value.clone()); &mut self.state.sts_policies,
}); t,
self.reload_changes.as_ref(),
CachePartition::StsPolicies,
key,
|map| {
map.insert(key.to_string(), value.clone());
},
);
} }
pub(crate) fn add_or_update_group(&mut self, key: &str, value: &GroupInfo, t: OffsetDateTime) { pub(crate) fn add_or_update_group(&mut self, key: &str, value: &GroupInfo, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.groups, t, |map| { self.dirty |= Self::exec(
map.insert(key.to_string(), value.clone()); &mut self.state.groups,
}); t,
self.reload_changes.as_ref(),
CachePartition::Groups,
key,
|map| {
map.insert(key.to_string(), value.clone());
},
);
} }
pub(crate) fn add_or_update_user_group_membership(&mut self, key: &str, value: &HashSet<String>, t: OffsetDateTime) { pub(crate) fn add_or_update_user_group_membership(&mut self, key: &str, value: &HashSet<String>, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.user_group_memberships, t, |map| { self.dirty |= Self::exec(
map.insert(key.to_string(), value.clone()); &mut self.state.user_group_memberships,
}); t,
self.reload_changes.as_ref(),
CachePartition::UserGroupMemberships,
key,
|map| {
map.insert(key.to_string(), value.clone());
},
);
} }
pub(crate) fn add_or_update_group_policy(&mut self, key: &str, value: &MappedPolicy, t: OffsetDateTime) { pub(crate) fn add_or_update_group_policy(&mut self, key: &str, value: &MappedPolicy, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.group_policies, t, |map| { self.dirty |= Self::exec(
map.insert(key.to_string(), value.clone()); &mut self.state.group_policies,
}); t,
self.reload_changes.as_ref(),
CachePartition::GroupPolicies,
key,
|map| {
map.insert(key.to_string(), value.clone());
},
);
} }
pub(crate) fn delete_policy_doc(&mut self, key: &str, t: OffsetDateTime) { pub(crate) fn delete_policy_doc(&mut self, key: &str, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.policy_docs, t, |map| { self.dirty |= Self::exec(
map.remove(key); &mut self.state.policy_docs,
}); t,
self.reload_changes.as_ref(),
CachePartition::PolicyDocs,
key,
|map| {
map.remove(key);
},
);
} }
pub(crate) fn delete_user(&mut self, key: &str, t: OffsetDateTime) { pub(crate) fn delete_user(&mut self, key: &str, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.users, t, |map| { self.dirty |= Self::exec(
map.remove(key); &mut self.state.users,
}); t,
self.reload_changes.as_ref(),
CachePartition::Users,
key,
|map| {
map.remove(key);
},
);
} }
pub(crate) fn delete_user_policy(&mut self, key: &str, t: OffsetDateTime) { pub(crate) fn delete_user_policy(&mut self, key: &str, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.user_policies, t, |map| { self.dirty |= Self::exec(
map.remove(key); &mut self.state.user_policies,
}); t,
self.reload_changes.as_ref(),
CachePartition::UserPolicies,
key,
|map| {
map.remove(key);
},
);
} }
pub(crate) fn delete_user_group_membership(&mut self, key: &str, t: OffsetDateTime) { pub(crate) fn delete_user_group_membership(&mut self, key: &str, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.user_group_memberships, t, |map| { self.dirty |= Self::exec(
map.remove(key); &mut self.state.user_group_memberships,
}); t,
self.reload_changes.as_ref(),
CachePartition::UserGroupMemberships,
key,
|map| {
map.remove(key);
},
);
} }
pub(crate) fn delete_sts_account(&mut self, key: &str, t: OffsetDateTime) { pub(crate) fn delete_sts_account(&mut self, key: &str, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.sts_accounts, t, |map| { self.dirty |= Self::exec(
map.remove(key); &mut self.state.sts_accounts,
}); t,
self.reload_changes.as_ref(),
CachePartition::StsAccounts,
key,
|map| {
map.remove(key);
},
);
} }
pub(crate) fn delete_sts_policy(&mut self, key: &str, t: OffsetDateTime) { pub(crate) fn delete_sts_policy(&mut self, key: &str, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.sts_policies, t, |map| { self.dirty |= Self::exec(
map.remove(key); &mut self.state.sts_policies,
}); t,
self.reload_changes.as_ref(),
CachePartition::StsPolicies,
key,
|map| {
map.remove(key);
},
);
} }
pub(crate) fn delete_group(&mut self, key: &str, t: OffsetDateTime) { pub(crate) fn delete_group(&mut self, key: &str, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.groups, t, |map| { self.dirty |= Self::exec(
map.remove(key); &mut self.state.groups,
}); t,
self.reload_changes.as_ref(),
CachePartition::Groups,
key,
|map| {
map.remove(key);
},
);
} }
pub(crate) fn delete_group_policy(&mut self, key: &str, t: OffsetDateTime) { pub(crate) fn delete_group_policy(&mut self, key: &str, t: OffsetDateTime) {
self.dirty |= Self::exec(&mut self.state.group_policies, t, |map| { self.dirty |= Self::exec(
map.remove(key); &mut self.state.group_policies,
}); t,
self.reload_changes.as_ref(),
CachePartition::GroupPolicies,
key,
|map| {
map.remove(key);
},
);
} }
pub(crate) fn build_user_group_memberships(&mut self) { pub(crate) fn build_user_group_memberships(&mut self) {
@@ -494,6 +756,50 @@ mod tests {
use crate::cache::Cache; use crate::cache::Cache;
use crate::store::MappedPolicy; use crate::store::MappedPolicy;
#[tokio::test]
async fn reload_journal_preserves_same_key_mutations_and_expires_on_cancellation() {
use super::{CacheEntity, CachePartition};
let cache = Cache::default();
let stale = UserIdentity {
version: 1,
..Default::default()
};
let current = UserIdentity {
version: 2,
..Default::default()
};
let reload = cache.begin_reload().await;
cache.add_or_update_user("updated", &current, OffsetDateTime::now_utc());
cache.add_or_update_user("removed", &current, OffsetDateTime::now_utc());
cache.delete_user("removed", OffsetDateTime::now_utc());
// Even an absent key's deletion is recorded: storage may have returned
// an entry created and deleted entirely during the reload's reads.
cache.delete_user("absent", OffsetDateTime::now_utc());
let mut loaded = CacheEntity::new(std::collections::HashMap::from([
("updated".to_string(), stale.clone()),
("removed".to_string(), stale.clone()),
("absent".to_string(), stale.clone()),
("disk-only".to_string(), stale),
]));
cache.with_write_lock(|locked| {
reload.merge_partition(CachePartition::Users, &mut loaded, &locked.state().users);
locked.replace_users(loaded);
});
let snapshot = cache.snapshot();
assert_eq!(snapshot.users["updated"].version, 2);
assert!(!snapshot.users.contains_key("removed"));
assert!(!snapshot.users.contains_key("absent"));
assert!(snapshot.users.contains_key("disk-only"));
drop(reload);
assert!(cache.reload_changes.lock().expect("journal lock").upgrade().is_none());
let cancelled = cache.begin_reload().await;
cache.delete_user("disk-only", OffsetDateTime::now_utc());
drop(cancelled);
assert!(cache.reload_changes.lock().expect("journal lock").upgrade().is_none());
let next = cache.begin_reload().await;
assert!(next.changes.lock().expect("new journal").is_empty());
}
#[test] #[test]
fn sts_mutation_locks_are_keyed_and_prune_unused_entries() { fn sts_mutation_locks_are_keyed_and_prune_unused_entries() {
let cache = Cache::default(); let cache = Cache::default();
+10
View File
@@ -16,6 +16,7 @@ use super::{FederatedClaims, VerifiedFederatedIdentity};
use crate::{EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC}; use crate::{EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive}; use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use rustfs_utils::HashAlgorithm; use rustfs_utils::HashAlgorithm;
use serde_json::Value;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use tracing::debug; use tracing::debug;
@@ -66,6 +67,15 @@ impl FederatedAuthorization {
&self.provider_id &self.provider_id
} }
/// Issuer from the verified upstream identity.
pub fn issuer(&self) -> Option<&str> {
self.claims
.raw
.get("iss")
.and_then(Value::as_str)
.filter(|issuer| !issuer.is_empty())
}
pub fn subject(&self) -> &str { pub fn subject(&self) -> &str {
&self.claims.sub &self.claims.sub
} }
+149 -7
View File
@@ -415,6 +415,16 @@ where
self.api.save_iam_config(IAMFormat::new_version_1(), path).await self.api.save_iam_config(IAMFormat::new_version_1(), path).await
} }
/// Current built-in IAM group membership, independent of credential snapshots.
pub fn user_group_memberships(&self, name: &str) -> Vec<String> {
self.cache
.snapshot()
.user_group_memberships
.get(name)
.map(|groups| groups.iter().cloned().collect())
.unwrap_or_default()
}
pub async fn get_user(&self, access_key: &str) -> Option<UserIdentity> { pub async fn get_user(&self, access_key: &str) -> Option<UserIdentity> {
let cache = self.cache.snapshot(); let cache = self.cache.snapshot();
cache cache
@@ -1005,6 +1015,17 @@ where
} }
pub async fn sts_policy_db_get(&self, name: &str, groups: &Option<Vec<String>>) -> Result<Vec<String>> { pub async fn sts_policy_db_get(&self, name: &str, groups: &Option<Vec<String>>) -> Result<Vec<String>> {
self.sts_policy_db_get_with_identity_mapping(name, groups)
.await
.map(|(policies, _)| policies)
}
/// Resolve STS policies and whether that same lookup found a nonempty identity mapping.
pub async fn sts_policy_db_get_with_identity_mapping(
&self,
name: &str,
groups: &Option<Vec<String>>,
) -> Result<(Vec<String>, bool)> {
if name.is_empty() { if name.is_empty() {
return Err(Error::InvalidArgument); return Err(Error::InvalidArgument);
} }
@@ -1031,7 +1052,10 @@ where
} }
}; };
self.policy_db_get_with_groups(mapped_policy.to_slice(), groups).await let direct_policies = mapped_policy.to_slice();
let has_identity_mapping = !direct_policies.is_empty();
let policies = self.policy_db_get_with_groups(direct_policies, groups).await?;
Ok((policies, has_identity_mapping))
} }
async fn policy_db_get_with_groups(&self, mut policies: Vec<String>, groups: &Option<Vec<String>>) -> Result<Vec<String>> { async fn policy_db_get_with_groups(&self, mut policies: Vec<String>, groups: &Option<Vec<String>>) -> Result<Vec<String>> {
@@ -1158,7 +1182,7 @@ where
if let Some(groups) = u.credentials.groups.as_ref() { if let Some(groups) = u.credentials.groups.as_ref() {
for group in groups.iter() { for group in groups.iter() {
if groups_cache.get(group).filter(|v| v.status == STATUS_DISABLED).is_some() { if groups_cache.get(group).filter(|v| v.status == STATUS_DISABLED).is_some() {
return Ok((Vec::new(), OffsetDateTime::now_utc())); continue;
} }
let mp = match group_policies.get(group) { let mp = match group_policies.get(group) {
@@ -1189,7 +1213,7 @@ where
for group in user_group_memberships.get(name).cloned().unwrap_or_default().iter() { for group in user_group_memberships.get(name).cloned().unwrap_or_default().iter() {
if groups_cache.get(group).filter(|v| v.status == STATUS_DISABLED).is_some() { if groups_cache.get(group).filter(|v| v.status == STATUS_DISABLED).is_some() {
return Ok((Vec::new(), OffsetDateTime::now_utc())); continue;
} }
let mp = match group_policies.get(group) { let mp = match group_policies.get(group) {
@@ -1486,7 +1510,27 @@ where
args: &AddOrUpdateUserReq, args: &AddOrUpdateUserReq,
updated_at: OffsetDateTime, updated_at: OffsetDateTime,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
self.add_user_at_with_secret_change(access_key, args, updated_at)
.await
.map(|(updated_at, _, _guard)| updated_at)
}
/// Return whether this write replaced a different regular-user secret.
pub(crate) async fn add_user_at_with_secret_change(
&self,
access_key: &str,
args: &AddOrUpdateUserReq,
updated_at: OffsetDateTime,
) -> Result<(OffsetDateTime, bool, tokio::sync::OwnedMutexGuard<crate::cache::MutationLockState>)> {
let parent_lock = self.cache.parent_mutation_lock(access_key);
let parent_guard = parent_lock.lock_owned().await;
let _service_guard = self.cache.service_account_mutation_lock().lock().await;
let sts_lock = self.cache.sts_account_mutation_lock(access_key);
let _sts_guard = sts_lock.lock().await;
let cache = self.cache.snapshot(); let cache = self.cache.snapshot();
if cache.sts_accounts.contains_key(access_key) {
return Err(Error::AccessKeyAlreadyExists);
}
let users = Arc::clone(&cache.users); let users = Arc::clone(&cache.users);
if let Some(x) = users.get(access_key) { if let Some(x) = users.get(access_key) {
debug!( debug!(
@@ -1496,10 +1540,13 @@ where
state = "existing", state = "existing",
"Updating existing IAM user" "Updating existing IAM user"
); );
if x.credentials.is_temp() { if x.credentials.is_temp() || x.credentials.is_service_account() {
return Err(Error::AccessKeyAlreadyExists); return Err(Error::AccessKeyAlreadyExists);
} }
} }
let secret_changed = users
.get(access_key)
.is_some_and(|user| user.credentials.secret_key != args.secret_key);
drop(cache); drop(cache);
drop(users); drop(users);
@@ -1523,7 +1570,7 @@ where
self.update_user_with_claims(access_key, user_entry)?; self.update_user_with_claims(access_key, user_entry)?;
Ok(updated_at) Ok((updated_at, secret_changed, parent_guard))
} }
pub async fn delete_user(&self, access_key: &str, utype: UserType) -> Result<()> { pub async fn delete_user(&self, access_key: &str, utype: UserType) -> Result<()> {
@@ -1540,12 +1587,17 @@ where
return Err(Error::InvalidArgument); return Err(Error::InvalidArgument);
} }
let parent_lock = (utype == UserType::Reg).then(|| self.cache.parent_mutation_lock(access_key));
let _parent_guard = match &parent_lock {
Some(lock) => Some(lock.lock().await),
None => None,
};
let sts_mutation_lock = (utype == UserType::Sts).then(|| self.cache.sts_account_mutation_lock(access_key)); let sts_mutation_lock = (utype == UserType::Sts).then(|| self.cache.sts_account_mutation_lock(access_key));
let _sts_mutation_guard = match &sts_mutation_lock { let _sts_mutation_guard = match &sts_mutation_lock {
Some(lock) => Some(lock.lock().await), Some(lock) => Some(lock.lock().await),
None => None, None => None,
}; };
let _service_account_guard = if utype == UserType::Svc { let _service_account_guard = if matches!(utype, UserType::Svc | UserType::Reg) {
Some(self.cache.service_account_mutation_lock().lock().await) Some(self.cache.service_account_mutation_lock().lock().await)
} else { } else {
None None
@@ -1640,17 +1692,46 @@ where
} }
pub async fn update_user_secret_key(&self, access_key: &str, secret_key: &str) -> Result<(OffsetDateTime, AccountStatus)> { pub async fn update_user_secret_key(&self, access_key: &str, secret_key: &str) -> Result<(OffsetDateTime, AccountStatus)> {
self.update_user_secret_key_with_guard(access_key, secret_key, None)
.await
.map(|(updated_at, status, _guard)| (updated_at, status))
}
pub(crate) async fn update_user_secret_key_with_guard(
&self,
access_key: &str,
secret_key: &str,
expected_secret: Option<&str>,
) -> Result<(
OffsetDateTime,
AccountStatus,
tokio::sync::OwnedMutexGuard<crate::cache::MutationLockState>,
)> {
if access_key.is_empty() || secret_key.is_empty() { if access_key.is_empty() || secret_key.is_empty() {
return Err(Error::InvalidArgument); return Err(Error::InvalidArgument);
} }
let parent_lock = self.cache.parent_mutation_lock(access_key);
let parent_guard = parent_lock.lock_owned().await;
let cache = self.cache.snapshot(); let cache = self.cache.snapshot();
let users = Arc::clone(&cache.users); let users = Arc::clone(&cache.users);
let u = match users.get(access_key) { let u = match users.get(access_key) {
Some(u) => u, Some(u) => u,
None if expected_secret.is_some() => return Err(Error::IAMActionNotAllowed),
None => return Err(Error::NoSuchUser(access_key.to_string())), None => return Err(Error::NoSuchUser(access_key.to_string())),
}; };
if let Some(expected) = expected_secret {
use subtle::ConstantTimeEq as _;
if !u.credentials.is_valid()
|| u.credentials.is_temp()
|| u.credentials.is_service_account()
|| !bool::from(u.credentials.secret_key.as_bytes().ct_eq(expected.as_bytes()))
{
return Err(Error::IAMActionNotAllowed);
}
}
let mut cred = u.credentials.clone(); let mut cred = u.credentials.clone();
cred.secret_key = secret_key.to_string(); cred.secret_key = secret_key.to_string();
@@ -1672,7 +1753,7 @@ where
.await?; .await?;
self.update_user_with_claims(access_key, u)?; self.update_user_with_claims(access_key, u)?;
Ok((updated_at, status)) Ok((updated_at, status, parent_guard))
} }
/// Add SSH public key for a user (for SFTP authentication) /// Add SSH public key for a user (for SFTP authentication)
@@ -1681,6 +1762,8 @@ where
return Err(Error::InvalidArgument); return Err(Error::InvalidArgument);
} }
let parent_lock = self.cache.parent_mutation_lock(access_key);
let _parent_guard = parent_lock.lock().await;
let cache = self.cache.snapshot(); let cache = self.cache.snapshot();
let users = Arc::clone(&cache.users); let users = Arc::clone(&cache.users);
let u = match users.get(access_key) { let u = match users.get(access_key) {
@@ -1720,6 +1803,8 @@ where
return Err(Error::InvalidArgument); return Err(Error::InvalidArgument);
} }
let parent_lock = self.cache.parent_mutation_lock(access_key);
let _parent_guard = parent_lock.lock().await;
let cache = self.cache.snapshot(); let cache = self.cache.snapshot();
let users = Arc::clone(&cache.users); let users = Arc::clone(&cache.users);
let u = match users.get(access_key) { let u = match users.get(access_key) {
@@ -2238,6 +2323,11 @@ where
Ok(()) Ok(())
} }
pub async fn user_notification_handler(&self, name: &str, user_type: UserType) -> Result<()> { pub async fn user_notification_handler(&self, name: &str, user_type: UserType) -> Result<()> {
let parent_lock = (user_type == UserType::Reg).then(|| self.cache.parent_mutation_lock(name));
let _parent_guard = match &parent_lock {
Some(lock) => Some(lock.lock().await),
None => None,
};
let sts_mutation_lock = (user_type == UserType::Sts).then(|| self.cache.sts_account_mutation_lock(name)); let sts_mutation_lock = (user_type == UserType::Sts).then(|| self.cache.sts_account_mutation_lock(name));
let _sts_mutation_guard = match &sts_mutation_lock { let _sts_mutation_guard = match &sts_mutation_lock {
Some(lock) => Some(lock.lock().await), Some(lock) => Some(lock.lock().await),
@@ -2985,6 +3075,58 @@ mod tests {
} }
} }
#[tokio::test]
async fn disabled_group_preserves_direct_and_other_group_policies() {
for credential_groups in [false, true] {
let iam = build_test_iam_cache(FailingInitialLoadStore);
let groups = vec!["disabled".to_string(), "enabled".to_string()];
let user = UserIdentity::new(Credentials {
access_key: "alice".to_string(),
secret_key: "alice-secret".to_string(),
status: STATUS_ENABLED.to_string(),
groups: credential_groups.then(|| groups.clone()),
..Default::default()
});
let now = OffsetDateTime::now_utc();
iam.cache.add_or_update_user("alice", &user, now);
iam.cache
.add_or_update_user_policy("alice", &MappedPolicy::new("direct-deny"), now);
if !credential_groups {
iam.cache
.add_or_update_user_group_membership("alice", &groups.iter().cloned().collect(), now);
}
for (group, status, policy) in [
("disabled", STATUS_DISABLED, "disabled-grant"),
("enabled", STATUS_ENABLED, "other-group-deny"),
] {
iam.cache.add_or_update_group(
group,
&GroupInfo {
status: status.to_string(),
members: vec!["alice".to_string()],
..Default::default()
},
now,
);
iam.cache.add_or_update_group_policy(group, &MappedPolicy::new(policy), now);
}
let policies: HashSet<_> = iam
.policy_db_get("alice", &None)
.await
.expect("user policy lookup")
.into_iter()
.collect();
assert_eq!(policies, HashSet::from(["direct-deny".to_string(), "other-group-deny".to_string()]));
assert!(
iam.policy_db_get_internal("disabled", true, false)
.await
.expect("disabled group lookup")
.0
.is_empty()
);
}
}
#[tokio::test] #[tokio::test]
async fn sts_policy_lookup_ignores_colliding_regular_user_mapping() { async fn sts_policy_lookup_ignores_colliding_regular_user_mapping() {
let iam = build_test_iam_cache(FailingInitialLoadStore); let iam = build_test_iam_cache(FailingInitialLoadStore);
+90 -15
View File
@@ -16,19 +16,21 @@ use super::{
transport::{DiscoveryRequest, OidcHttpError, ReqwestHttpClient, oidc_extra_root_certs, oidc_http_error_diagnostics}, transport::{DiscoveryRequest, OidcHttpError, ReqwestHttpClient, oidc_extra_root_certs, oidc_http_error_diagnostics},
}; };
use crate::{EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC}; use crate::{EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC};
use openidconnect::core::{CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm}; use openidconnect::core::{CoreIdToken, CoreIdTokenClaims, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm};
use openidconnect::{ use openidconnect::{
AsyncHttpClient, ClientId, ClientSecret, DiscoveryError, IssuerUrl, JsonWebKeySetUrl, ProviderMetadataWithLogout, TokenUrl, AsyncHttpClient, ClaimsVerificationError, ClientId, DiscoveryError, IssuerUrl, JsonWebKeySetUrl, NonceVerifier,
ProviderMetadataWithLogout, SignatureVerificationError, TokenUrl,
}; };
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::RwLock; use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration as StdDuration, Instant}; use std::time::{Duration as StdDuration, Instant};
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{debug, warn}; use tracing::{debug, warn};
use url::Url; use url::Url;
pub(super) const OIDC_JWKS_REFRESH_INTERVAL: StdDuration = StdDuration::from_secs(24 * 60 * 60); pub(super) const OIDC_JWKS_REFRESH_INTERVAL: StdDuration = StdDuration::from_secs(24 * 60 * 60);
const OIDC_JWKS_REFRESH_COOLDOWN: StdDuration = StdDuration::from_secs(60);
pub(super) const OIDC_DISCOVERY_TRANSPORT_RETRIES: usize = 3; pub(super) const OIDC_DISCOVERY_TRANSPORT_RETRIES: usize = 3;
pub(super) const OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY: StdDuration = StdDuration::from_millis(50); pub(super) const OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY: StdDuration = StdDuration::from_millis(50);
pub(super) const OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY: &str = "JWKS request blocked by outbound policy"; pub(super) const OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY: &str = "JWKS request blocked by outbound policy";
@@ -96,9 +98,14 @@ pub(super) struct ProviderState {
pub(super) discovered_at: Instant, pub(super) discovered_at: Instant,
} }
type RefreshAttempt = Option<(Instant, Result<ProviderState, String>)>;
type ProviderRefreshLock = Arc<tokio::sync::Mutex<RefreshAttempt>>;
#[derive(Clone)]
pub(super) struct ProviderRuntime { pub(super) struct ProviderRuntime {
states: RwLock<HashMap<String, ProviderState>>, states: Arc<RwLock<HashMap<String, ProviderState>>>,
http_client: ReqwestHttpClient, http_client: Arc<ReqwestHttpClient>,
refreshes: Arc<Mutex<HashMap<String, ProviderRefreshLock>>>,
} }
// Workload issuers do not implement the browser authorization flow. Keep their // Workload issuers do not implement the browser authorization flow. Keep their
@@ -199,19 +206,57 @@ impl DiscoveredProviderMetadata {
pub(super) fn verifier(&self, config: &OidcProviderConfig) -> CoreIdTokenVerifier<'static> { pub(super) fn verifier(&self, config: &OidcProviderConfig) -> CoreIdTokenVerifier<'static> {
let client_id = ClientId::new(config.client_id.clone()); let client_id = ClientId::new(config.client_id.clone());
let secret = config.client_secret.as_ref().map(|secret| ClientSecret::new(secret.clone()));
let (issuer, jwks, algorithms) = match self { let (issuer, jwks, algorithms) = match self {
Self::Console(metadata) => (metadata.issuer(), metadata.jwks(), metadata.id_token_signing_alg_values_supported()), Self::Console(metadata) => (metadata.issuer(), metadata.jwks(), metadata.id_token_signing_alg_values_supported()),
Self::Workload(metadata) => (&metadata.issuer, &metadata.jwks, &metadata.id_token_signing_alg_values_supported), Self::Workload(metadata) => (&metadata.issuer, &metadata.jwks, &metadata.id_token_signing_alg_values_supported),
}; };
let verifier = match secret { // Caller-supplied tokens must prove possession of the issuer's signing
Some(secret) => CoreIdTokenVerifier::new_confidential_client(client_id, secret, issuer.clone(), jwks.clone()), // key. A client secret is shared with clients and cannot provide that proof.
None => CoreIdTokenVerifier::new_public_client(client_id, issuer.clone(), jwks.clone()), let algorithms = algorithms
}; .iter()
verifier.set_allowed_algs(algorithms.clone()) .filter(|algorithm| {
!matches!(
algorithm,
CoreJwsSigningAlgorithm::HmacSha256
| CoreJwsSigningAlgorithm::HmacSha384
| CoreJwsSigningAlgorithm::HmacSha512
)
})
.cloned()
.collect::<Vec<_>>();
CoreIdTokenVerifier::new_public_client(client_id, issuer.clone(), jwks.clone()).set_allowed_algs(algorithms)
} }
} }
pub(super) fn verify_id_token_claims<'a, N: NonceVerifier>(
token: &'a CoreIdToken,
verifier: &CoreIdTokenVerifier<'_>,
nonce_verifier: N,
) -> Result<&'a CoreIdTokenClaims, ClaimsVerificationError> {
let expected_length = match token.signing_alg().map_err(ClaimsVerificationError::SignatureVerification)? {
CoreJwsSigningAlgorithm::HmacSha256 => 32,
CoreJwsSigningAlgorithm::HmacSha384 => 48,
CoreJwsSigningAlgorithm::HmacSha512 => 64,
_ => return token.claims(verifier, nonce_verifier),
};
// RUSTFS_COMPAT_TODO(openidconnect-hmac-signature-width): Reject widths that upstream converts to fixed-size arrays. Remove after upstream returns verification errors for arbitrary HMAC signature lengths.
// openidconnect 4.1.0 converts HMAC signatures to fixed-size arrays before verifying them.
let invalid_length = || {
ClaimsVerificationError::SignatureVerification(SignatureVerificationError::CryptoError(
"invalid HMAC signature length".to_owned(),
))
};
let compact = token.to_string();
let (_, signature) = compact.rsplit_once('.').ok_or_else(invalid_length)?;
let signature = base64_simd::URL_SAFE_NO_PAD
.decode_to_vec(signature)
.map_err(|_| invalid_length())?;
if signature.len() != expected_length {
return Err(invalid_length());
}
token.claims(verifier, nonce_verifier)
}
// This adapter is used only for discovery/JWKS fetches, never token exchange. // This adapter is used only for discovery/JWKS fetches, never token exchange.
struct JwksAcceptClient<'a> { struct JwksAcceptClient<'a> {
inner: &'a ReqwestHttpClient, inner: &'a ReqwestHttpClient,
@@ -243,8 +288,9 @@ impl ProviderState {
impl ProviderRuntime { impl ProviderRuntime {
pub(super) fn new(http_client: ReqwestHttpClient, states: HashMap<String, ProviderState>) -> Self { pub(super) fn new(http_client: ReqwestHttpClient, states: HashMap<String, ProviderState>) -> Self {
Self { Self {
states: RwLock::new(states), states: Arc::new(RwLock::new(states)),
http_client, http_client: Arc::new(http_client),
refreshes: Arc::new(Mutex::new(HashMap::new())),
} }
} }
@@ -296,9 +342,38 @@ impl ProviderRuntime {
provider_id: &str, provider_id: &str,
config: &OidcProviderConfig, config: &OidcProviderConfig,
) -> Result<ProviderState, String> { ) -> Result<ProviderState, String> {
self.recover_provider_state(provider_id, config) let refresh = self
.refreshes
.lock()
.map_err(|_| "provider refresh registry is unavailable".to_string())?
.entry(provider_id.to_string())
.or_default()
.clone();
let mut attempt = refresh.lock_owned().await;
if let Some((completed, result)) = attempt.as_ref()
&& completed.elapsed() < OIDC_JWKS_REFRESH_COOLDOWN
{
return result.clone();
}
// The admitted fetch owns the per-provider lock. A disconnected request
// cannot abandon discovery while consuming the provider's cooldown.
// Waiting callers share its completed result, including genuine failures.
let runtime = tokio::runtime::Handle::try_current().map_err(|err| err.to_string())?;
let provider_runtime = self.clone();
let provider_id = provider_id.to_string();
let config = config.clone();
runtime
.spawn(async move {
let result = provider_runtime
.recover_provider_state(&provider_id, &config)
.await
.map_err(|err| err.to_string());
*attempt = Some((Instant::now(), result.clone()));
result
})
.await .await
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())?
} }
pub(super) async fn recover_provider_state( pub(super) async fn recover_provider_state(
+173 -22
View File
@@ -180,7 +180,7 @@ fn workload_metadata_rejects_duplicate_fields() {
} }
} }
#[test] #[test]
fn workload_verifier_preserves_algorithm_secret_and_audience_policy() { fn caller_supplied_tokens_cannot_be_signed_with_the_client_secret() {
let secret = Nonce::new_random().secret().to_string(); let secret = Nonce::new_random().secret().to_string();
let mut config = build_mocked_oidc_provider_config("workload", "https://issuer.example.com"); let mut config = build_mocked_oidc_provider_config("workload", "https://issuer.example.com");
config.client_secret = Some(secret.clone()); config.client_secret = Some(secret.clone());
@@ -195,7 +195,8 @@ fn workload_verifier_preserves_algorithm_secret_and_audience_policy() {
let token: CoreIdToken = signed.parse().unwrap(); let token: CoreIdToken = signed.parse().unwrap();
for workload in [false, true] { for workload in [false, true] {
for (algorithms, accepted) in [ for (algorithms, accepted) in [
(serde_json::json!(["HS256", "unsupported-future-algorithm"]), true), (serde_json::json!(["RS256", "HS256"]), false),
(serde_json::json!(["HS256", "unsupported-future-algorithm"]), false),
(serde_json::json!(["ES256"]), false), (serde_json::json!(["ES256"]), false),
(serde_json::json!(["unsupported-future-algorithm"]), false), (serde_json::json!(["unsupported-future-algorithm"]), false),
] { ] {
@@ -216,21 +217,6 @@ fn workload_verifier_preserves_algorithm_secret_and_audience_policy() {
accepted, accepted,
"workload={workload}, algorithms={algorithms}" "workload={workload}, algorithms={algorithms}"
); );
if accepted {
assert!(
token.claims(&metadata.verifier(&config), |_: Option<&Nonce>| Ok(())).is_err(),
"additional audiences require explicit trust"
);
let mut wrong_secret = config.clone();
wrong_secret.client_secret = Some(Nonce::new_random().secret().to_string());
let verifier = metadata
.verifier(&wrong_secret)
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
assert!(
token.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_err(),
"incorrect client secret must fail"
);
}
} }
} }
} }
@@ -242,7 +228,7 @@ fn configured_verifier_rejects_malformed_hmac_signature_lengths() {
let now = time::OffsetDateTime::now_utc().unix_timestamp(); let now = time::OffsetDateTime::now_utc().unix_timestamp();
let payload = serde_json::json!({ let payload = serde_json::json!({
"iss": config.config_url, "sub": "synthetic-subject", "aud": config.client_id, "iss": config.config_url, "sub": "synthetic-subject", "aud": config.client_id,
"iat": now, "exp": now + 300, "iat": now, "exp": now + 300, "nonce": "synthetic-nonce",
}); });
for (algorithm, name, signature_length) in [ for (algorithm, name, signature_length) in [
@@ -275,22 +261,41 @@ fn configured_verifier_rejects_malformed_hmac_signature_lengths() {
.expect("parse configured provider metadata"); .expect("parse configured provider metadata");
let verifier = metadata.verifier(&config); let verifier = metadata.verifier(&config);
assert!( assert!(
valid.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_ok(), verify_id_token_claims(&valid, &verifier, |_: Option<&Nonce>| Ok(())).is_err(),
"caller-supplied HMAC tokens must be rejected"
);
// The authorization-code flow obtains tokens directly from the
// provider and still supports confidential-client verification.
let verifier = openidconnect::core::CoreIdTokenVerifier::new_confidential_client(
openidconnect::ClientId::new(config.client_id.clone()),
openidconnect::ClientSecret::new(secret.to_owned()),
metadata.issuer().clone(),
openidconnect::core::CoreJsonWebKeySet::new(vec![]),
)
.set_allowed_algs(vec![serde_json::from_value(serde_json::json!(name)).expect("parse signing algorithm")]);
assert!(
verify_id_token_claims(&valid, &verifier, |_: Option<&Nonce>| Ok(())).is_ok(),
"valid {name} token must pass for workload={workload}" "valid {name} token must pass for workload={workload}"
); );
assert!(verify_id_token_claims(&valid, &verifier, &Nonce::new("synthetic-nonce".into())).is_ok());
assert!(verify_id_token_claims(&valid, &verifier, &Nonce::new("wrong-nonce".into())).is_err());
for length in [1, 0, signature_length - 1, signature_length, signature_length + 1, 128] { for length in [1, 0, signature_length - 1, signature_length, signature_length + 1, 128] {
let signature = base64_simd::URL_SAFE_NO_PAD.encode_to_string(vec![0_u8; length]); let signature = base64_simd::URL_SAFE_NO_PAD.encode_to_string(vec![0_u8; length]);
let malformed: CoreIdToken = format!("{signing_input}.{signature}") let malformed: CoreIdToken = format!("{signing_input}.{signature}")
.parse() .parse()
.expect("invalid signature length must still reach cryptographic verification"); .expect("invalid signature length must remain parseable before verification");
assert!( assert!(
malformed.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_err(), verify_id_token_claims(&malformed, &verifier, |_: Option<&Nonce>| Ok(())).is_err(),
"{name} signature length {length} must return an error for workload={workload}" "{name} signature length {length} must return an error for workload={workload}"
); );
assert!(
verify_id_token_claims(&malformed, &verifier, &Nonce::new("synthetic-nonce".into())).is_err(),
"{name} signature length {length} must return an error with a nonce for workload={workload}"
);
} }
assert!( assert!(
valid.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_ok(), verify_id_token_claims(&valid, &verifier, |_: Option<&Nonce>| Ok(())).is_ok(),
"invalid inputs must not prevent later valid {name} verification" "invalid inputs must not prevent later valid {name} verification"
); );
} }
@@ -774,3 +779,149 @@ async fn test_validate_oidc_provider_config_returns_detailed_errors() {
assert!(err.contains(&format!("issuer '{base}/application/o/rustfs/'"))); assert!(err.contains(&format!("issuer '{base}/application/o/rustfs/'")));
assert!(handle.join().is_ok()); assert!(handle.join().is_ok());
} }
#[tokio::test]
async fn jwks_refresh_is_single_flight_and_survives_request_cancellation() {
let (initial_key, initial_jwk) = oidc_es256_key_and_jwk("initial");
let (rotated_key, rotated_jwk) = oidc_es256_key_and_jwk("rotated");
let (wrong_key, _) = oidc_es256_key_and_jwk("wrong");
let requests = Arc::new(AtomicUsize::new(0));
let started = Arc::new(tokio::sync::Notify::new());
let (release_tx, release_rx) = std::sync::mpsc::channel();
let server_requests = Arc::clone(&requests);
let server_started = Arc::clone(&started);
let Some((base, server)) = start_mock_oidc_discovery_server_with_jwks(
|base| (base.to_string(), format!("{base}/jwks"), "/jwks".to_string()),
6,
"ES256",
false,
move |fetch| {
let request = server_requests.fetch_add(1, Ordering::SeqCst);
if request == 2 {
server_started.notify_one();
release_rx
.recv_timeout(StdDuration::from_secs(5))
.expect("release refresh response");
}
serde_json::json!({"keys": [if fetch == 0 { &initial_jwk } else { &rotated_jwk }]}).to_string()
},
) else {
return;
};
let mut config = build_mocked_oidc_provider_config("bounded-refresh", &base);
config.issuer = Some(base.clone());
let http = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).expect("allow test origin"));
let state = discover_provider(&config, &http).await.expect("initial discovery");
let sys = Arc::new(OidcSys {
configs: HashMap::from([(config.id.clone(), test_sourced_config(config.clone()))]),
provider_runtime: ProviderRuntime::new(http, HashMap::from([(config.id.clone(), state)])),
state_store: OidcStateStore::new(),
pending_discovery: Default::default(),
});
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let payload = serde_json::json!({"iss": base, "sub": "user", "aud": config.client_id, "iat": now, "exp": now + 300});
let sign = |kid: &str, claims: &serde_json::Value, key: &EncodingKey| {
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(kid.to_string());
jsonwebtoken::encode(&header, claims, key).expect("sign test token")
};
for (claim, value) in [
("aud", serde_json::json!("wrong-audience")),
("exp", serde_json::json!(now - 300)),
] {
let mut invalid = payload.clone();
invalid[claim] = value;
assert!(
sys.verify_web_identity_token(&sign("initial", &invalid, &initial_key))
.await
.is_err()
);
}
assert_eq!(requests.load(Ordering::SeqCst), 2, "claim failures cannot refresh signing keys");
let invalid = sign("rotated", &payload, &wrong_key);
let attack = {
let sys = Arc::clone(&sys);
let invalid = invalid.clone();
tokio::spawn(async move { sys.verify_web_identity_token(&invalid).await })
};
tokio::time::timeout(StdDuration::from_secs(5), started.notified())
.await
.expect("refresh started");
attack.abort();
assert!(attack.await.expect_err("request canceled").is_cancelled());
let mut retries = Vec::new();
for _ in 0..16 {
let sys = Arc::clone(&sys);
let invalid = invalid.clone();
retries.push(tokio::spawn(async move { sys.verify_web_identity_token(&invalid).await }));
}
release_tx.send(()).expect("release provider");
for retry in retries {
assert!(
tokio::time::timeout(StdDuration::from_secs(5), retry)
.await
.expect("retry timeout")
.expect("retry join")
.is_err()
);
}
let rotated = sign("rotated", &payload, &rotated_key);
sys.verify_web_identity_token(&rotated)
.await
.expect("completed refresh must accept rotated keys after the original caller left");
assert_eq!(requests.load(Ordering::SeqCst), 4, "one discovery/JWKS pair for all rejected tokens");
sys.provider_runtime
.refresh_provider_state("independent-provider", &config)
.await
.expect("provider cooldowns must be independent");
assert_eq!(requests.load(Ordering::SeqCst), 6);
server.join().expect("mock server");
}
#[tokio::test]
async fn jwks_refresh_cools_down_failures_and_recovers_after_the_interval() {
let (_, initial_jwk) = oidc_es256_key_and_jwk("initial");
let (_, recovered_jwk) = oidc_es256_key_and_jwk("recovered");
let requests = Arc::new(AtomicUsize::new(0));
let server_requests = Arc::clone(&requests);
let Some((base, server)) = start_mock_oidc_discovery_server_with_jwks(
|base| (base.to_string(), format!("{base}/jwks"), "/jwks".to_string()),
6,
"ES256",
false,
move |fetch| {
server_requests.fetch_add(1, Ordering::SeqCst);
match fetch {
0 => serde_json::json!({"keys": [initial_jwk]}).to_string(),
1 => "invalid JWKS".to_string(),
_ => serde_json::json!({"keys": [recovered_jwk]}).to_string(),
}
},
) else {
return;
};
let mut config = build_mocked_oidc_provider_config("recovering-refresh", &base);
config.issuer = Some(base.clone());
let http = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).expect("allow test origin"));
let initial = discover_provider(&config, &http).await.expect("initial discovery");
let runtime = ProviderRuntime::new(http, HashMap::from([(config.id.clone(), initial.clone())]));
assert!(runtime.refresh_provider_state(&config.id, &config).await.is_err());
assert_eq!(requests.load(Ordering::SeqCst), 4);
for _ in 0..8 {
assert!(runtime.refresh_provider_state(&config.id, &config).await.is_err());
}
assert_eq!(requests.load(Ordering::SeqCst), 4, "completed failures also consume a bounded cooldown");
assert_eq!(
runtime.get_provider_state(&config.id).expect("cached keys").discovered_at,
initial.discovered_at
);
let refresh = runtime.refreshes.lock().expect("registry")[&config.id].clone();
refresh.lock().await.as_mut().expect("completed attempt").0 = Instant::now() - OIDC_JWKS_REFRESH_COOLDOWN;
runtime
.refresh_provider_state(&config.id, &config)
.await
.expect("retry after cooldown");
assert_eq!(requests.load(Ordering::SeqCst), 6);
assert!(runtime.get_provider_state(&config.id).expect("recovered keys").discovered_at > initial.discovered_at);
server.join().expect("mock server");
}
+13 -9
View File
@@ -16,7 +16,7 @@ use super::{
OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderSummary, OidcSiteReplicationSnapshot, OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderSummary, OidcSiteReplicationSnapshot,
SourcedOidcProviderConfig, load_oidc_config_snapshot, SourcedOidcProviderConfig, load_oidc_config_snapshot,
}, },
provider::{ProviderRuntime, discover_provider_for_recovery}, provider::{ProviderRuntime, discover_provider_for_recovery, verify_id_token_claims},
state::{OidcAuthSession, OidcLogoutSession, OidcStateStore}, state::{OidcAuthSession, OidcLogoutSession, OidcStateStore},
transport::{OidcExtraRootCaProvider, ReqwestHttpClient, inspect_token_response_body, oidc_http_error_diagnostics}, transport::{OidcExtraRootCaProvider, ReqwestHttpClient, inspect_token_response_body, oidc_http_error_diagnostics},
}; };
@@ -33,8 +33,8 @@ use crate::{
}; };
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken}; use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken};
use openidconnect::{ use openidconnect::{
Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, LogoutRequest, Nonce, PkceCodeChallenge, Audience, AuthType, AuthorizationCode, ClaimsVerificationError, ClientId, ClientSecret, CsrfToken, LogoutRequest, Nonce,
PkceCodeVerifier, PostLogoutRedirectUrl, RedirectUrl, RequestTokenError, Scope, PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, RedirectUrl, RequestTokenError, Scope,
}; };
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive}; use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use rustfs_utils::logging::LogThrottle; use rustfs_utils::logging::LogThrottle;
@@ -535,8 +535,11 @@ impl OidcSys {
let verifier = client let verifier = client
.id_token_verifier() .id_token_verifier()
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
let verified = id_token.claims(&verifier, &Nonce::new(session.nonce.clone())); let verified = verify_id_token_claims(id_token, &verifier, &Nonce::new(session.nonce.clone()));
if let Err(e) = verified { if let Err(e) = verified {
if !matches!(e, ClaimsVerificationError::SignatureVerification(_)) {
return Err(format!("ID token verification failed: {e}"));
}
let verification_error = e.to_string(); let verification_error = e.to_string();
warn!( warn!(
event = EVENT_OIDC_DIAGNOSTICS, event = EVENT_OIDC_DIAGNOSTICS,
@@ -583,8 +586,7 @@ impl OidcSys {
let verifier = client let verifier = client
.id_token_verifier() .id_token_verifier()
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
id_token verify_id_token_claims(id_token, &verifier, &Nonce::new(session.nonce.clone()))
.claims(&verifier, &Nonce::new(session.nonce.clone()))
.map_err(|retry_err| { .map_err(|retry_err| {
error!( error!(
event = EVENT_OIDC_DIAGNOSTICS, event = EVENT_OIDC_DIAGNOSTICS,
@@ -767,7 +769,10 @@ impl OidcSys {
.metadata .metadata
.verifier(&config) .verifier(&config)
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
if let Err(e) = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(())) { if let Err(e) = verify_id_token_claims(&id_token, &verifier, |_: Option<&Nonce>| Ok(())) {
if !matches!(e, ClaimsVerificationError::SignatureVerification(_)) {
return Err(format!("ID token verification failed: {e}"));
}
state = self state = self
.provider_runtime .provider_runtime
.refresh_provider_state(&provider_id, &config) .refresh_provider_state(&provider_id, &config)
@@ -780,8 +785,7 @@ impl OidcSys {
.metadata .metadata
.verifier(&config) .verifier(&config)
.set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
id_token verify_id_token_claims(&id_token, &verifier, |_: Option<&Nonce>| Ok(()))
.claims(&verifier, |_: Option<&Nonce>| Ok(()))
.map_err(|retry_err| format!("ID token verification failed after JWKS refresh: {retry_err}"))?; .map_err(|retry_err| format!("ID token verification failed after JWKS refresh: {retry_err}"))?;
} }
+14
View File
@@ -14,6 +14,20 @@
use std::sync::Arc; use std::sync::Arc;
pub(crate) fn is_unreadable_bucket_tags(error: &crate::error::Error) -> bool {
let crate::error::Error::Io(error) = error else {
return false;
};
let Some(storage_error) = error.get_ref().and_then(|inner| inner.downcast_ref::<IamStorageError>()) else {
return false;
};
rustfs_ecstore::api::bucket::metadata::unreadable_config_refusal(storage_error)
.is_some_and(|failure| failure.config_file == rustfs_ecstore::api::bucket::metadata::BUCKET_TAGGING_CONFIG)
}
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::metadata::UnreadableBucketConfig;
pub(crate) use rustfs_ecstore::api::config::RUSTFS_CONFIG_PREFIX as IAM_CONFIG_ROOT_PREFIX; pub(crate) use rustfs_ecstore::api::config::RUSTFS_CONFIG_PREFIX as IAM_CONFIG_ROOT_PREFIX;
use rustfs_ecstore::api::config::com::{ use rustfs_ecstore::api::config::com::{
delete_config as ecstore_delete_config, read_config_no_lock as ecstore_read_config_no_lock, delete_config as ecstore_delete_config, read_config_no_lock as ecstore_read_config_no_lock,
+146 -37
View File
@@ -1154,7 +1154,7 @@ impl Store for ObjectStore {
// node-counted lock quorum (rustfs#4304; startup contract rustfs#4056). // node-counted lock quorum (rustfs#4304; startup contract rustfs#4056).
const LOAD_ALL_MODE: LoadMode = LoadMode::BootstrapNoLock; const LOAD_ALL_MODE: LoadMode = LoadMode::BootstrapNoLock;
let cache_snapshot = cache.snapshot(); let reload = cache.begin_reload().await;
let listed_config_items = self.list_all_iamconfig_items().await?; let listed_config_items = self.list_all_iamconfig_items().await?;
let mut policy_docs_cache = CacheEntity::new(get_default_policies()); let mut policy_docs_cache = CacheEntity::new(get_default_policies());
@@ -1201,26 +1201,20 @@ impl Store for ObjectStore {
} }
// groups // groups
let mut groups_cache = None; let mut groups_cache = CacheEntity::default();
if let Some(item_name_list) = listed_config_items.get(GROUPS_LIST_KEY) { if let Some(item_name_list) = listed_config_items.get(GROUPS_LIST_KEY) {
let mut items_cache = CacheEntity::default();
for item in item_name_list.iter() { for item in item_name_list.iter() {
let name = rustfs_utils::path::dir(item); let name = rustfs_utils::path::dir(item);
debug!(group = %name, "IAM group loaded"); debug!(group = %name, "IAM group loaded");
if let Err(err) = self.load_group_with(&name, &mut items_cache, LOAD_ALL_MODE).await { if let Err(err) = self.load_group_with(&name, &mut groups_cache, LOAD_ALL_MODE).await {
return Err(Error::other(format!("load group failed: {err}"))); return Err(Error::other(format!("load group failed: {err}")));
}; };
} }
groups_cache = Some(items_cache);
} }
// user policies // user policies
let mut user_policies_cache = None; let mut user_policies_cache = CacheEntity::default();
if let Some(item_name_list) = listed_config_items.get(POLICY_DB_USERS_LIST_KEY) { if let Some(item_name_list) = listed_config_items.get(POLICY_DB_USERS_LIST_KEY) {
let mut items_cache = CacheEntity::default();
// Load in fixed-size chunks so each mapping is fetched exactly once // Load in fixed-size chunks so each mapping is fetched exactly once
// (backlog#806 — the split_off loop was O(n^2)). // (backlog#806 — the split_off loop was O(n^2)).
for chunk in item_name_list.chunks(32) { for chunk in item_name_list.chunks(32) {
@@ -1235,32 +1229,26 @@ impl Store for ObjectStore {
let name = chunk[idx].trim_end_matches(".json").to_owned(); let name = chunk[idx].trim_end_matches(".json").to_owned();
debug!(user = %name, "IAM user policy loaded"); debug!(user = %name, "IAM user policy loaded");
items_cache.insert(name, p); user_policies_cache.insert(name, p);
} }
} }
user_policies_cache = Some(items_cache);
} }
// group policy // group policy
let mut group_policies_cache = None; let mut group_policies_cache = CacheEntity::default();
if let Some(item_name_list) = listed_config_items.get(POLICY_DB_GROUPS_LIST_KEY) { if let Some(item_name_list) = listed_config_items.get(POLICY_DB_GROUPS_LIST_KEY) {
let mut items_cache = CacheEntity::default();
for item in item_name_list.iter() { for item in item_name_list.iter() {
let name = item.trim_end_matches(".json"); let name = item.trim_end_matches(".json");
debug!(group = %name, "IAM group policy loaded"); debug!(group = %name, "IAM group policy loaded");
if let Err(err) = self if let Err(err) = self
.load_mapped_policy_with(name, UserType::Reg, true, &mut items_cache, LOAD_ALL_MODE) .load_mapped_policy_with(name, UserType::Reg, true, &mut group_policies_cache, LOAD_ALL_MODE)
.await .await
&& !is_err_no_such_policy(&err) && !is_err_no_such_policy(&err)
{ {
return Err(Error::other(format!("load group policy failed: {err}"))); return Err(Error::other(format!("load group policy failed: {err}")));
}; };
} }
group_policies_cache = Some(items_cache);
} }
let mut sts_policies_cache = CacheEntity::default(); let mut sts_policies_cache = CacheEntity::default();
@@ -1330,25 +1318,48 @@ impl Store for ObjectStore {
} }
} }
#[cfg(test)]
if let Ok(barrier) = LOAD_ALL_COMMIT_BARRIER.try_with(std::sync::Arc::clone) {
barrier.0.notify_one();
barrier.1.notified().await;
}
cache.with_write_lock(|cache| { cache.with_write_lock(|cache| {
if cache.matches_snapshot(&cache_snapshot) { reload.merge_partition(
cache.replace_policy_docs(policy_docs_cache); crate::cache::CachePartition::PolicyDocs,
if let Some(groups_cache) = groups_cache { &mut policy_docs_cache,
cache.replace_groups(groups_cache); &cache.state().policy_docs,
} );
if let Some(user_policies_cache) = user_policies_cache { reload.merge_partition(crate::cache::CachePartition::Groups, &mut groups_cache, &cache.state().groups);
cache.replace_user_policies(user_policies_cache); reload.merge_partition(
} crate::cache::CachePartition::UserPolicies,
if let Some(group_policies_cache) = group_policies_cache { &mut user_policies_cache,
cache.replace_group_policies(group_policies_cache); &cache.state().user_policies,
} );
cache.replace_users(user_items_cache); reload.merge_partition(
cache.replace_sts_accounts(sts_items_cache); crate::cache::CachePartition::GroupPolicies,
cache.replace_sts_policies(sts_policies_cache); &mut group_policies_cache,
cache.build_user_group_memberships(); &cache.state().group_policies,
} else { );
warn!("IAM full reload cache commit skipped due to concurrent cache changes"); reload.merge_partition(crate::cache::CachePartition::Users, &mut user_items_cache, &cache.state().users);
} reload.merge_partition(
crate::cache::CachePartition::StsAccounts,
&mut sts_items_cache,
&cache.state().sts_accounts,
);
reload.merge_partition(
crate::cache::CachePartition::StsPolicies,
&mut sts_policies_cache,
&cache.state().sts_policies,
);
cache.replace_policy_docs(policy_docs_cache);
cache.replace_groups(groups_cache);
cache.replace_user_policies(user_policies_cache);
cache.replace_group_policies(group_policies_cache);
cache.replace_users(user_items_cache);
cache.replace_sts_accounts(sts_items_cache);
cache.replace_sts_policies(sts_policies_cache);
cache.build_user_group_memberships();
}); });
Ok(()) Ok(())
@@ -1363,6 +1374,11 @@ fn map_delete_user_identity_error(name: &str, err: Error) -> Error {
} }
} }
#[cfg(test)]
tokio::task_local! {
static LOAD_ALL_COMMIT_BARRIER: std::sync::Arc<(tokio::sync::Notify, tokio::sync::Notify)>;
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{DecryptSource, LoadMode, ObjectStore, map_delete_user_identity_error}; use super::{DecryptSource, LoadMode, ObjectStore, map_delete_user_identity_error};
@@ -1372,6 +1388,99 @@ mod tests {
use serial_test::serial; use serial_test::serial;
use temp_env::with_vars; use temp_env::with_vars;
#[test]
#[serial]
fn test_load_all_clears_partitions_with_empty_prefix() {
use crate::cache::Cache;
use crate::store::{GroupInfo, MappedPolicy, Store, UserType};
std::thread::Builder::new()
.stack_size(8 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("build IAM reload test runtime");
runtime.block_on(async {
let _credentials = test_cred();
let temp = tempfile::tempdir().expect("create IAM reload store directory");
let env = Box::pin(
rustfs_test_utils::TestECStoreEnv::builder()
.base_dir(temp.path())
.init_bucket_metadata(false)
.build(),
)
.await;
let store = ObjectStore::new(env.ecstore);
store
.save_group_info("reload-group", GroupInfo::new(vec!["reload-user".to_string()]))
.await
.expect("seed group membership");
store
.save_mapped_policy("reload-user", UserType::Reg, false, MappedPolicy::new("readwrite"), None)
.await
.expect("seed user policy");
store
.save_mapped_policy("reload-group", UserType::Reg, true, MappedPolicy::new("readonly"), None)
.await
.expect("seed group policy");
let cache = Cache::default();
store.load_all(&cache).await.expect("load nonempty IAM partitions");
{
let snapshot = cache.snapshot();
assert!(snapshot.groups.contains_key("reload-group"));
assert_eq!(snapshot.user_policies["reload-user"].policies, "readwrite");
assert_eq!(snapshot.group_policies["reload-group"].policies, "readonly");
assert!(snapshot.user_group_memberships["reload-user"].contains("reload-group"));
}
// Mutate durable state without notifying this cache, as on
// a peer that missed the last mapping/group deletion.
store
.delete_mapped_policy("reload-user", UserType::Reg, false)
.await
.expect("remove last user mapping");
store
.delete_mapped_policy("reload-group", UserType::Reg, true)
.await
.expect("remove last group mapping");
store.delete_group_info("reload-group").await.expect("remove last group");
assert!(cache.snapshot().groups.contains_key("reload-group"));
// Simulate a missed peer revocation while STS publication
// and a separate user update overlap this full disk reload.
let stale = rustfs_policy::auth::UserIdentity {
version: 1,
..Default::default()
};
cache.add_or_update_user("revoked-user", &stale, time::OffsetDateTime::now_utc());
let barrier = std::sync::Arc::new((tokio::sync::Notify::new(), tokio::sync::Notify::new()));
let loading = super::LOAD_ALL_COMMIT_BARRIER.scope(std::sync::Arc::clone(&barrier), store.load_all(&cache));
let concurrent = async {
barrier.0.notified().await;
cache.add_or_update_sts_account("new-session", &stale, time::OffsetDateTime::now_utc());
cache.add_or_update_user("new-service", &stale, time::OffsetDateTime::now_utc());
barrier.1.notify_one();
};
let (loaded, ()) = tokio::join!(loading, concurrent);
loaded.expect("reconcile revoked identities despite STS traffic");
let snapshot = cache.snapshot();
assert!(!snapshot.users.contains_key("revoked-user"));
assert!(snapshot.users.contains_key("new-service"));
assert!(snapshot.sts_accounts.contains_key("new-session"));
assert!(snapshot.groups.is_empty());
assert!(snapshot.user_policies.is_empty());
assert!(snapshot.group_policies.is_empty());
assert!(snapshot.user_group_memberships.is_empty());
assert!(!snapshot.policy_docs.is_empty(), "built-in policies must remain available");
});
})
.expect("spawn IAM reload test thread")
.join()
.expect("IAM reload test should complete");
}
/// rustfs#4304 / startup contract rustfs#4056: the bulk snapshot load /// rustfs#4304 / startup contract rustfs#4056: the bulk snapshot load
/// (`load_all`) must never depend on the node-counted namespace lock /// (`load_all`) must never depend on the node-counted namespace lock
/// quorum, so its reads have to carry `no_lock = true` down to the /// quorum, so its reads have to carry `no_lock = true` down to the
+1013 -18
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -42,6 +42,7 @@ rustfs-io-metrics = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
[dev-dependencies] [dev-dependencies]
metrics = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs"] }
[lib] [lib]
+79 -68
View File
@@ -83,7 +83,10 @@ struct PoolTier {
/// Queue of available buffers for reuse /// Queue of available buffers for reuse
available_buffers: Mutex<Vec<BytesMut>>, available_buffers: Mutex<Vec<BytesMut>>,
/// Metrics for tracking this tier /// Metrics for tracking this tier
metrics: Mutex<Option<Arc<BytesPoolMetrics>>>, metrics: Arc<BytesPoolMetrics>,
/// Serialize acquisitions before taking buffers so a slow recorder does not
/// accumulate allocated buffers behind hit-rate publication.
acquire_metrics_lock: Mutex<()>,
/// Total acquisitions for this tier /// Total acquisitions for this tier
tier_total_acquires: AtomicU64, tier_total_acquires: AtomicU64,
/// Total hits for this tier /// Total hits for this tier
@@ -185,16 +188,10 @@ impl BytesPool {
/// ``` /// ```
pub fn with_config(config: BytesPoolConfig) -> Self { pub fn with_config(config: BytesPoolConfig) -> Self {
let metrics = Arc::new(BytesPoolMetrics::default()); let metrics = Arc::new(BytesPoolMetrics::default());
let small_pool = Arc::new(PoolTier::new(config.small_size, config.small_max, "small")); let small_pool = Arc::new(PoolTier::new(config.small_size, config.small_max, "small", Arc::clone(&metrics)));
let medium_pool = Arc::new(PoolTier::new(config.medium_size, config.medium_max, "medium")); let medium_pool = Arc::new(PoolTier::new(config.medium_size, config.medium_max, "medium", Arc::clone(&metrics)));
let large_pool = Arc::new(PoolTier::new(config.large_size, config.large_max, "large")); let large_pool = Arc::new(PoolTier::new(config.large_size, config.large_max, "large", Arc::clone(&metrics)));
let xlarge_pool = Arc::new(PoolTier::new(config.xlarge_size, config.xlarge_max, "xlarge")); let xlarge_pool = Arc::new(PoolTier::new(config.xlarge_size, config.xlarge_max, "xlarge", Arc::clone(&metrics)));
// Set metrics reference in all tiers
small_pool.set_metrics(Arc::clone(&metrics));
medium_pool.set_metrics(Arc::clone(&metrics));
large_pool.set_metrics(Arc::clone(&metrics));
xlarge_pool.set_metrics(Arc::clone(&metrics));
Self { Self {
small_pool, small_pool,
@@ -225,7 +222,7 @@ impl BytesPool {
/// ``` /// ```
pub async fn acquire_buffer(&self, size: usize) -> PooledBuffer { pub async fn acquire_buffer(&self, size: usize) -> PooledBuffer {
let tier = self.select_tier(size); let tier = self.select_tier(size);
let mut buffer = tier.acquire_buffer(size, &self.metrics).await; let mut buffer = tier.acquire_buffer(size).await;
if buffer._permit.is_some() { if buffer._permit.is_some() {
buffer.tier = Some(Arc::clone(tier)); buffer.tier = Some(Arc::clone(tier));
} }
@@ -252,7 +249,7 @@ impl BytesPool {
/// ``` /// ```
pub fn try_acquire_buffer(&self, size: usize) -> Option<PooledBuffer> { pub fn try_acquire_buffer(&self, size: usize) -> Option<PooledBuffer> {
let tier = self.select_tier(size); let tier = self.select_tier(size);
let mut buffer = tier.try_acquire_buffer(size, &self.metrics)?; let mut buffer = tier.try_acquire_buffer(size)?;
// Set tier reference for return on drop // Set tier reference for return on drop
buffer.tier = Some(Arc::clone(tier)); buffer.tier = Some(Arc::clone(tier));
Some(buffer) Some(buffer)
@@ -290,43 +287,38 @@ impl BytesPool {
} }
impl PoolTier { impl PoolTier {
fn new(buffer_size: usize, max_buffers: usize, name: &'static str) -> Self { fn new(buffer_size: usize, max_buffers: usize, name: &'static str, metrics: Arc<BytesPoolMetrics>) -> Self {
Self { Self {
buffer_size, buffer_size,
max_buffers, max_buffers,
semaphore: Arc::new(Semaphore::new(max_buffers)), semaphore: Arc::new(Semaphore::new(max_buffers)),
name, name,
available_buffers: Mutex::new(Vec::new()), available_buffers: Mutex::new(Vec::new()),
metrics: Mutex::new(None), metrics,
acquire_metrics_lock: Mutex::new(()),
tier_total_acquires: AtomicU64::new(0), tier_total_acquires: AtomicU64::new(0),
tier_pool_hits: AtomicU64::new(0), tier_pool_hits: AtomicU64::new(0),
tier_current_allocated_bytes: AtomicU64::new(0), tier_current_allocated_bytes: AtomicU64::new(0),
} }
} }
fn set_metrics(&self, metrics: Arc<BytesPoolMetrics>) { fn take_or_allocate_buffer(&self, size: usize) -> (BytesMut, bool) {
*self.metrics.lock().unwrap_or_else(|e| e.into_inner()) = Some(metrics); let pool_metrics = &self.metrics;
}
fn take_or_allocate_buffer(&self, size: usize, pool_metrics: &BytesPoolMetrics) -> (BytesMut, bool) {
let buffer_opt = { let buffer_opt = {
let mut available = self.available_buffers.lock().unwrap_or_else(|e| e.into_inner()); let mut available = self.available_buffers.lock().unwrap_or_else(|e| e.into_inner());
available.pop() let buffer = available.pop();
if buffer.is_some() {
pool_metrics.available_buffers.fetch_sub(1, Ordering::Relaxed);
}
buffer
}; };
let was_reused = buffer_opt.is_some(); let was_reused = buffer_opt.is_some();
if was_reused {
// A reused buffer leaves the available pool. `return_buffer` does a
// matching `fetch_add(1)` on push, so without this the
// `available_buffers` gauge only ever grows and never reflects the real
// pool size (backlog#806).
pool_metrics.available_buffers.fetch_sub(1, Ordering::Relaxed);
}
let buffer = if let Some(mut buf) = buffer_opt { let buffer = if let Some(mut buf) = buffer_opt {
let previous_capacity = buf.capacity(); let previous_capacity = buf.capacity();
buf.clear(); buf.clear();
if previous_capacity < size { if previous_capacity < size {
buf.reserve(size - previous_capacity); buf.reserve(size);
} }
let current_capacity = buf.capacity(); let current_capacity = buf.capacity();
if current_capacity > previous_capacity { if current_capacity > previous_capacity {
@@ -353,7 +345,10 @@ impl PoolTier {
(buffer, was_reused) (buffer, was_reused)
} }
fn record_acquire_metrics(&self, pool_metrics: &BytesPoolMetrics, buffer_capacity: usize, was_reused: bool) { fn record_acquire_metrics(&self, buffer_capacity: usize, was_reused: bool) {
let pool_metrics = &self.metrics;
pool_metrics.total_acquires.fetch_add(1, Ordering::Relaxed);
self.tier_total_acquires.fetch_add(1, Ordering::Relaxed);
rustfs_io_metrics::record_bytes_pool_acquire(self.name, buffer_capacity, was_reused); rustfs_io_metrics::record_bytes_pool_acquire(self.name, buffer_capacity, was_reused);
if was_reused { if was_reused {
@@ -374,7 +369,7 @@ impl PoolTier {
rustfs_io_metrics::record_bytes_pool_allocated(self.name, self.tier_current_allocated_bytes.load(Ordering::Relaxed)); rustfs_io_metrics::record_bytes_pool_allocated(self.name, self.tier_current_allocated_bytes.load(Ordering::Relaxed));
} }
async fn acquire_buffer(&self, size: usize, pool_metrics: &BytesPoolMetrics) -> PooledBuffer { async fn acquire_buffer(&self, size: usize) -> PooledBuffer {
// Acquire semaphore permit (owned for storage in PooledBuffer) // Acquire semaphore permit (owned for storage in PooledBuffer)
let permit = match Arc::clone(&self.semaphore).acquire_owned().await { let permit = match Arc::clone(&self.semaphore).acquire_owned().await {
Ok(p) => p, Ok(p) => p,
@@ -388,17 +383,10 @@ impl PoolTier {
} }
}; };
// Use the pool's shared metrics for recording let _guard = self.acquire_metrics_lock.lock().unwrap_or_else(|e| e.into_inner());
let _metrics_lock = self.metrics.lock().unwrap_or_else(|e| e.into_inner()); let (buffer, was_reused) = self.take_or_allocate_buffer(size);
let _metrics = _metrics_lock.as_ref().expect("operation should succeed");
// Record acquisition
pool_metrics.total_acquires.fetch_add(1, Ordering::Relaxed);
self.tier_total_acquires.fetch_add(1, Ordering::Relaxed);
let (buffer, was_reused) = self.take_or_allocate_buffer(size, pool_metrics);
let buffer_capacity = buffer.capacity(); let buffer_capacity = buffer.capacity();
self.record_acquire_metrics(pool_metrics, buffer_capacity, was_reused); self.record_acquire_metrics(buffer_capacity, was_reused);
PooledBuffer { PooledBuffer {
buffer: ManuallyDrop::new(buffer), buffer: ManuallyDrop::new(buffer),
@@ -407,21 +395,14 @@ impl PoolTier {
} }
} }
fn try_acquire_buffer(&self, size: usize, pool_metrics: &BytesPoolMetrics) -> Option<PooledBuffer> { fn try_acquire_buffer(&self, size: usize) -> Option<PooledBuffer> {
// Try to acquire permit without blocking // Try to acquire permit without blocking
let permit = Arc::clone(&self.semaphore).try_acquire_owned().ok()?; let permit = Arc::clone(&self.semaphore).try_acquire_owned().ok()?;
// Use the pool's shared metrics for recording let _guard = self.acquire_metrics_lock.lock().unwrap_or_else(|e| e.into_inner());
let _metrics_lock = self.metrics.lock().unwrap_or_else(|e| e.into_inner()); let (buffer, was_reused) = self.take_or_allocate_buffer(size);
let _metrics = _metrics_lock.as_ref().expect("operation should succeed");
// Record acquisition
pool_metrics.total_acquires.fetch_add(1, Ordering::Relaxed);
self.tier_total_acquires.fetch_add(1, Ordering::Relaxed);
let (buffer, was_reused) = self.take_or_allocate_buffer(size, pool_metrics);
let buffer_capacity = buffer.capacity(); let buffer_capacity = buffer.capacity();
self.record_acquire_metrics(pool_metrics, buffer_capacity, was_reused); self.record_acquire_metrics(buffer_capacity, was_reused);
Some(PooledBuffer { Some(PooledBuffer {
buffer: ManuallyDrop::new(buffer), buffer: ManuallyDrop::new(buffer),
@@ -430,7 +411,7 @@ impl PoolTier {
}) })
} }
/// Return a buffer to the pool for reuse without ever blocking the caller. /// Return a buffer to the pool for reuse without waiting for the queue lock.
fn return_buffer(&self, buffer: BytesMut) { fn return_buffer(&self, buffer: BytesMut) {
let mut buffer = Some(buffer); let mut buffer = Some(buffer);
@@ -438,11 +419,7 @@ impl PoolTier {
&& available.len() < self.max_buffers && available.len() < self.max_buffers
{ {
available.push(buffer.take().expect("buffer should be present until returned")); available.push(buffer.take().expect("buffer should be present until returned"));
if let Ok(metrics) = self.metrics.try_lock() self.metrics.available_buffers.fetch_add(1, Ordering::Relaxed);
&& let Some(metrics) = metrics.as_ref()
{
metrics.available_buffers.fetch_add(1, Ordering::Relaxed);
}
} }
if let Some(buffer) = buffer { if let Some(buffer) = buffer {
@@ -452,16 +429,12 @@ impl PoolTier {
Some(current.saturating_sub(released_bytes)) Some(current.saturating_sub(released_bytes))
}) })
.ok(); .ok();
if let Ok(metrics) = self.metrics.try_lock() self.metrics
&& let Some(metrics) = metrics.as_ref() .current_allocated_bytes
{ .try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
metrics Some(current.saturating_sub(released_bytes))
.current_allocated_bytes })
.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| { .ok();
Some(current.saturating_sub(released_bytes))
})
.ok();
}
} }
rustfs_io_metrics::record_bytes_pool_allocated(self.name, self.tier_current_allocated_bytes.load(Ordering::Relaxed)); rustfs_io_metrics::record_bytes_pool_allocated(self.name, self.tier_current_allocated_bytes.load(Ordering::Relaxed));
} }
@@ -551,6 +524,44 @@ mod tests {
assert!(buffer.capacity() >= 2048); assert!(buffer.capacity() >= 2048);
} }
#[tokio::test]
async fn reused_buffer_satisfies_larger_async_request() {
let pool = BytesPool::with_config(BytesPoolConfig {
small_max: 1,
..Default::default()
});
let mut first = pool.acquire_buffer(32 * 1024).await;
first.extend_from_slice(b"previous contents");
assert!(first.capacity() < 48 * 1024, "fixture must require buffer growth");
drop(first);
let reused = pool.acquire_buffer(48 * 1024).await;
assert!(reused.is_empty());
assert!(reused.capacity() >= 48 * 1024);
assert_eq!(pool.metrics().pool_hits.load(Ordering::Relaxed), 1);
assert_eq!(
pool.metrics().current_allocated_bytes.load(Ordering::Relaxed),
u64::try_from(reused.capacity()).expect("buffer capacity fits in metrics")
);
}
#[test]
fn reused_buffer_satisfies_larger_try_request() {
let pool = BytesPool::with_config(BytesPoolConfig {
small_max: 1,
..Default::default()
});
let mut first = pool.try_acquire_buffer(32 * 1024).expect("first buffer is available");
first.extend_from_slice(b"previous contents");
assert!(first.capacity() < 48 * 1024, "fixture must require buffer growth");
drop(first);
let reused = pool.try_acquire_buffer(48 * 1024).expect("returned buffer is available");
assert!(reused.is_empty());
assert!(reused.capacity() >= 48 * 1024);
assert_eq!(pool.metrics().pool_hits.load(Ordering::Relaxed), 1);
}
#[tokio::test] #[tokio::test]
async fn available_buffers_gauge_decrements_on_reuse() { async fn available_buffers_gauge_decrements_on_reuse() {
// Regression (backlog#806): acquiring a pooled (reused) buffer must // Regression (backlog#806): acquiring a pooled (reused) buffer must
@@ -0,0 +1,121 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::{Mutex, mpsc};
use std::thread;
use std::time::Duration;
use metrics::{Counter, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit};
use rustfs_io_core::{BytesPool, BytesPoolConfig};
const WAIT: Duration = Duration::from_secs(5);
const GATE_WAIT: Duration = Duration::from_secs(20);
struct AcquisitionGate {
entered: mpsc::Sender<()>,
release: Mutex<mpsc::Receiver<()>>,
}
impl Recorder for AcquisitionGate {
fn describe_counter(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
fn describe_gauge(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
fn describe_histogram(&self, _key: KeyName, _unit: Option<Unit>, _description: SharedString) {}
fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter {
if key.name() == "rustfs_bytes_pool_acquisitions_total" {
self.entered
.send(())
.expect("notify test that acquisition reached the recorder");
self.release
.lock()
.expect("lock acquisition gate")
.recv_timeout(GATE_WAIT)
.expect("release the acquisition recorder gate");
}
Counter::noop()
}
fn register_gauge(&self, _key: &Key, _metadata: &Metadata<'_>) -> Gauge {
Gauge::noop()
}
fn register_histogram(&self, _key: &Key, _metadata: &Metadata<'_>) -> Histogram {
Histogram::noop()
}
}
#[test]
fn returned_buffer_is_counted_while_an_acquisition_recorder_is_busy() {
let pool = BytesPool::with_config(BytesPoolConfig {
small_max: 2,
..Default::default()
});
let held = pool.try_acquire_buffer(8 * 1024).expect("acquire the buffer to return");
assert_eq!(pool.available_buffers(), 0);
// This integration test is a dedicated test process: the recorder is local
// to the worker, while the metrics enablement flag is process-wide.
rustfs_io_metrics::set_metrics_enabled(true);
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let worker_pool = pool.clone();
let acquirer = thread::spawn(move || {
let gate = AcquisitionGate {
entered: entered_tx,
release: Mutex::new(release_rx),
};
metrics::with_local_recorder(&gate, || {
worker_pool
.try_acquire_buffer(8 * 1024)
.expect("acquire a second same-tier buffer")
})
});
let entered = entered_rx.recv_timeout(WAIT);
if entered.is_err() {
let _ = release_tx.send(());
let _ = acquirer.join();
panic!("acquisition did not reach the recorder gate: {entered:?}");
}
let (dropped_tx, dropped_rx) = mpsc::channel();
let returner = thread::spawn(move || {
drop(held);
let _ = dropped_tx.send(());
});
let returned_while_blocked = dropped_rx.recv_timeout(WAIT);
// Always release before joining so a blocking Drop cannot deadlock the test.
let _ = release_tx.send(());
let returned = returner.join();
let acquired = acquirer.join();
rustfs_io_metrics::set_metrics_enabled(false);
assert!(returned_while_blocked.is_ok(), "returning a buffer must not wait on the recorder");
returned.expect("return worker should finish");
let acquired = acquired.expect("acquisition worker should finish");
let after_return = pool.available_buffers();
let reused = pool.try_acquire_buffer(8 * 1024).expect("reuse the returned buffer");
let after_reuse = pool.available_buffers();
drop(reused);
drop(acquired);
let after_both_return = pool.available_buffers();
assert_eq!(
(after_return, after_reuse, after_both_return),
(1, 0, 2),
"returned buffers must be counted, and reuse must not underflow the available gauge"
);
}
@@ -1411,6 +1411,28 @@ pub fn record_peer_reachable(addr: &str) {
publish_offline_gauge(&peers); publish_offline_gauge(&peers);
} }
/// Record a successful interaction with a cluster peer, but only when the peer is currently
/// marked offline. This is the fast recovery path for cached-channel RPCs, recovery probes,
/// and lock-path successes that do not go through a fresh dial.
///
/// Fast path (peer unknown or already online): a shared read lock, no write, no gauge
/// republish — the overwhelmingly common case on a healthy cluster. Slow path (offline →
/// online transition): upgrades to the write lock and delegates to [`record_peer_reachable`].
pub fn record_peer_reachable_if_offline(addr: &str) {
let key = normalize_peer_key(addr);
// Fast path: read lock to check whether the peer is currently offline.
{
let peers = CLUSTER_PEER_HEALTH.read().unwrap_or_else(|poisoned| poisoned.into_inner());
match peers.get(key) {
None => return,
Some(entry) if entry.online => return,
Some(_) => {} // offline: fall through to the write path
}
}
// Slow path: the peer is offline, so flip it back online and republish the gauge.
record_peer_reachable(addr);
}
/// Record a failed interaction with a cluster peer (dial failure or RPC-triggered eviction). After /// Record a failed interaction with a cluster peer (dial failure or RPC-triggered eviction). After
/// `failure_threshold` (>= 1) consecutive failures the peer flips offline. /// `failure_threshold` (>= 1) consecutive failures the peer flips offline.
pub fn record_peer_unreachable(addr: &str, failure_threshold: u32) { pub fn record_peer_unreachable(addr: &str, failure_threshold: u32) {
@@ -2188,6 +2210,34 @@ mod tests {
assert_eq!(CLUSTER_SERVERS_OFFLINE_TOTAL, "rustfs_cluster_servers_offline_total"); assert_eq!(CLUSTER_SERVERS_OFFLINE_TOTAL, "rustfs_cluster_servers_offline_total");
} }
#[test]
fn record_peer_reachable_if_offline_recovers_stale_offline_state() {
let addr = "http://cluster-peer-recover-if-offline-test:9000";
// Mark the peer offline.
record_peer_unreachable(addr, 1);
assert!(cluster_peer_is_offline(addr));
// A successful cached-channel RPC (no fresh dial) clears the offline state.
record_peer_reachable_if_offline(addr);
assert!(!cluster_peer_is_offline(addr));
assert_eq!(cluster_peer_observed_online_status(addr), Some(true));
}
#[test]
fn record_peer_reachable_if_offline_no_op_for_online_peer() {
let addr = "http://cluster-peer-recover-noop-test:9000";
record_peer_reachable(addr);
// Should not panic or change state for an already-online peer.
record_peer_reachable_if_offline(addr);
assert_eq!(cluster_peer_observed_online_status(addr), Some(true));
// Should also be a no-op for unknown peers.
record_peer_reachable_if_offline("http://cluster-peer-recover-unknown-test:9000");
assert_eq!(cluster_peer_observed_online_status("http://cluster-peer-recover-unknown-test:9000"), None);
}
#[test] #[test]
fn cluster_peer_should_bypass_is_self_healing() { fn cluster_peer_should_bypass_is_self_healing() {
let addr = "http://cluster-peer-bypass-selfheal-test:9000"; let addr = "http://cluster-peer-bypass-selfheal-test:9000";
+11
View File
@@ -85,6 +85,17 @@ impl KeystoneAuthProvider {
Ok(self.keystone_token_to_credentials(&keystone_token)) Ok(self.keystone_token_to_credentials(&keystone_token))
} }
/// Revalidate a long-lived subscription directly with Keystone, bypassing
/// the request cache so token revocation terminates an existing stream.
/// Returns the same authentication and expiry errors as a new token login.
pub async fn revalidate_subscription_token(&self, token: &str) -> Result<Credentials> {
let validated = self.client.validate_token(token).await?;
if validated.is_expired() {
return Err(KeystoneError::TokenExpired);
}
Ok(self.keystone_token_to_credentials(&validated))
}
/// Authenticate using EC2 credentials (S3 API with AWS SigV4) /// Authenticate using EC2 credentials (S3 API with AWS SigV4)
pub async fn authenticate_with_ec2(&self, access_key: &str, signature: &str, string_to_sign: &str) -> Result<Credentials> { pub async fn authenticate_with_ec2(&self, access_key: &str, signature: &str, string_to_sign: &str) -> Result<Credentials> {
// Check cache // Check cache
+142 -6
View File
@@ -121,7 +121,7 @@ impl KeystoneClient {
.await .await
.map_err(|e| { .map_err(|e| {
error!("Failed to send token validation request: {}", e); error!("Failed to send token validation request: {}", e);
KeystoneError::HttpError(e.to_string()) KeystoneError::HttpError(e.without_url().to_string())
})?; })?;
let status = response.status(); let status = response.status();
@@ -138,7 +138,10 @@ impl KeystoneClient {
))); )));
} }
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?; let body: serde_json::Value = response
.json()
.await
.map_err(|e| KeystoneError::ParseError(e.without_url().to_string()))?;
self.parse_token_v3(&body) self.parse_token_v3(&body)
} }
@@ -260,13 +263,16 @@ impl KeystoneClient {
.header("X-Auth-Token", admin_token) .header("X-Auth-Token", admin_token)
.send() .send()
.await .await
.map_err(|e| KeystoneError::HttpError(e.to_string()))?; .map_err(|e| KeystoneError::HttpError(e.without_url().to_string()))?;
if !response.status().is_success() { if !response.status().is_success() {
return Ok(vec![]); return Ok(vec![]);
} }
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?; let body: serde_json::Value = response
.json()
.await
.map_err(|e| KeystoneError::ParseError(e.without_url().to_string()))?;
let credentials = body let credentials = body
.get("credentials") .get("credentials")
@@ -365,7 +371,7 @@ impl KeystoneClient {
.json(&auth_payload) .json(&auth_payload)
.send() .send()
.await .await
.map_err(|e| KeystoneError::HttpError(e.to_string()))?; .map_err(|e| KeystoneError::HttpError(e.without_url().to_string()))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(KeystoneError::AuthenticationFailed("Admin authentication failed".to_string())); return Err(KeystoneError::AuthenticationFailed("Admin authentication failed".to_string()));
@@ -379,7 +385,10 @@ impl KeystoneClient {
.to_string(); .to_string();
// Parse expiration from response body // Parse expiration from response body
let body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?; let body: serde_json::Value = response
.json()
.await
.map_err(|e| KeystoneError::ParseError(e.without_url().to_string()))?;
let expires_at = body let expires_at = body
.get("token") .get("token")
@@ -410,6 +419,133 @@ impl KeystoneClient {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn subscription_token_refresh_bypasses_a_still_valid_login_cache() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fake Keystone");
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let expires = (OffsetDateTime::now_utc() + time::Duration::hours(1))
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
let body = json!({"token":{"user":{"id":"listener","name":"listener"},
"roles":[{"name":"Member"}],"expires_at":expires}})
.to_string();
let server = std::thread::spawn(move || {
for response in [
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
),
"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(),
] {
let (mut socket, _) = listener.accept().expect("token validation request");
socket.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let mut request = [0; 4096];
let length = socket.read(&mut request).expect("request headers");
assert!(String::from_utf8_lossy(&request[..length]).contains("subscription-token"));
socket.write_all(response.as_bytes()).expect("validation response");
}
});
let mut client = KeystoneClient::new(
endpoint,
KeystoneVersion::V3,
None,
None,
None,
"Default".into(),
true,
std::time::Duration::from_secs(5),
);
client.client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let provider = crate::KeystoneAuthProvider::new(client, 10, std::time::Duration::from_secs(60), true);
assert!(provider.authenticate_with_token("subscription-token").await.is_ok());
assert!(
provider.authenticate_with_token("subscription-token").await.is_ok(),
"ordinary login still uses the valid cached token"
);
assert!(matches!(
provider.revalidate_subscription_token("subscription-token").await,
Err(KeystoneError::InvalidToken)
));
server.join().expect("fake Keystone finished");
}
#[tokio::test]
async fn middleware_keystone_failures_do_not_disclose_upstream_locations() {
use http_body_util::{BodyExt, Full};
use std::io::{Read, Write};
use tower::{Layer, ServiceExt};
for (response, details) in [
(None, "Keystone token validation failed"),
(
Some("HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nnot-json"),
"Keystone token validation failed",
),
(
Some("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"),
"Invalid token",
),
] {
for header in ["X-Auth-Token", "X-Storage-Token"] {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fake Keystone");
let endpoint = format!("http://{}/private-deployment", listener.local_addr().unwrap());
let server = response.map(|response| {
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("Keystone request");
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let mut request = [0; 4096];
assert!(stream.read(&mut request).expect("read token validation request") > 0);
stream.write_all(response.as_bytes()).expect("send fake response");
})
});
let mut client = KeystoneClient::new(
endpoint.clone(),
KeystoneVersion::V3,
None,
None,
None,
"Default".to_owned(),
true,
std::time::Duration::from_secs(5),
);
client.client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
let provider = crate::KeystoneAuthProvider::new(client, 1, std::time::Duration::from_secs(1), false);
let inner = tower::service_fn(|_| async {
Ok::<_, std::convert::Infallible>(http::Response::new(Full::new(bytes::Bytes::new())))
});
let response = crate::middleware::KeystoneAuthLayer::new(Some(Arc::new(provider)))
.layer(inner)
.oneshot(
http::Request::builder()
.header(header, "private-token")
.body(Full::new(bytes::Bytes::new()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains(&format!("<Details>{details}</Details>")));
for secret in [&endpoint, "private-deployment", "private-token", "/v3/auth/tokens"] {
assert!(!body.contains(secret), "upstream diagnostics must not appear in the response");
}
if let Some(server) = server {
server.join().unwrap();
}
}
}
}
#[test] #[test]
fn test_client_creation() { fn test_client_creation() {
let client = KeystoneClient::new( let client = KeystoneClient::new(
+5 -1
View File
@@ -173,7 +173,11 @@ where
<Message>Invalid Keystone token</Message> <Message>Invalid Keystone token</Message>
<Details>{}</Details> <Details>{}</Details>
</Error>"#, </Error>"#,
xml_escape(&e.to_string()) xml_escape(match e {
crate::KeystoneError::InvalidToken => "Invalid token",
crate::KeystoneError::TokenExpired => "Token expired",
_ => "Keystone token validation failed",
})
); );
let body: BoxBody = Full::new(Bytes::from(error_xml)) let body: BoxBody = Full::new(Bytes::from(error_xml))
+11 -1
View File
@@ -214,6 +214,9 @@ impl KmsManager {
} }
async fn create_key_inner(&self, request: CreateKeyRequest) -> Result<CreateKeyResponse> { async fn create_key_inner(&self, request: CreateKeyRequest) -> Result<CreateKeyResponse> {
if request.key_name.as_deref() == Some(crate::probe::PROBE_KEY_ID) {
return Err(KmsError::invalid_operation("the KMS probe key is reserved for internal health checks"));
}
// A blank name is neither "generate one" (that is `None`) nor a usable // A blank name is neither "generate one" (that is `None`) nor a usable
// id: the Local backend would write a key file with an empty stem and // id: the Local backend would write a key file with an empty stem and
// the Vault backends would address their mount root, each failing with // the Vault backends would address their mount root, each failing with
@@ -438,6 +441,9 @@ impl KmsManager {
/// any backend sees the request. The backends repeat the window bound as a /// any backend sees the request. The backends repeat the window bound as a
/// defensive assertion for callers that hold a backend handle directly. /// defensive assertion for callers that hold a backend handle directly.
async fn delete_key_inner(&self, request: DeleteKeyRequest) -> Result<DeleteKeyResponse> { async fn delete_key_inner(&self, request: DeleteKeyRequest) -> Result<DeleteKeyResponse> {
if request.key_id == crate::probe::PROBE_KEY_ID {
return Err(KmsError::invalid_operation("the KMS probe key cannot be deleted through key management"));
}
self.check_deletion_request(&request)?; self.check_deletion_request(&request)?;
if request.force_immediate.unwrap_or(false) { if request.force_immediate.unwrap_or(false) {
self.refuse_referenced_immediate_deletion(&request.key_id).await?; self.refuse_referenced_immediate_deletion(&request.key_id).await?;
@@ -591,7 +597,11 @@ impl KmsManager {
/// Disable a key on behalf of `context`'s principal /// Disable a key on behalf of `context`'s principal
pub async fn disable_key_with_context(&self, key_id: &str, context: &OperationContext) -> Result<()> { pub async fn disable_key_with_context(&self, key_id: &str, context: &OperationContext) -> Result<()> {
let started = Instant::now(); let started = Instant::now();
let result = self.backend.disable_key(key_id).await; let result = if key_id == crate::probe::PROBE_KEY_ID {
Err(KmsError::invalid_operation("the KMS probe key cannot be disabled through key management"))
} else {
self.backend.disable_key(key_id).await
};
if result.is_ok() { if result.is_ok() {
self.invalidate_cached_metadata(key_id).await; self.invalidate_cached_metadata(key_id).await;
} }
+51
View File
@@ -700,6 +700,57 @@ mod tests {
); );
} }
#[tokio::test]
async fn key_management_cannot_preempt_or_disable_the_readiness_probe() {
let temp_dir = tempfile::tempdir().expect("key directory");
let backend = local_backend(&temp_dir).await;
let mut config = KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults();
config.allow_immediate_deletion = true;
let manager = crate::manager::KmsManager::new(backend.clone(), config);
let request = CreateKeyRequest {
key_name: Some(PROBE_KEY_ID.to_owned()),
..Default::default()
};
assert!(matches!(manager.create_key(request).await, Err(KmsError::InvalidOperation { .. })));
let worker = worker(backend.clone());
assert_eq!(worker.round().await, ProbeResult::Success, "the probe still provisions its reserved key");
assert!(matches!(manager.disable_key(PROBE_KEY_ID).await, Err(KmsError::InvalidOperation { .. })));
for immediate in [false, true] {
assert!(matches!(
manager
.delete_key(DeleteKeyRequest {
key_id: PROBE_KEY_ID.to_owned(),
pending_window_in_days: Some(7),
force_immediate: Some(immediate),
confirm_key_id: Some(PROBE_KEY_ID.to_owned())
})
.await,
Err(KmsError::InvalidOperation { .. })
));
assert_eq!(worker.round().await, ProbeResult::Success);
}
let customer = manager
.create_key(CreateKeyRequest {
key_name: Some("customer-probe-control".to_owned()),
..Default::default()
})
.await
.expect("customer key");
manager
.disable_key(&customer.key_id)
.await
.expect("customer key can be disabled");
manager
.delete_key(DeleteKeyRequest {
key_id: customer.key_id,
pending_window_in_days: Some(7),
..Default::default()
})
.await
.expect("customer deletion remains available");
assert_eq!(worker.round().await, ProbeResult::Success);
}
#[test] #[test]
fn read_only_backend_is_reported_unsupported_without_failures() { fn read_only_backend_is_reported_unsupported_without_failures() {
let (snapshot, result) = record_metrics(|| { let (snapshot, result) = record_metrics(|| {
+255
View File
@@ -694,6 +694,132 @@ impl DistributedLock {
self.acquire_guard(&req).await self.acquire_guard(&req).await
} }
/// Acquire exclusive locks for many independent resources in one batched
/// round: one [`LockClient::acquire_locks_batch`] RPC per client instead of
/// one quorum round-trip per key. Returns one guard or error per resource.
///
/// Bulk operations (S3 `DeleteObjects`) are the only multi-key write-lock
/// holders, and their keys are independent. Per-key outcomes are
/// deadlock-free without any serial fallback: the wire call is a single
/// round per client and every lock server orders concurrent batches by the
/// same global (shard, key) order, so no acquirer can hold one key while
/// waiting for another. A key that fails to reach quorum releases its
/// partial grants (awaited) before its error is returned.
///
/// Guards keep the same TTL heartbeat and lock-lost semantics as
/// [`Self::acquire_guard`]. Contention is waited out server-side up to
/// `timeout` (each request carries it), matching the single-key path.
pub async fn acquire_guard_batch(
&self,
resources: &[ObjectKey],
owner: &str,
timeout: Duration,
ttl: Duration,
) -> Vec<Result<DistributedLockGuard>> {
if resources.is_empty() {
return Vec::new();
}
if self.clients.is_empty() {
return resources
.iter()
.map(|_| Err(LockError::internal("No lock clients available")))
.collect();
}
let required_quorum = self.required_quorum(LockType::Exclusive);
let attempt_started = Instant::now();
let requests: Vec<LockRequest> = resources
.iter()
.map(|resource| {
LockRequest::new(resource.clone(), LockType::Exclusive, owner)
.with_acquire_timeout(timeout)
.with_ttl(ttl)
})
.collect();
// One batched round per client; a client-level failure is a per-key
// failure for every key that still needed that client's vote.
let rounds = join_all(self.clients.iter().map(|client| {
let requests = requests.clone();
async move { client.acquire_locks_batch(&requests).await }
}))
.await;
let mut results: Vec<Result<DistributedLockGuard>> = Vec::with_capacity(resources.len());
let mut orphaned_entries: Vec<(LockId, Arc<dyn LockClient>)> = Vec::new();
for (key_idx, resource) in resources.iter().enumerate() {
let mut entries: Vec<HeldLockEntry> = Vec::new();
let mut failure: Option<String> = None;
for (client_idx, client) in self.clients.iter().enumerate() {
match &rounds[client_idx] {
Ok(responses) => match responses.get(key_idx) {
Some(response) if response.success => {
let lock_id = response
.lock_info
.as_ref()
.map(|info| info.id.clone())
.unwrap_or_else(|| LockId::new_unique(resource));
entries.push(HeldLockEntry {
lock_id,
client: client.clone(),
valid_until: attempt_started.checked_add(ttl).unwrap_or(attempt_started),
});
}
Some(response) => {
failure.get_or_insert_with(|| {
response
.error
.clone()
.unwrap_or_else(|| "distributed lock acquisition failed".to_string())
});
}
None => {
failure.get_or_insert_with(|| "client returned incomplete batch lock response".to_string());
}
},
Err(err) => {
failure.get_or_insert_with(|| err.to_string());
}
}
}
if entries.len() >= required_quorum {
let heartbeat = LockHeartbeatConfig {
ttl,
interval: derive_refresh_interval(entries.len(), ttl, None),
refresh_quorum: required_quorum,
owner: owner.to_string(),
resource: resource.clone(),
};
results.push(Ok(DistributedLockGuard::new(
LockId::new_unique(resource),
entries,
LockType::Exclusive,
heartbeat,
)));
} else {
let achieved = entries.len();
orphaned_entries.extend(entries.iter().map(HeldLockEntry::release_entry));
if let Some(error) = failure {
tracing::debug!(resource = %resource, owner, required_quorum, achieved, "batch lock acquire failed: {error}");
}
results.push(Err(LockError::QuorumNotReached {
required: required_quorum,
achieved,
}));
}
}
// One bounded synchronous pass so the caller cannot race these
// unlocks on an immediate retry; anything a slow peer still holds
// keeps retrying in the background instead of parking the request
// on the deferred unlock schedule.
if !orphaned_entries.is_empty() {
Self::release_entries_bounded(orphaned_entries, "distributed_lock_batch_quorum_rollback").await;
}
results
}
fn spawn_lock_requests_until( fn spawn_lock_requests_until(
&self, &self,
pending: &mut JoinSet<LockAcquireTaskResult>, pending: &mut JoinSet<LockAcquireTaskResult>,
@@ -755,6 +881,19 @@ impl DistributedLock {
Self::release_entries_deferred(pending, context).await; Self::release_entries_deferred(pending, context).await;
} }
/// Release entries with one synchronous pass, then continue the retry
/// schedule in the background.
///
/// The batch-acquire rollback path runs on the request: it must not park
/// the caller on the deferred unlock schedule when a peer is unreachable,
/// while still confirming the healthy quorum is free before returning.
async fn release_entries_bounded(entries: Vec<(LockId, Arc<dyn LockClient>)>, context: &'static str) {
let pending = Self::release_pending_once(entries, 1, context).await;
if !pending.is_empty() {
Self::spawn_release_cleanup(pending, context);
}
}
async fn release_pending_once( async fn release_pending_once(
pending: Vec<(LockId, Arc<dyn LockClient>)>, pending: Vec<(LockId, Arc<dyn LockClient>)>,
attempt: usize, attempt: usize,
@@ -2856,4 +2995,120 @@ mod tests {
drop(guard); drop(guard);
} }
/// Per-object scripted client: grants only the named objects and records
/// every released lock's resource.
#[derive(Debug)]
struct ScriptedBatchClient {
grants: Vec<String>,
releases: Arc<Mutex<Vec<String>>>,
}
impl ScriptedBatchClient {
fn build(grants: &[&str]) -> (Arc<dyn LockClient>, Arc<Mutex<Vec<String>>>) {
let releases = Arc::new(Mutex::new(Vec::new()));
let client = Arc::new(Self {
grants: grants.iter().map(|object| (*object).to_string()).collect(),
releases: Arc::clone(&releases),
}) as Arc<dyn LockClient>;
(client, releases)
}
}
#[async_trait::async_trait]
impl LockClient for ScriptedBatchClient {
async fn acquire_lock(&self, request: &LockRequest) -> crate::Result<LockResponse> {
if self.grants.iter().any(|object| object == request.resource.object.as_ref()) {
let info = LockInfo {
id: request.lock_id.clone(),
resource: request.resource.clone(),
lock_type: request.lock_type,
status: crate::types::LockStatus::Acquired,
owner: request.owner.clone(),
acquired_at: std::time::SystemTime::now(),
expires_at: std::time::SystemTime::now() + request.ttl,
last_refreshed: std::time::SystemTime::now(),
metadata: request.metadata.clone(),
priority: request.priority,
wait_start_time: None,
};
Ok(LockResponse::success(info, Duration::ZERO))
} else {
Ok(LockResponse::failure("denied by test script", Duration::ZERO))
}
}
async fn release(&self, lock_id: &LockId) -> crate::Result<bool> {
self.releases
.lock()
.expect("release log")
.push(lock_id.resource.object.to_string());
Ok(true)
}
async fn refresh(&self, _lock_id: &LockId) -> crate::Result<bool> {
Ok(true)
}
async fn force_release(&self, lock_id: &LockId) -> crate::Result<bool> {
self.release(lock_id).await
}
async fn check_status(&self, _lock_id: &LockId) -> crate::Result<Option<LockInfo>> {
Ok(None)
}
async fn get_stats(&self) -> crate::Result<LockStats> {
Ok(LockStats::default())
}
async fn close(&self) -> crate::Result<()> {
Ok(())
}
async fn is_online(&self) -> bool {
true
}
async fn is_local(&self) -> bool {
false
}
}
/// A batch key granted by fewer than quorum clients fails alone, and its
/// partial grants are released synchronously before the call returns
/// (PR9155 review coverage for the distributed rollback path).
#[tokio::test]
async fn acquire_guard_batch_releases_partial_grants_below_quorum() {
let (c0, c0_releases) = ScriptedBatchClient::build(&["kept", "lost"]);
let (c1, _c1_releases) = ScriptedBatchClient::build(&["kept"]);
let (c2, _c2_releases) = ScriptedBatchClient::build(&[]);
let lock = DistributedLock::new("batch-test".to_string(), vec![c0, c1, c2], 2);
let resources = vec![ObjectKey::new("bucket", "kept"), ObjectKey::new("bucket", "lost")];
let results = lock
.acquire_guard_batch(&resources, "owner", Duration::from_millis(200), Duration::from_secs(30))
.await;
assert!(results[0].is_ok(), "kept reached quorum: {:?}", results[0].as_ref().err());
let error = results[1].as_ref().expect_err("lost must fail below quorum");
assert!(
matches!(
error,
LockError::QuorumNotReached {
required: 2,
achieved: 1
}
),
"lost must report its partial vote: {error:?}"
);
// Exactly the orphaned grant is released before the call returns,
// while "kept" stays held by its live guard.
assert_eq!(
c0_releases.lock().expect("release log").clone(),
vec!["lost".to_string()],
"only the below-quorum key's partial grant is released synchronously"
);
drop(results);
}
} }
+9 -1
View File
@@ -153,7 +153,15 @@ impl LockShard {
{ {
return false; return false;
} }
self.try_fast_path(request).is_some() // Count the acquisition like `acquire_lock` does: batch callers use
// this path for every uncontended key, and the metric promises the
// total of successful acquisitions.
if self.try_fast_path(request).is_some() {
self.metrics.record_fast_path_success();
true
} else {
false
}
} }
/// Try fast path lock acquisition (lock-free when possible) /// Try fast path lock acquisition (lock-free when possible)
+58 -1
View File
@@ -15,7 +15,7 @@
use crate::{ use crate::{
GlobalLockManager, ObjectKey, GlobalLockManager, ObjectKey,
error::Result, error::Result,
fast_lock::{FastLockGuard, LockManager, LockMode, ObjectLockRequest}, fast_lock::{FastLockGuard, LockManager, LockMode, LockResult, ObjectLockRequest},
types::{LockPriority, LockRequest, LockType}, types::{LockPriority, LockRequest, LockType},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -112,4 +112,61 @@ impl LocalLock {
.with_ttl(ttl); .with_ttl(ttl);
self.acquire_guard(&req).await self.acquire_guard(&req).await
} }
/// Acquire exclusive locks for many independent resources in one batched
/// pass; see [`crate::distributed_lock::DistributedLock::acquire_guard_batch`].
/// The manager's batch acquire sorts every request into one global
/// (shard, key) order, so concurrent batches cannot deadlock. Returns one
/// guard or error per resource, in `resources` order.
pub async fn acquire_guard_batch(
&self,
resources: &[ObjectKey],
owner: &str,
timeout: Duration,
ttl: Duration,
) -> Vec<Result<FastLockGuard>> {
if resources.is_empty() {
return Vec::new();
}
let batch = crate::fast_lock::BatchLockRequest {
requests: resources
.iter()
.map(|resource| {
ObjectLockRequest::new_write(resource.clone(), owner)
.with_acquire_timeout(timeout)
.with_lock_timeout(ttl)
})
.collect(),
owner: owner.into(),
all_or_nothing: false,
};
let outcome = self.manager.acquire_locks_batch(batch).await;
let mut guards = outcome
.successful_locks
.into_iter()
.zip(outcome.guards)
.collect::<std::collections::HashMap<ObjectKey, FastLockGuard>>();
let mut failures = outcome
.failed_locks
.into_iter()
.collect::<std::collections::HashMap<ObjectKey, LockResult>>();
resources
.iter()
.map(|resource| {
if let Some(guard) = guards.remove(resource) {
return Ok(guard);
}
match failures.remove(resource) {
Some(LockResult::Timeout) => Err(crate::error::LockError::timeout(resource.to_string(), timeout)),
Some(LockResult::Conflict { current_owner, .. }) => Err(crate::error::LockError::AlreadyLocked {
resource: resource.to_string(),
owner: current_owner.to_string(),
}),
Some(LockResult::Acquired) | None => {
Err(crate::error::LockError::internal("batch lock acquire returned no outcome for key"))
}
}
})
.collect()
}
} }
+43
View File
@@ -88,6 +88,17 @@ impl NamespaceLockWrapper {
pub async fn get_read_lock(&self, timeout: Duration) -> std::result::Result<NamespaceLockGuard, crate::error::LockError> { pub async fn get_read_lock(&self, timeout: Duration) -> std::result::Result<NamespaceLockGuard, crate::error::LockError> {
self.lock.get_read_lock(self.resource.clone(), &self.owner, timeout).await self.lock.get_read_lock(self.resource.clone(), &self.owner, timeout).await
} }
/// Acquire write locks for `resources` in one batched round using this
/// wrapper's owner; see [`NamespaceLock::acquire_write_locks_batch`].
/// One guard or error per resource, in `resources` order.
pub async fn get_write_locks_batch(
&self,
resources: &[ObjectKey],
timeout: Duration,
) -> Vec<std::result::Result<NamespaceLockGuard, crate::error::LockError>> {
self.lock.acquire_write_locks_batch(resources, &self.owner, timeout).await
}
} }
impl NamespaceLockGuard { impl NamespaceLockGuard {
@@ -349,6 +360,38 @@ impl NamespaceLock {
} }
} }
/// Acquire exclusive locks for many independent resources in one batched
/// round; see [`crate::distributed_lock::DistributedLock::acquire_guard_batch`].
/// Returns one guard or error per resource, in `resources` order.
///
/// This is the lock primitive bulk mutations (S3 `DeleteObjects`) should
/// use: per-key quorum round-trips serialize a 1000-key batch, while the
/// batched form is one wire round per lock client and stays deadlock-free
/// without any canonical serial fallback (every lock server orders
/// concurrent batches by the same global key order).
pub async fn acquire_write_locks_batch(
&self,
resources: &[ObjectKey],
owner: &str,
timeout: Duration,
) -> Vec<std::result::Result<NamespaceLockGuard, crate::error::LockError>> {
let ttl = crate::fast_lock::DEFAULT_LOCK_TIMEOUT;
match self {
Self::Distributed(lock) => lock
.acquire_guard_batch(resources, owner, timeout, ttl)
.await
.into_iter()
.map(|result| result.map(NamespaceLockGuard::Standard))
.collect(),
Self::Local(lock) => lock
.acquire_guard_batch(resources, owner, timeout, ttl)
.await
.into_iter()
.map(|result| result.map(NamespaceLockGuard::Fast))
.collect(),
}
}
/// Get health information /// Get health information
pub async fn get_health(&self) -> crate::types::HealthInfo { pub async fn get_health(&self) -> crate::types::HealthInfo {
let lock_stats = self.get_stats().await; let lock_stats = self.get_stats().await;
+51
View File
@@ -41,6 +41,16 @@ pub(super) fn pyroscope_log_filter() -> FilterFn {
filter_fn(allows_non_pyroscope_dependency_event) filter_fn(allows_non_pyroscope_dependency_event)
} }
/// s3s verbose events include response bodies and expected signatures. Keep
/// diagnostics spans, but never let these events reach logs or OTLP exporters.
pub(super) fn s3s_log_filter() -> FilterFn {
filter_fn(|metadata| {
!(metadata.is_event()
&& (metadata.target() == "s3s" || metadata.target().starts_with("s3s::"))
&& *metadata.level() > tracing::Level::INFO)
})
}
/// Build an `EnvFilter` from the given log level string. /// Build an `EnvFilter` from the given log level string.
/// ///
/// If `default_level` is provided, it is used directly. Otherwise, the /// If `default_level` is provided, it is used directly. Otherwise, the
@@ -339,6 +349,47 @@ mod tests {
format!("{HTTP_SERVER_LOG_TARGET}={level}") format!("{HTTP_SERVER_LOG_TARGET}={level}")
} }
#[test]
fn s3s_filter_blocks_credentials_despite_explicit_verbose_directives() {
use tracing_subscriber::prelude::*;
for directive in ["trace", concat!("trace,", "s3s::service=trace,", "s3s::ops::signature=debug")] {
let writer = SharedWriter::default();
let captured = Arc::clone(&writer.0);
let subscriber = tracing_subscriber::registry()
.with(EnvFilter::new(directive))
.with(s3s_log_filter())
.with(
tracing_subscriber::fmt::layer()
.without_time()
.with_ansi(false)
.with_writer(writer)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::NEW),
);
with_default(subscriber, || {
let span = tracing::debug_span!(target: "s3s::service", "safe-operation-span");
let _entered = span.enter();
tracing::debug!(target: "s3s::service", response = "secret-response", "response dump");
tracing::trace!(target: "s3s", response = "secret-trace", "response dump");
tracing::debug!(target: "s3s::ops::signature", expected = "secret-signature", "signature mismatch");
tracing::info!(target: "s3s::service", "visible-info");
tracing::warn!(target: "s3s::service", "visible-warning");
tracing::debug!(target: "rustfs", "visible-rustfs-debug");
tracing::debug!(target: "s3s_other", "visible-other-target");
});
let output = String::from_utf8(captured.lock().expect("captured logs").clone()).expect("UTF-8 logs");
assert!(!output.contains("secret"), "{output}");
for expected in [
"safe-operation-span",
"visible-info",
"visible-warning",
"visible-rustfs-debug",
"visible-other-target",
] {
assert!(output.contains(expected), "missing {expected}: {output}");
}
}
}
#[test] #[test]
fn test_is_verbose_level() { fn test_is_verbose_level() {
assert!(is_verbose_level("debug")); assert!(is_verbose_level("debug"));
+3 -1
View File
@@ -36,7 +36,7 @@ use crate::cleaner::LogCleaner;
use crate::cleaner::types::{CompressionAlgorithm, FileMatchMode}; use crate::cleaner::types::{CompressionAlgorithm, FileMatchMode};
use crate::config::OtelConfig; use crate::config::OtelConfig;
use crate::global::{METRIC_LOG_CLEANER_RUN_FAILURES_TOTAL, METRIC_LOG_CLEANER_RUNS_TOTAL, set_observability_metric_enabled}; use crate::global::{METRIC_LOG_CLEANER_RUN_FAILURES_TOTAL, METRIC_LOG_CLEANER_RUNS_TOTAL, set_observability_metric_enabled};
use crate::telemetry::filter::{build_env_filter, effective_verbose_logging, pyroscope_log_filter}; use crate::telemetry::filter::{build_env_filter, effective_verbose_logging, pyroscope_log_filter, s3s_log_filter};
use crate::telemetry::rolling::{RollingAppender, Rotation}; use crate::telemetry::rolling::{RollingAppender, Rotation};
use metrics::counter; use metrics::counter;
use rustfs_config::observability::{ use rustfs_config::observability::{
@@ -369,6 +369,7 @@ fn init_stdout_only(_config: &OtelConfig, logger_level: &str, is_production: boo
tracing_subscriber::registry() tracing_subscriber::registry()
.with(env_filter) .with(env_filter)
.with(pyroscope_log_filter()) .with(pyroscope_log_filter())
.with(s3s_log_filter())
.with(ErrorLayer::default()) .with(ErrorLayer::default())
.with(fmt_layer) .with(fmt_layer)
.try_init() .try_init()
@@ -486,6 +487,7 @@ fn init_file_logging_internal(
tracing_subscriber::registry() tracing_subscriber::registry()
.with(env_filter) .with(env_filter)
.with(pyroscope_log_filter()) .with(pyroscope_log_filter())
.with(s3s_log_filter())
.with(ErrorLayer::default()) .with(ErrorLayer::default())
.with(file_layer) .with(file_layer)
.with(stdout_layer) .with(stdout_layer)

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